diff --git a/apps/ctrlshell/README.md b/apps/ctrlshell/README.md deleted file mode 100644 index 93a7e783..00000000 --- a/apps/ctrlshell/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Webshell Router - -Simple router that redirects web terminal requests to instances and vis-versa. diff --git a/apps/ctrlshell/src/agent-socket.ts b/apps/ctrlshell/src/agent-socket.ts deleted file mode 100644 index 94e06b3b..00000000 --- a/apps/ctrlshell/src/agent-socket.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { IncomingMessage } from "http"; -import type WebSocket from "ws"; -import type { MessageEvent } from "ws"; -import { v4 as uuidv4 } from "uuid"; - -import type { - AgentHeartbeat, - SessionCreate, - SessionDelete, - SessionInput, - SessionOutput, -} from "./payloads"; -import { agentHeartbeat, sessionOutput } from "./payloads"; -import { ifMessage } from "./utils"; - -export class AgentSocket { - static from(socket: WebSocket, request: IncomingMessage) { - if (request.headers["x-api-key"] == null) return null; - return new AgentSocket(socket, request); - } - - private stdoutListeners = new Set<(data: SessionOutput) => void>(); - readonly id: string; - - private constructor( - private readonly socket: WebSocket, - private readonly request: IncomingMessage, - ) { - this.id = uuidv4(); - this.socket.addEventListener( - "message", - ifMessage() - .is(sessionOutput, (data) => this.notifySubscribers(data)) - .is(agentHeartbeat, (data) => this.updateStatus(data)) - .handle(), - ); - } - - onSessionStdout(callback: (data: SessionOutput) => void) { - this.stdoutListeners.add(callback); - } - - private notifySubscribers(data: SessionOutput) { - for (const subscriber of this.stdoutListeners) { - subscriber(data); - } - } - - private updateStatus(data: AgentHeartbeat) { - console.log("status", data.timestamp); - } - - createSession(username = "", shell = "") { - const createSession: SessionCreate = { - type: "session.create", - username, - shell, - }; - - this.send(createSession); - - return this.waitForResponse( - (response) => response.type === "session.created", - ); - } - - async deleteSession(sessionId: string) { - const deletePayload: SessionDelete = { - type: "session.delete", - sessionId, - }; - this.send(deletePayload); - - return this.waitForResponse( - (response) => response.type === "session.delete.success", - ); - } - - waitForResponse(predicate: (response: any) => boolean, timeoutMs = 5000) { - return waitForResponse(this.socket, predicate, timeoutMs); - } - - send(data: SessionCreate | SessionDelete | SessionInput) { - return this.socket.send(JSON.stringify(data)); - } -} - -async function waitForResponse( - socket: WebSocket, - predicate: (response: any) => boolean, - timeoutMs = 5000, -): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - socket.removeEventListener("message", onMessage); - reject(new Error(`Response timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - const onMessage = (event: MessageEvent) => { - try { - const response = JSON.parse( - typeof event.data === "string" ? event.data : "", - ); - if (predicate(response)) { - clearTimeout(timeout); - socket.removeEventListener("message", onMessage); - resolve(response); - } - } catch { - clearTimeout(timeout); - socket.removeEventListener("message", onMessage); - reject(new Error("Failed to parse response")); - } - }; - - socket.addEventListener("message", onMessage); - }); -} diff --git a/apps/ctrlshell/src/payloads/index.ts b/apps/ctrlshell/src/payloads/index.ts deleted file mode 100644 index b225a1f8..00000000 --- a/apps/ctrlshell/src/payloads/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { z } from "zod"; - -import agentConnect from "./agent-connect"; -import agentHeartbeat from "./agent-heartbeat"; -import sessionCreate from "./session-create"; -import sessionDelete from "./session-delete"; -import sessionInput from "./session-input"; -import sessionOutput from "./session-output"; - -export type AgentHeartbeat = z.infer; -export type AgentConnect = z.infer; -export type SessionCreate = z.infer; -export type SessionInput = z.infer; -export type SessionOutput = z.infer; -export type SessionDelete = z.infer; - -export { - agentConnect, - agentHeartbeat, - sessionCreate, - sessionDelete, - sessionInput, - sessionOutput, -}; diff --git a/apps/ctrlshell/src/payloads/session-create.ts b/apps/ctrlshell/src/payloads/session-create.ts deleted file mode 100644 index 9b688ab7..00000000 --- a/apps/ctrlshell/src/payloads/session-create.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from "zod"; - -export default z.object({ - type: z - .literal("session.create") - .describe("Type of payload - must be session.create"), - sessionId: z.string().describe("Optional ID for the session").optional(), - username: z - .string() - .describe("Optional username for the session") - .default(""), - shell: z - .string() - .describe("Optional shell to use for the session") - .default(""), -}); diff --git a/apps/ctrlshell/src/payloads/session-input.ts b/apps/ctrlshell/src/payloads/session-input.ts deleted file mode 100644 index fad985e5..00000000 --- a/apps/ctrlshell/src/payloads/session-input.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from "zod"; - -export default z.object({ - type: z - .literal("session.input") - .describe( - "Type of payload - must be session.input to identify this as session input data", - ), - sessionId: z - .string() - .describe( - "Unique identifier of the PTY session that should receive this input data", - ), - data: z - .string() - .describe( - "The input data to send to the PTY session's standard input (stdin)", - ), -}); diff --git a/apps/ctrlshell/src/payloads/session-output.ts b/apps/ctrlshell/src/payloads/session-output.ts deleted file mode 100644 index 09888d34..00000000 --- a/apps/ctrlshell/src/payloads/session-output.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod"; - -export default z.object({ - type: z - .literal("session.output") - .describe( - "Type of payload - must be session.output to identify this as session output data", - ), - sessionId: z.string().describe("ID of the session that generated the output"), - data: z.string().describe("Output data from the PTY session"), -}); diff --git a/apps/ctrlshell/src/routing.ts b/apps/ctrlshell/src/routing.ts deleted file mode 100644 index 1c62f9e3..00000000 --- a/apps/ctrlshell/src/routing.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createServer } from "node:http"; -import type { Express } from "express"; -import type { IncomingMessage } from "node:http"; -import type WebSocket from "ws"; -import { WebSocketServer } from "ws"; - -import { AgentSocket } from "./agent-socket"; -import { auditSessions } from "./session-auditor"; -import { agents, users } from "./sockets"; -import { UserSocket } from "./user-socket"; - -const onConnect = async (ws: WebSocket, request: IncomingMessage) => { - auditSessions(ws); - - const agent = AgentSocket.from(ws, request); - if (agent != null) { - agents.set(agent.id, agent); - return; - } - - const user = await UserSocket.from(ws, request); - if (user != null) { - users.set(user.id, user); - return; - } - - ws.close(); -}; - -export const addSocket = (expressApp: Express) => { - const server = createServer(expressApp); - const wss = new WebSocketServer({ noServer: true }); - - server.on("upgrade", (request, socket, head) => { - if (request.url == null) { - socket.destroy(); - return; - } - - const { pathname } = new URL(request.url, "ws://base.ws"); - if (pathname !== "/api/shell/ws") { - socket.destroy(); - return; - } - - wss.handleUpgrade(request, socket, head, (ws) => { - wss.emit("connection", ws, request); - }); - }); - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - wss.on("connection", onConnect); - - return server; -}; diff --git a/apps/ctrlshell/src/session-auditor.ts b/apps/ctrlshell/src/session-auditor.ts deleted file mode 100644 index 3a1fddda..00000000 --- a/apps/ctrlshell/src/session-auditor.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type WebSocket from "ws"; -import { z } from "zod"; - -import { sessionInput, sessionOutput } from "./payloads"; -import { ifMessage } from "./utils"; - -export const auditSessions = (socket: WebSocket) => { - socket.addListener( - "message", - ifMessage() - .is(z.union([sessionOutput, sessionInput]), (data) => { - const timeStamp = new Date().toISOString(); - console.log(`${timeStamp} Output: ${data.data}`); - }) - .handle(), - ); -}; diff --git a/apps/ctrlshell/src/user-socket.ts b/apps/ctrlshell/src/user-socket.ts deleted file mode 100644 index 163df018..00000000 --- a/apps/ctrlshell/src/user-socket.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { IncomingMessage } from "node:http"; -import type WebSocket from "ws"; -import { v4 as uuidv4 } from "uuid"; - -import { getSession } from "./auth"; - -export class UserSocket { - static async from(socket: WebSocket, request: IncomingMessage) { - const session = await getSession(request); - if (session == null) return null; - - const { user } = session; - if (user == null) return null; - - console.log(`${user.name ?? user.email} (${user.id}) connected`); - return new UserSocket(socket, request); - } - - readonly id: string; - - private constructor( - private readonly socket: WebSocket, - private readonly request: IncomingMessage, - ) { - this.id = uuidv4(); - } -} diff --git a/apps/ctrlshell/src/utils.ts b/apps/ctrlshell/src/utils.ts deleted file mode 100644 index 47a9630d..00000000 --- a/apps/ctrlshell/src/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MessageEvent } from "ws"; -import type { z } from "zod"; - -export const ifMessage = () => { - const checks: ((event: MessageEvent) => void)[] = []; - return { - is(schema: z.ZodSchema, callback: (data: T) => void) { - checks.push((e: MessageEvent) => { - const result = schema.safeParse(e.data); - if (result.success) { - callback(result.data); - } - }); - return this; - }, - handle() { - return (event: MessageEvent) => { - for (const check of checks) check(event); - }; - }, - }; -}; diff --git a/apps/ctrlshell/Dockerfile b/apps/target-proxy/Dockerfile similarity index 93% rename from apps/ctrlshell/Dockerfile rename to apps/target-proxy/Dockerfile index 401d3c68..711b6d61 100644 --- a/apps/ctrlshell/Dockerfile +++ b/apps/target-proxy/Dockerfile @@ -36,4 +36,4 @@ RUN adduser --system --uid 1001 expressjs USER expressjs COPY --from=installer /app . -CMD ["node", "apps/webshell-router/dist/index.js"] +CMD ["node", "apps/target-proxy/dist/index.js"] diff --git a/apps/target-proxy/README.md b/apps/target-proxy/README.md new file mode 100644 index 00000000..6597e7db --- /dev/null +++ b/apps/target-proxy/README.md @@ -0,0 +1,30 @@ +# Target Proxy Router + +Simple router that redirects web terminal requests to instances and vis-versa. + +### Sequence Diagram + +```mermaid +sequenceDiagram + autonumber + + participant AG as Agebt + participant PR as Proxy + participant CP as Ctrlplane + participant DE as Developer + + opt Init Agent + AG->>PR: Connects to Proxy + PR->>CP: Register as target + AG->>PR: Sends heartbeat + end + + opt Session + DE->>CP: Opens session + CP->>PR: Forwards commands + PR->>AG: Receives commands + AG->>PR: Sends output + PR->>CP: Sends output + CP->>DE: Displays output + end +``` diff --git a/apps/ctrlshell/eslint.config.js b/apps/target-proxy/eslint.config.js similarity index 100% rename from apps/ctrlshell/eslint.config.js rename to apps/target-proxy/eslint.config.js diff --git a/apps/ctrlshell/package.json b/apps/target-proxy/package.json similarity index 80% rename from apps/ctrlshell/package.json rename to apps/target-proxy/package.json index eeabe487..2bb3793c 100644 --- a/apps/ctrlshell/package.json +++ b/apps/target-proxy/package.json @@ -1,15 +1,19 @@ { - "name": "@ctrlplane/ctrlshell", + "name": "@ctrlplane/target-proxy", "private": true, "type": "module", "scripts": { "clean": "rm -rf .turbo node_modules", - "dev": "tsx watch --clear-screen=false src/index.ts", + "dev:t": "pnpm with-env tsx watch --clear-screen=false src/index.ts", "lint": "eslint", - "format": "prettier --check . --ignore-path ../../.gitignore" + "format": "prettier --check . --ignore-path ../../.gitignore", + "with-env": "dotenv -e ../../.env --" }, "dependencies": { "@ctrlplane/db": "workspace:*", + "@ctrlplane/job-dispatch": "workspace:*", + "@ctrlplane/logger": "workspace:*", + "@ctrlplane/validators": "workspace:*", "@t3-oss/env-core": "catalog:", "cookie-parser": "^1.4.6", "cors": "^2.8.5", diff --git a/apps/ctrlshell/src/auth.ts b/apps/target-proxy/src/auth.ts similarity index 100% rename from apps/ctrlshell/src/auth.ts rename to apps/target-proxy/src/auth.ts diff --git a/apps/ctrlshell/src/config.ts b/apps/target-proxy/src/config.ts similarity index 100% rename from apps/ctrlshell/src/config.ts rename to apps/target-proxy/src/config.ts diff --git a/apps/target-proxy/src/controller/agent-socket.ts b/apps/target-proxy/src/controller/agent-socket.ts new file mode 100644 index 00000000..2c97bd11 --- /dev/null +++ b/apps/target-proxy/src/controller/agent-socket.ts @@ -0,0 +1,147 @@ +import type { InsertTarget, Target } from "@ctrlplane/db/schema"; +import type { + AgentHeartbeat, + SessionCreate, + SessionDelete, + SessionResize, +} from "@ctrlplane/validators/session"; +import type { IncomingMessage } from "http"; +import type WebSocket from "ws"; +import type { MessageEvent } from "ws"; + +import { eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; +import { upsertTargets } from "@ctrlplane/job-dispatch"; +import { logger } from "@ctrlplane/logger"; +import { agentConnect, agentHeartbeat } from "@ctrlplane/validators/session"; + +import { ifMessage } from "./utils"; + +export class AgentSocket { + static async from(socket: WebSocket, request: IncomingMessage) { + const agentName = request.headers["x-agent-name"]?.toString(); + if (agentName == null) { + logger.warn("Agent connection rejected - missing agent name"); + return null; + } + + const apiKey = request.headers["x-api-key"]?.toString(); + if (apiKey == null) { + logger.error("Agent connection rejected - missing API key"); + throw new Error("API key is required."); + } + + const workspaceSlug = request.headers["x-workspace"]?.toString(); + if (workspaceSlug == null) { + logger.error("Agent connection rejected - missing workspace slug"); + throw new Error("Workspace slug is required."); + } + + const workspace = await db.query.workspace.findFirst({ + where: eq(schema.workspace.slug, workspaceSlug), + }); + if (workspace == null) { + logger.error("Agent connection rejected - workspace not found"); + return null; + } + + const targetInfo: InsertTarget = { + name: agentName, + version: "ctrlplane/v1", + kind: "TargetSession", + identifier: `ctrlplane/target-agent/${agentName}`, + workspaceId: workspace.id, + }; + const [target] = await upsertTargets(db, [targetInfo]); + if (target == null) return null; + return new AgentSocket(socket, request, target); + } + + private constructor( + private readonly socket: WebSocket, + private readonly _: IncomingMessage, + public readonly target: Target, + ) { + this.target = target; + this.socket.on( + "message", + ifMessage() + .is(agentConnect, async (data) => { + await upsertTargets(db, [ + { + ...target, + config: data.config, + metadata: data.metadata, + version: "ctrlplane/v1", + }, + ]); + }) + .is(agentHeartbeat, (data) => this.updateStatus(data)) + .handle(), + ); + } + + private updateStatus(data: AgentHeartbeat) { + console.log("status", data.timestamp); + } + + createSession(session: SessionCreate) { + this.send(session); + return this.waitForResponse( + (response) => response.type === "session.created", + ); + } + + async deleteSession(sessionId: string) { + const deletePayload: SessionDelete = { + type: "session.delete", + sessionId, + }; + this.send(deletePayload); + + return this.waitForResponse( + (response) => response.type === "session.delete.success", + ); + } + + waitForResponse(predicate: (response: any) => boolean, timeoutMs = 5000) { + return waitForResponse(this.socket, predicate, timeoutMs); + } + + send(data: SessionCreate | SessionDelete | SessionResize) { + return this.socket.send(JSON.stringify(data)); + } +} + +async function waitForResponse( + socket: WebSocket, + predicate: (response: any) => boolean, + timeoutMs = 5000, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.removeEventListener("message", onMessage); + reject(new Error(`Response timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + const onMessage = (event: MessageEvent) => { + try { + const response = JSON.parse( + typeof event.data === "string" ? event.data : "", + ); + if (predicate(response)) { + clearTimeout(timeout); + socket.removeEventListener("message", onMessage); + resolve(response); + } + } catch { + clearTimeout(timeout); + socket.removeEventListener("message", onMessage); + reject(new Error("Failed to parse response")); + } + }; + + socket.addEventListener("message", onMessage); + }); +} diff --git a/apps/target-proxy/src/controller/index.ts b/apps/target-proxy/src/controller/index.ts new file mode 100644 index 00000000..4d2e3814 --- /dev/null +++ b/apps/target-proxy/src/controller/index.ts @@ -0,0 +1,57 @@ +import type { IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import type WebSocket from "ws"; +import { WebSocketServer } from "ws"; + +import { logger } from "@ctrlplane/logger"; + +import { AgentSocket } from "./agent-socket"; +import { agents, users } from "./sockets"; +import { UserSocket } from "./user-socket"; + +const onConnect = async (ws: WebSocket, request: IncomingMessage) => { + const agent = await AgentSocket.from(ws, request); + if (agent != null) { + logger.info("Agent connected", { + targetId: agent.target.id, + name: agent.target.name, + }); + agents.set(agent.target.id, agent); + return; + } + + const user = await UserSocket.from(ws, request); + if (user != null) { + logger.info("User connected", { + userId: user.user.id, + }); + users.set(user.user.id, user); + return; + } + + logger.warn("Connection rejected - neither agent nor user"); + ws.close(); +}; + +const wss = new WebSocketServer({ noServer: true }); +wss.on("connection", (ws, request) => { + logger.debug("WebSocket connection established"); + onConnect(ws, request).catch((error) => { + logger.error("Error handling connection", { error }); + ws.close(); + }); +}); + +export const controllerOnUpgrade = ( + request: IncomingMessage, + socket: Duplex, + head: Buffer, +) => { + logger.debug("WebSocket upgrade request received", { + url: request.url, + }); + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); +}; diff --git a/apps/ctrlshell/src/sockets.ts b/apps/target-proxy/src/controller/sockets.ts similarity index 100% rename from apps/ctrlshell/src/sockets.ts rename to apps/target-proxy/src/controller/sockets.ts diff --git a/apps/target-proxy/src/controller/user-socket.ts b/apps/target-proxy/src/controller/user-socket.ts new file mode 100644 index 00000000..bff2bd35 --- /dev/null +++ b/apps/target-proxy/src/controller/user-socket.ts @@ -0,0 +1,97 @@ +import type { SessionOutput } from "@ctrlplane/validators/session"; +import type { IncomingMessage } from "node:http"; +import type WebSocket from "ws"; +import { createSessionSocket } from "@/sessions"; + +import { logger } from "@ctrlplane/logger"; +import { sessionCreate, sessionResize } from "@ctrlplane/validators/session"; + +import { getSession } from "../auth"; +import { agents } from "./sockets"; +import { ifMessage } from "./utils"; + +type User = { id: string }; + +export class UserSocket { + static async from(socket: WebSocket, request: IncomingMessage) { + const session = await getSession(request); + if (session == null) { + logger.warn("User connection rejected - no session found"); + return null; + } + + const { user } = session; + if (user == null) { + logger.error("User connection rejected - no user in session"); + return null; + } + if (user.id == null) { + logger.error("User connection rejected - no user ID"); + return null; + } + + logger.info(`User connection accepted ${user.email}`, { + userId: user.id, + }); + return new UserSocket(socket, request, { id: user.id }); + } + + private constructor( + private readonly socket: WebSocket, + private readonly _: IncomingMessage, + public readonly user: User, + ) { + this.socket.on( + "message", + ifMessage() + .is(sessionCreate, (data) => { + logger.debug("Received session create request", { + targetId: data.targetId, + sessionId: data.sessionId, + userId: user.id, + }); + + const agent = agents.get(data.targetId); + if (agent == null) { + logger.warn("Agent not found for session create", { + targetId: data.targetId, + sessionId: data.sessionId, + userId: user.id, + }); + return; + } + + logger.info("Found agent for session create", { + targetId: data.targetId, + agentName: agent.target.name, + }); + + createSessionSocket(data.sessionId); + agent.send(data); + }) + .is(sessionResize, (data) => { + const { sessionId, targetId } = data; + logger.info("Received session resize request", { + sessionId: data.sessionId, + userId: user.id, + }); + + const agent = agents.get(targetId); + if (agent == null) { + logger.warn("Agent not found for session resize", { + targetId, + sessionId, + }); + return; + } + + agent.send(data); + }) + .handle(), + ); + } + + send(data: SessionOutput) { + return this.socket.send(JSON.stringify(data)); + } +} diff --git a/apps/target-proxy/src/controller/utils.ts b/apps/target-proxy/src/controller/utils.ts new file mode 100644 index 00000000..792d5cac --- /dev/null +++ b/apps/target-proxy/src/controller/utils.ts @@ -0,0 +1,30 @@ +import type { RawData } from "ws"; +import type { z } from "zod"; + +export const ifMessage = () => { + const checks: ((event: RawData, isBinary: boolean) => void)[] = []; + return { + is(schema: z.ZodSchema, callback: (data: T) => Promise | void) { + checks.push((e: RawData | MessageEvent) => { + const stringData = JSON.stringify(e); + let data = JSON.parse(stringData); + + if (data.type === "Buffer") + data = JSON.parse(Buffer.from(data.data).toString()); + + const result = schema.safeParse(data); + if (result.success) { + const maybePromise = callback(result.data); + if (maybePromise instanceof Promise) + maybePromise.catch(console.error); + } + }); + return this; + }, + handle() { + return (event: RawData, isBinary: boolean) => { + for (const check of checks) check(event, isBinary); + }; + }, + }; +}; diff --git a/apps/ctrlshell/src/index.ts b/apps/target-proxy/src/index.ts similarity index 100% rename from apps/ctrlshell/src/index.ts rename to apps/target-proxy/src/index.ts diff --git a/apps/target-proxy/src/routing.ts b/apps/target-proxy/src/routing.ts new file mode 100644 index 00000000..708b70c6 --- /dev/null +++ b/apps/target-proxy/src/routing.ts @@ -0,0 +1,28 @@ +import { createServer } from "node:http"; +import type { Express } from "express"; + +import { controllerOnUpgrade } from "./controller"; +import { sessionOnUpgrade } from "./sessions"; + +export const addSocket = (expressApp: Express) => { + const server = createServer(expressApp); + server.on("upgrade", (request, socket, head) => { + if (request.url == null) { + socket.destroy(); + return; + } + + const { pathname } = new URL(request.url, "ws://base.ws"); + if (pathname.startsWith("/api/v1/target/proxy/session")) { + sessionOnUpgrade(request, socket, head); + return; + } + + if (pathname.startsWith("/api/v1/target/proxy/controller")) { + controllerOnUpgrade(request, socket, head); + return; + } + socket.destroy(); + }); + return server; +}; diff --git a/apps/ctrlshell/src/server.ts b/apps/target-proxy/src/server.ts similarity index 84% rename from apps/ctrlshell/src/server.ts rename to apps/target-proxy/src/server.ts index e86b3928..695e1578 100644 --- a/apps/ctrlshell/src/server.ts +++ b/apps/target-proxy/src/server.ts @@ -27,4 +27,9 @@ app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use(cookieParser()); +// Health check endpoint +app.get("/api/proxy/health", (_req, res) => { + res.status(200).json({ status: "ok" }); +}); + export { app }; diff --git a/apps/target-proxy/src/session-auditor.ts b/apps/target-proxy/src/session-auditor.ts new file mode 100644 index 00000000..f48b7ee7 --- /dev/null +++ b/apps/target-proxy/src/session-auditor.ts @@ -0,0 +1,15 @@ +import type WebSocket from "ws"; +import { z } from "zod"; + +import { ifMessage } from "./controller/utils"; + +export const auditSessions = (socket: WebSocket) => { + socket.on( + "message", + ifMessage() + .is(z.any(), (data) => { + console.log(data); + }) + .handle(), + ); +}; diff --git a/apps/target-proxy/src/sessions/index.ts b/apps/target-proxy/src/sessions/index.ts new file mode 100644 index 00000000..d9419cc0 --- /dev/null +++ b/apps/target-proxy/src/sessions/index.ts @@ -0,0 +1,100 @@ +import type { IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import { WebSocketServer } from "ws"; + +import { logger } from "@ctrlplane/logger"; + +import { sessionServers } from "./servers"; + +const MAX_HISTORY_BYTES = 1024; + +interface MessageHistory { + messages: Buffer[]; + totalSize: number; +} + +const createMessageHistory = (): MessageHistory => ({ + messages: [], + totalSize: 0, +}); + +const addMessageToHistory = (history: MessageHistory, msg: Buffer) => { + history.messages.push(msg); + history.totalSize += msg.length; + + while (history.totalSize > MAX_HISTORY_BYTES && history.messages.length > 0) { + const oldMsg = history.messages.shift(); + if (oldMsg) { + history.totalSize -= oldMsg.length; + } + } +}; + +export const createSessionSocket = (sessionId: string) => { + logger.info("Creating session socket", { sessionId }); + const wss = new WebSocketServer({ noServer: true }); + sessionServers.set(sessionId, wss); + + // Store messages up to 1024 bytes to replay to new clients + const messageHistory = createMessageHistory(); + + wss.on("connection", (ws) => { + logger.info("Session connection established", { sessionId }); + + // Send message history to new client + messageHistory.messages.forEach((msg) => { + ws.send(msg); + }); + + ws.on("message", (msg) => { + const msgBuffer = Buffer.from(msg as Buffer); + addMessageToHistory(messageHistory, msgBuffer); + wss.clients.forEach((client) => { + if (client !== ws) client.send(msg); + }); + }); + + ws.on("close", () => { + logger.info("Session connection closed", { sessionId }); + sessionServers.delete(sessionId); + }); + }); + + return wss; +}; + +const getSessionId = (request: IncomingMessage) => { + if (request.url == null) return null; + + const { pathname } = new URL(request.url, "ws://base.ws"); + const sessionId = pathname.split("/").at(-1); + logger.info("Extracted session ID from path", { sessionId }); + + return sessionId === "" ? null : sessionId; +}; + +export const sessionOnUpgrade = ( + request: IncomingMessage, + socket: Duplex, + head: Buffer, +) => { + const sessionId = getSessionId(request); + if (sessionId == null) { + logger.warn("Session upgrade rejected - no session ID", { + url: request.url, + }); + socket.destroy(); + return; + } + + const wss = sessionServers.get(sessionId); + if (wss == null) { + logger.warn("Session upgrade rejected - session not found", { sessionId }); + socket.destroy(); + return; + } + + wss.handleUpgrade(request, socket, head, (ws, req) => { + wss.emit("connection", ws, req); + }); +}; diff --git a/apps/target-proxy/src/sessions/servers.ts b/apps/target-proxy/src/sessions/servers.ts new file mode 100644 index 00000000..4c4fd7bb --- /dev/null +++ b/apps/target-proxy/src/sessions/servers.ts @@ -0,0 +1,3 @@ +import type { WebSocketServer } from "ws"; + +export const sessionServers = new Map(); diff --git a/apps/ctrlshell/tsconfig.json b/apps/target-proxy/tsconfig.json similarity index 100% rename from apps/ctrlshell/tsconfig.json rename to apps/target-proxy/tsconfig.json diff --git a/apps/ctrlshell/types.d.ts b/apps/target-proxy/types.d.ts similarity index 100% rename from apps/ctrlshell/types.d.ts rename to apps/target-proxy/types.d.ts diff --git a/apps/webservice/next.config.js b/apps/webservice/next.config.js index 864f1aea..0fe32fd5 100644 --- a/apps/webservice/next.config.js +++ b/apps/webservice/next.config.js @@ -36,8 +36,12 @@ const config = { async rewrites() { return [ { - source: "/webshell/ws", - destination: "http://localhost:4000/webshell/ws", + source: "/api/v1/target/proxy/controller", + destination: "http://localhost:4000/api/v1/target/proxy/controller", + }, + { + source: "/api/v1/target/proxy/session/:path*", + destination: "http://localhost:4000/api/v1/target/proxy/session/:path*", }, ]; }, diff --git a/apps/webservice/package.json b/apps/webservice/package.json index 3080274f..c54db770 100644 --- a/apps/webservice/package.json +++ b/apps/webservice/package.json @@ -42,6 +42,14 @@ "@trpc/client": "11.0.0-rc.364", "@trpc/react-query": "11.0.0-rc.364", "@trpc/server": "11.0.0-rc.364", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-clipboard": "^0.1.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-unicode11": "^0.8.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "add": "^2.0.6", "change-case": "^5.4.4", "dagre": "^0.8.5", "date-fns": "catalog:", @@ -55,6 +63,7 @@ "next": "catalog:", "next-auth": "catalog:", "next-themes": "^0.3.0", + "pnpm": "^9.12.3", "pretty-ms": "^9.0.0", "randomcolor": "^0.6.2", "react": "catalog:react18", @@ -62,6 +71,7 @@ "react-grid-layout": "^1.4.4", "react-hook-form": "^7.51.4", "react-use": "^17.5.0", + "react-use-websocket": "^4.10.1", "reactflow": "^11.11.3", "recharts": "^2.1.12", "semver": "^7.6.2", @@ -88,6 +98,7 @@ "@types/react-grid-layout": "^1.3.5", "@types/swagger-ui-react": "^4.18.3", "@types/uuid": "^10.0.0", + "@xterm/addon-webgl": "^0.18.0", "atlassian-openapi": "^1.0.19", "dotenv-cli": "^7.4.2", "eslint": "catalog:", diff --git a/apps/webservice/src/app/[workspaceSlug]/SidebarCreateMenu.tsx b/apps/webservice/src/app/[workspaceSlug]/SidebarCreateMenu.tsx index 5cb1f78f..121f5683 100644 --- a/apps/webservice/src/app/[workspaceSlug]/SidebarCreateMenu.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/SidebarCreateMenu.tsx @@ -16,6 +16,7 @@ import { CreateDeploymentDialog } from "./_components/CreateDeployment"; import { CreateReleaseDialog } from "./_components/CreateRelease"; import { CreateSystemDialog } from "./_components/CreateSystem"; import { CreateTargetDialog } from "./_components/CreateTarget"; +import { CreateSessionDialog } from "./_components/terminal/CreateDialogSession"; export const SidebarCreateMenu: React.FC<{ workspace: Workspace; @@ -64,6 +65,11 @@ export const SidebarCreateMenu: React.FC<{ Execute Runbook + + e.preventDefault()}> + Remote Session + + diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/terminal/CreateDialogSession.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/terminal/CreateDialogSession.tsx new file mode 100644 index 00000000..60e15812 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/terminal/CreateDialogSession.tsx @@ -0,0 +1,144 @@ +"use client"; + +import React, { useState } from "react"; +import { useParams } from "next/navigation"; +import { IconCheck, IconSelector } from "@tabler/icons-react"; + +import { cn } from "@ctrlplane/ui"; +import { Button } from "@ctrlplane/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@ctrlplane/ui/command"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { Label } from "@ctrlplane/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover"; + +import { api } from "~/trpc/react"; +import { useTerminalSessions } from "./TerminalSessionsProvider"; + +export const CreateSessionDialog: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [isModalOpen, setModelOpen] = useState(false); + const { createSession, setIsDrawerOpen } = useTerminalSessions(); + + const [targetId, setTargetId] = React.useState(""); + + const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); + const workspace = api.workspace.bySlug.useQuery(workspaceSlug); + + const targets = api.target.byWorkspaceId.list.useQuery( + { + workspaceId: workspace.data?.id ?? "", + limit: 500, + filter: { + type: "kind", + operator: "equals", + value: "TargetSession", + }, + }, + { enabled: workspace.data != null }, + ); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + + {children} + + + Create Remote Session + +
+
+ + + + + + + + + + + + No target found. + + + {targets.data?.items.map((target) => ( + { + setTargetId( + currentValue === targetId ? "" : currentValue, + ); + setIsPopoverOpen(false); + }} + > + {target.name} + + + ))} + + + + + +
+
+ + + +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/terminal/TerminalSessionsDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/terminal/TerminalSessionsDrawer.tsx new file mode 100644 index 00000000..30dbbbc9 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/terminal/TerminalSessionsDrawer.tsx @@ -0,0 +1,137 @@ +"use client"; + +import React, { Fragment } from "react"; +import { IconCircleFilled, IconPlus, IconX } from "@tabler/icons-react"; +import { createPortal } from "react-dom"; +import useWebSocket, { ReadyState } from "react-use-websocket"; + +import { cn } from "@ctrlplane/ui"; +import { Button } from "@ctrlplane/ui/button"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@ctrlplane/ui/resizable"; + +import { SocketTerminal } from "~/components/xterm/SessionTerminal"; +import { api } from "~/trpc/react"; +import { CreateSessionDialog } from "./CreateDialogSession"; +import { useTerminalSessions } from "./TerminalSessionsProvider"; +import { useResizableHeight } from "./useResizableHeight"; + +const MIN_HEIGHT = 200; +const DEFAULT_HEIGHT = 300; + +const SessionTerminal: React.FC<{ sessionId: string; targetId: string }> = ({ + sessionId, + targetId, +}) => { + const target = api.target.byId.useQuery(targetId); + const { resizeSession } = useTerminalSessions(); + const { getWebSocket, readyState } = useWebSocket( + `/api/v1/target/proxy/session/${sessionId}`, + { shouldReconnect: () => true }, + ); + const connectionStatus = { + [ReadyState.CONNECTING]: "Connecting", + [ReadyState.OPEN]: "Open", + [ReadyState.CLOSING]: "Closing", + [ReadyState.CLOSED]: "Closed", + [ReadyState.UNINSTANTIATED]: "Uninstantiated", + }[readyState]; + + return ( + <> +
+ {target.data?.name} ({targetId} / {sessionId}) +
+ + + + {connectionStatus} +
+
+ + {readyState === ReadyState.OPEN && ( +
+ + resizeSession(sessionId, targetId, cols, rows) + } + /> +
+ )} + + ); +}; + +const TerminalSessionsContent: React.FC = () => { + const { sessionIds, setIsDrawerOpen } = useTerminalSessions(); + return ( +
+
+ + + + +
+ + + {sessionIds.map((s, idx) => ( + + {idx !== 0 && } + + + + + ))} + +
+ ); +}; + +export const TerminalDrawer: React.FC = () => { + const { height, handleMouseDown } = useResizableHeight( + DEFAULT_HEIGHT, + MIN_HEIGHT, + ); + const { isDrawerOpen } = useTerminalSessions(); + + return createPortal( +
+
+ + +
, + document.body, + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/terminal/TerminalSessionsProvider.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/terminal/TerminalSessionsProvider.tsx new file mode 100644 index 00000000..ec9b3b03 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/terminal/TerminalSessionsProvider.tsx @@ -0,0 +1,97 @@ +"use client"; + +import type { + SessionCreate, + SessionResize, +} from "@ctrlplane/validators/session"; +import React, { createContext, useContext, useState } from "react"; +import useWebSocket from "react-use-websocket"; +import { v4 as uuidv4 } from "uuid"; + +type SessionContextType = { + isDrawerOpen: boolean; + setIsDrawerOpen: (open: boolean) => void; + sessionIds: { sessionId: string; targetId: string }[]; + createSession: (targetId: string) => void; + removeSession: (id: string) => void; + resizeSession: ( + sessionId: string, + targetId: string, + cols: number, + rows: number, + ) => void; +}; + +const SessionContext = createContext(undefined); + +export const useTerminalSessions = () => { + const context = useContext(SessionContext); + if (!context) + throw new Error("useSession must be used within a SessionProvider"); + + return context; +}; + +const url = "/api/v1/target/proxy/controller"; +export const TerminalSessionsProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const [sessionIds, setSessionIds] = useState< + { sessionId: string; targetId: string }[] + >([]); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { sendJsonMessage } = useWebSocket(url, { + shouldReconnect: () => true, + }); + + const resizeSession = ( + sessionId: string, + targetId: string, + cols: number, + rows: number, + ) => { + const resizePayload: SessionResize = { + type: "session.resize", + sessionId, + targetId, + cols, + rows, + }; + console.log(resizePayload); + sendJsonMessage(resizePayload); + }; + + const createSession = (targetId: string) => { + const sessionId = uuidv4(); + const sessionCreatePayload: SessionCreate = { + type: "session.create", + targetId, + sessionId, + cols: 80, + rows: 24, + }; + sendJsonMessage(sessionCreatePayload); + setTimeout(() => { + setSessionIds((prev) => [...prev, { sessionId, targetId }]); + }, 500); + }; + const removeSession = (id: string) => { + setSessionIds((prev) => prev.filter((session) => session.sessionId !== id)); + }; + + return ( + + {children} + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/terminal/useResizableHeight.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/terminal/useResizableHeight.tsx new file mode 100644 index 00000000..fec60315 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/terminal/useResizableHeight.tsx @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export const useResizableHeight = ( + initialHeight: number, + minHeight: number, +) => { + const [height, setHeight] = useState(initialHeight); + const [isDragging, setIsDragging] = useState(false); + const dragStartY = useRef(0); + const dragStartHeight = useRef(0); + + const handleMouseDown = useCallback>( + (e) => { + setIsDragging(true); + dragStartY.current = e.clientY; + dragStartHeight.current = height; + }, + [height], + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + + const deltaY = dragStartY.current - e.clientY; + const newHeight = Math.max(minHeight, dragStartHeight.current + deltaY); + setHeight(newHeight); + }, + [isDragging, minHeight], + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + } + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return { height, handleMouseDown }; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/layout.tsx index 1ce59f75..58feb751 100644 --- a/apps/webservice/src/app/[workspaceSlug]/layout.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/layout.tsx @@ -1,3 +1,4 @@ +import dynamic from "next/dynamic"; import { notFound, redirect } from "next/navigation"; import { auth } from "@ctrlplane/auth"; @@ -9,9 +10,18 @@ import { JobDrawer } from "./_components/job-drawer/JobDrawer"; import { ReleaseChannelDrawer } from "./_components/release-channel-drawer/ReleaseChannelDrawer"; import { ReleaseDrawer } from "./_components/release-drawer/ReleaseDrawer"; import { TargetDrawer } from "./_components/target-drawer/TargetDrawer"; +import { TerminalSessionsProvider } from "./_components/terminal/TerminalSessionsProvider"; import { VariableSetDrawer } from "./_components/variable-set-drawer/VariableSetDrawer"; import { SidebarPanels } from "./SidebarPanels"; +const TerminalDrawer = dynamic( + () => + import("./_components/terminal/TerminalSessionsDrawer").then( + (t) => t.TerminalDrawer, + ), + { ssr: false }, +); + export default async function WorkspaceLayout({ children, params, @@ -30,7 +40,7 @@ export default async function WorkspaceLayout({ const systems = await api.system.list({ workspaceId: workspace.id }); return ( - <> +
{children} @@ -43,6 +53,7 @@ export default async function WorkspaceLayout({ - + + ); } diff --git a/apps/webservice/src/app/[workspaceSlug]/term/Terminal.tsx b/apps/webservice/src/app/[workspaceSlug]/term/Terminal.tsx new file mode 100644 index 00000000..1d7da29f --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/term/Terminal.tsx @@ -0,0 +1,33 @@ +import React, { useEffect } from "react"; +import useWebSocket, { ReadyState } from "react-use-websocket"; + +import { useSessionTerminal } from "~/components/xterm/SessionTerminal"; + +export const SessionTerminal: React.FC<{ sessionId: string }> = ({ + sessionId, +}) => { + console.log(sessionId); + const { getWebSocket, readyState } = useWebSocket( + `/api/v1/target/proxy/session/${sessionId}`, + ); + + const { terminalRef, divRef, fitAddon } = useSessionTerminal( + getWebSocket, + readyState, + ); + + useEffect(() => { + if (readyState !== ReadyState.OPEN) return; + if (terminalRef.current == null) return; + terminalRef.current.focus(); + fitAddon.fit(); + }, [getWebSocket, terminalRef, fitAddon, readyState]); + + return ( +
+
+
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/term/page.tsx b/apps/webservice/src/app/[workspaceSlug]/term/page.tsx new file mode 100644 index 00000000..0173d53b --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/term/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { SessionCreate } from "@ctrlplane/validators/session"; +import { useState } from "react"; +import dynamic from "next/dynamic"; +import useWebSocket, { ReadyState } from "react-use-websocket"; +import { v4 as uuidv4 } from "uuid"; + +import { Button } from "@ctrlplane/ui/button"; + +const Terminal = dynamic( + () => import("./Terminal").then((mod) => mod.SessionTerminal), + { ssr: false }, +); +export default function TermPage() { + const { sendJsonMessage, readyState } = useWebSocket( + "/api/v1/target/proxy/controller", + ); + const [targetId] = useState("f8610471-5077-4fb3-8c93-f82f7301bb2f"); + + const [sessionId, setSessionId] = useState(""); + const createSession = () => { + const sessionId = uuidv4(); + const sessionCreatePayload: SessionCreate = { + type: "session.create", + targetId, + sessionId, + cols: 80, + rows: 24, + }; + sendJsonMessage(sessionCreatePayload); + setSessionId(sessionId); + }; + + return ( +
+ + + {readyState === ReadyState.OPEN && sessionId !== "" && ( + + )} +
+ ); +} diff --git a/apps/webservice/src/components/xterm/SessionTerminal.tsx b/apps/webservice/src/components/xterm/SessionTerminal.tsx new file mode 100644 index 00000000..9f25cb10 --- /dev/null +++ b/apps/webservice/src/components/xterm/SessionTerminal.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { AttachAddon } from "@xterm/addon-attach"; +import { ClipboardAddon } from "@xterm/addon-clipboard"; +import { FitAddon } from "@xterm/addon-fit"; +import { SearchAddon } from "@xterm/addon-search"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { Terminal } from "@xterm/xterm"; + +import "@xterm/xterm/css/xterm.css"; + +import type { WebSocketLike } from "react-use-websocket/dist/lib/types"; +import { useDebounce, useSize } from "react-use"; +import { ReadyState } from "react-use-websocket"; + +export const useSessionTerminal = ( + getWebsocket: () => WebSocketLike | null, + readyState: ReadyState, +) => { + const divRef = useRef(null); + const [fitAddon] = useState(new FitAddon()); + const terminalRef = useRef(null); + const reloadTerminal = useCallback(() => { + if (readyState !== ReadyState.OPEN) return; + if (divRef.current == null) return; + + const ws = getWebsocket(); + if (ws == null) return; + + terminalRef.current?.dispose(); + + const terminal = new Terminal({ + allowProposedApi: true, + allowTransparency: true, + disableStdin: false, + fontSize: 14, + }); + terminal.open(divRef.current); + terminal.loadAddon(fitAddon); + terminal.loadAddon(new SearchAddon()); + terminal.loadAddon(new ClipboardAddon()); + terminal.loadAddon(new WebLinksAddon()); + terminal.loadAddon(new AttachAddon(getWebsocket() as WebSocket)); + terminal.loadAddon(new Unicode11Addon()); + terminal.loadAddon(new WebglAddon()); + terminal.unicode.activeVersion = "11"; + terminalRef.current = terminal; + return terminal; + }, [fitAddon, getWebsocket, readyState]); + + useEffect(() => { + if (divRef.current == null) return; + if (readyState !== ReadyState.OPEN) return; + terminalRef.current?.dispose(); + reloadTerminal(); + fitAddon.fit(); + }, [fitAddon, readyState, reloadTerminal]); + + return { terminalRef, divRef, fitAddon, reloadTerminal }; +}; + +export const SocketTerminal: React.FC<{ + getWebSocket: () => WebSocketLike | null; + onResize?: (size: { + width: number; + height: number; + cols: number; + rows: number; + }) => void; + readyState: ReadyState; + sessionId: string; +}> = ({ getWebSocket, readyState, onResize }) => { + const { terminalRef, divRef, fitAddon } = useSessionTerminal( + getWebSocket, + readyState, + ); + + useEffect(() => { + if (readyState !== ReadyState.OPEN) return; + const term = terminalRef.current; + if (term == null) return; + + term.focus(); + fitAddon.fit(); + setTimeout(() => { + const { cols, rows } = term; + onResize?.({ width, height, cols, rows }); + }, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getWebSocket, terminalRef, fitAddon, readyState]); + + const [sized, { width, height }] = useSize( + () => ( +
+
+
+ ), + { width: 100, height: 100 }, + ); + + useDebounce( + () => { + const term = terminalRef.current; + if (term == null) return; + fitAddon.fit(); + setTimeout(() => { + const { cols, rows } = term; + onResize?.({ width, height, cols, rows }); + }, 0); + }, + 250, + [width, height], + ); + + return sized; +}; diff --git a/packages/db/src/schema/target-agent.ts b/packages/db/src/schema/target-agent.ts new file mode 100644 index 00000000..3f60b35a --- /dev/null +++ b/packages/db/src/schema/target-agent.ts @@ -0,0 +1,10 @@ +import { pgTable, uuid } from "drizzle-orm/pg-core"; + +import { target } from "./target.js"; + +export const targetSession = pgTable("target_session", { + id: uuid("id").primaryKey(), + targetId: uuid("target_id") + .references(() => target.id, { onDelete: "cascade" }) + .notNull(), +}); diff --git a/packages/db/src/schema/target-session.ts b/packages/db/src/schema/target-session.ts new file mode 100644 index 00000000..42a2fdd1 --- /dev/null +++ b/packages/db/src/schema/target-session.ts @@ -0,0 +1,14 @@ +import { pgTable, uuid } from "drizzle-orm/pg-core"; + +import { user } from "./auth.js"; +import { target } from "./target.js"; + +export const targetSession = pgTable("target_session", { + id: uuid("id").primaryKey(), + targetId: uuid("target_id") + .references(() => target.id) + .notNull(), + createdBy: uuid("created_by_id") + .references(() => user.id) + .notNull(), +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 4a456795..580a9034 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-menubar": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.1.3", diff --git a/packages/ui/src/menubar.tsx b/packages/ui/src/menubar.tsx new file mode 100644 index 00000000..e0dc922d --- /dev/null +++ b/packages/ui/src/menubar.tsx @@ -0,0 +1,240 @@ +"use client"; + +import * as React from "react"; +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons"; +import * as MenubarPrimitive from "@radix-ui/react-menubar"; + +import { cn } from "./index"; + +const MenubarMenu = MenubarPrimitive.Menu; + +const MenubarGroup = MenubarPrimitive.Group; + +const MenubarPortal = MenubarPrimitive.Portal; + +const MenubarSub = MenubarPrimitive.Sub; + +const MenubarRadioGroup = MenubarPrimitive.RadioGroup; + +const Menubar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Menubar.displayName = MenubarPrimitive.Root.displayName; + +const MenubarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName; + +const MenubarSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; + +const MenubarSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName; + +const MenubarContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref, + ) => ( + + + + ), +); +MenubarContent.displayName = MenubarPrimitive.Content.displayName; + +const MenubarItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +MenubarItem.displayName = MenubarPrimitive.Item.displayName; + +const MenubarCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName; + +const MenubarRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName; + +const MenubarLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +MenubarLabel.displayName = MenubarPrimitive.Label.displayName; + +const MenubarSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName; + +const MenubarShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +MenubarShortcut.displayname = "MenubarShortcut"; + +export { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, +}; diff --git a/packages/ui/src/popover.tsx b/packages/ui/src/popover.tsx index 6a603bf3..c7d8c1ba 100644 --- a/packages/ui/src/popover.tsx +++ b/packages/ui/src/popover.tsx @@ -15,18 +15,20 @@ const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - - - + <> + + + + )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/packages/validators/package.json b/packages/validators/package.json index a4e2a991..c6ffdf24 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -40,6 +40,10 @@ "types": "./src/cac/index.ts", "default": "./dist/cac/index.js" }, + "./session": { + "types": "./src/session/index.ts", + "default": "./dist/session/index.js" + }, "./conditions": { "types": "./src/conditions/index.ts", "default": "./dist/conditions/index.js" diff --git a/apps/ctrlshell/src/payloads/agent-connect.ts b/packages/validators/src/session/agent-connect.ts similarity index 100% rename from apps/ctrlshell/src/payloads/agent-connect.ts rename to packages/validators/src/session/agent-connect.ts diff --git a/apps/ctrlshell/src/payloads/agent-heartbeat.ts b/packages/validators/src/session/agent-heartbeat.ts similarity index 100% rename from apps/ctrlshell/src/payloads/agent-heartbeat.ts rename to packages/validators/src/session/agent-heartbeat.ts diff --git a/packages/validators/src/session/index.ts b/packages/validators/src/session/index.ts new file mode 100644 index 00000000..86c50865 --- /dev/null +++ b/packages/validators/src/session/index.ts @@ -0,0 +1,21 @@ +import type { z } from "zod"; + +import agentConnect from "./agent-connect.js"; +import agentHeartbeat from "./agent-heartbeat.js"; +import sessionCreate from "./session-create.js"; +import sessionDelete from "./session-delete.js"; +import sessionResize from "./session-resize.js"; + +export type AgentConnect = z.infer; +export type AgentHeartbeat = z.infer; +export type SessionResize = z.infer; +export type SessionCreate = z.infer; +export type SessionDelete = z.infer; + +export { + agentConnect, + agentHeartbeat, + sessionResize, + sessionCreate, + sessionDelete, +}; diff --git a/packages/validators/src/session/session-create.ts b/packages/validators/src/session/session-create.ts new file mode 100644 index 00000000..4cfe0dc5 --- /dev/null +++ b/packages/validators/src/session/session-create.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export default z.object({ + type: z + .literal("session.create") + .describe("Type of payload - must be session.create"), + sessionId: z.string().describe("Optional ID for the session"), + username: z.string().describe("Optional username for the session").optional(), + shell: z + .string() + .describe("Optional shell to use for the session") + .optional(), + targetId: z.string().describe("Target ID for the session"), + cols: z.number().describe("Number of columns for the session").optional(), + rows: z.number().describe("Number of rows for the session").optional(), +}); diff --git a/apps/ctrlshell/src/payloads/session-delete.ts b/packages/validators/src/session/session-delete.ts similarity index 75% rename from apps/ctrlshell/src/payloads/session-delete.ts rename to packages/validators/src/session/session-delete.ts index c447cd9c..cc273438 100644 --- a/apps/ctrlshell/src/payloads/session-delete.ts +++ b/packages/validators/src/session/session-delete.ts @@ -5,4 +5,5 @@ export default z.object({ .literal("session.delete") .describe("Type of payload - must be session.create"), sessionId: z.string().describe("ID of the session to delete"), + targetId: z.string().describe("Target ID for the session").optional(), }); diff --git a/packages/validators/src/session/session-resize.ts b/packages/validators/src/session/session-resize.ts new file mode 100644 index 00000000..c25dc8f7 --- /dev/null +++ b/packages/validators/src/session/session-resize.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export default z.object({ + type: z + .literal("session.resize") + .describe("Type of payload - must be session.resize"), + targetId: z.string().describe("Target ID for the session"), + sessionId: z.string().describe("ID of the session to resize"), + cols: z.number().describe("New number of columns for the session"), + rows: z.number().describe("New number of rows for the session"), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00ad759e..76ef099d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,94 +96,6 @@ importers: specifier: ^5.6.3 version: 5.6.3 - apps/ctrlshell: - dependencies: - '@ctrlplane/db': - specifier: workspace:* - version: link:../../packages/db - '@t3-oss/env-core': - specifier: 'catalog:' - version: 0.11.1(typescript@5.6.3)(zod@3.23.8) - cookie-parser: - specifier: ^1.4.6 - version: 1.4.7 - cors: - specifier: ^2.8.5 - version: 2.8.5 - dotenv: - specifier: ^16.4.5 - version: 16.4.5 - express: - specifier: ^4.19.2 - version: 4.21.1 - express-rate-limit: - specifier: ^7.3.0 - version: 7.4.1(express@4.21.1) - helmet: - specifier: ^7.1.0 - version: 7.2.0 - ms: - specifier: ^2.1.3 - version: 2.1.3 - next-auth: - specifier: 'catalog:' - version: 5.0.0-beta.22(next@15.0.1(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - uuid: - specifier: ^10.0.0 - version: 10.0.0 - ws: - specifier: ^8.17.0 - version: 8.18.0 - zod: - specifier: 'catalog:' - version: 3.23.8 - devDependencies: - '@ctrlplane/eslint-config': - specifier: workspace:^ - version: link:../../tooling/eslint - '@ctrlplane/prettier-config': - specifier: workspace:^ - version: link:../../tooling/prettier - '@ctrlplane/tailwind-config': - specifier: workspace:* - version: link:../../tooling/tailwind - '@ctrlplane/tsconfig': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/cookie-parser': - specifier: ^1.4.7 - version: 1.4.7 - '@types/cors': - specifier: ^2.8.17 - version: 2.8.17 - '@types/express': - specifier: ^4.17.21 - version: 4.17.21 - '@types/ms': - specifier: ^0.7.34 - version: 0.7.34 - '@types/node': - specifier: catalog:node20 - version: 20.16.10 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 - '@types/ws': - specifier: ^8.5.10 - version: 8.5.12 - eslint: - specifier: 'catalog:' - version: 9.14.0(jiti@2.3.3) - prettier: - specifier: 'catalog:' - version: 3.3.3 - tsx: - specifier: 'catalog:' - version: 4.19.1 - typescript: - specifier: 'catalog:' - version: 5.6.3 - apps/docs: dependencies: '@ctrlplane/ui': @@ -433,6 +345,103 @@ importers: specifier: 'catalog:' version: 5.6.3 + apps/target-proxy: + dependencies: + '@ctrlplane/db': + specifier: workspace:* + version: link:../../packages/db + '@ctrlplane/job-dispatch': + specifier: workspace:* + version: link:../../packages/job-dispatch + '@ctrlplane/logger': + specifier: workspace:* + version: link:../../packages/logger + '@ctrlplane/validators': + specifier: workspace:* + version: link:../../packages/validators + '@t3-oss/env-core': + specifier: 'catalog:' + version: 0.11.1(typescript@5.6.3)(zod@3.23.8) + cookie-parser: + specifier: ^1.4.6 + version: 1.4.7 + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + express: + specifier: ^4.19.2 + version: 4.21.1 + express-rate-limit: + specifier: ^7.3.0 + version: 7.4.1(express@4.21.1) + helmet: + specifier: ^7.1.0 + version: 7.2.0 + ms: + specifier: ^2.1.3 + version: 2.1.3 + next-auth: + specifier: 'catalog:' + version: 5.0.0-beta.22(next@15.0.1(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + uuid: + specifier: ^10.0.0 + version: 10.0.0 + ws: + specifier: ^8.17.0 + version: 8.18.0 + zod: + specifier: 'catalog:' + version: 3.23.8 + devDependencies: + '@ctrlplane/eslint-config': + specifier: workspace:^ + version: link:../../tooling/eslint + '@ctrlplane/prettier-config': + specifier: workspace:^ + version: link:../../tooling/prettier + '@ctrlplane/tailwind-config': + specifier: workspace:* + version: link:../../tooling/tailwind + '@ctrlplane/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/cookie-parser': + specifier: ^1.4.7 + version: 1.4.7 + '@types/cors': + specifier: ^2.8.17 + version: 2.8.17 + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 + '@types/ms': + specifier: ^0.7.34 + version: 0.7.34 + '@types/node': + specifier: catalog:node20 + version: 20.16.10 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@types/ws': + specifier: ^8.5.10 + version: 8.5.12 + eslint: + specifier: 'catalog:' + version: 9.14.0(jiti@2.3.3) + prettier: + specifier: 'catalog:' + version: 3.3.3 + tsx: + specifier: 'catalog:' + version: 4.19.1 + typescript: + specifier: 'catalog:' + version: 5.6.3 + apps/webservice: dependencies: '@ctrlplane/api': @@ -516,6 +525,30 @@ importers: '@trpc/server': specifier: 11.0.0-rc.364 version: 11.0.0-rc.364 + '@xterm/addon-attach': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) + '@xterm/addon-clipboard': + specifier: ^0.1.0 + version: 0.1.0(@xterm/xterm@5.5.0) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/addon-search': + specifier: ^0.15.0 + version: 0.15.0(@xterm/xterm@5.5.0) + '@xterm/addon-unicode11': + specifier: ^0.8.0 + version: 0.8.0(@xterm/xterm@5.5.0) + '@xterm/addon-web-links': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 + add: + specifier: ^2.0.6 + version: 2.0.6 change-case: specifier: ^5.4.4 version: 5.4.4 @@ -555,6 +588,9 @@ importers: next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + pnpm: + specifier: ^9.12.3 + version: 9.12.3 pretty-ms: specifier: ^9.0.0 version: 9.1.0 @@ -576,6 +612,9 @@ importers: react-use: specifier: ^17.5.0 version: 17.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-use-websocket: + specifier: ^4.10.1 + version: 4.10.1 reactflow: specifier: ^11.11.3 version: 11.11.4(@types/react@18.3.10)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -649,6 +688,9 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + '@xterm/addon-webgl': + specifier: ^0.18.0 + version: 0.18.0(@xterm/xterm@5.5.0) atlassian-openapi: specifier: ^1.0.19 version: 1.0.19 @@ -1433,6 +1475,9 @@ importers: '@radix-ui/react-label': specifier: ^2.0.2 version: 2.1.0(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menubar': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-navigation-menu': specifier: ^1.1.4 version: 1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1581,7 +1626,7 @@ importers: version: 1.13.4(eslint@9.14.0(jiti@2.3.3)) eslint-plugin-import: specifier: ^2.29.1 - version: 2.31.0(eslint@9.14.0(jiti@2.3.3)) + version: 2.31.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.14.0(jiti@2.3.3)) eslint-plugin-jsx-a11y: specifier: ^6.9.0 version: 6.10.2(eslint@9.14.0(jiti@2.3.3)) @@ -4178,6 +4223,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menubar@1.1.2': + resolution: {integrity: sha512-cKmj5Gte7LVyuz+8gXinxZAZECQU+N7aq5pw7kUPpx3xjnDXDbsdzHtCCD2W72bwzy74AvrqdYnKYS42ueskUQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-navigation-menu@1.2.1': resolution: {integrity: sha512-egDo0yJD2IK8L17gC82vptkvW1jLeni1VuqCyzY727dSJdk5cDjINomouLoNk8RVF7g2aNIfENKWL4UzeU9c8Q==} peerDependencies: @@ -5922,6 +5980,44 @@ packages: '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} + '@xterm/addon-attach@0.11.0': + resolution: {integrity: sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-clipboard@0.1.0': + resolution: {integrity: sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==} + peerDependencies: + '@xterm/xterm': ^5.4.0 + + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-search@0.15.0': + resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-unicode11@0.8.0': + resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-web-links@0.11.0': + resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/addon-webgl@0.18.0': + resolution: {integrity: sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -5969,6 +6065,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + add@2.0.6: + resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -8807,6 +8906,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + js-cookie@2.2.1: resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} @@ -10134,6 +10236,11 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pnpm@9.12.3: + resolution: {integrity: sha512-zOD53pxafJW++UQWnMXf6HQav7FFB4wNUIuGgFaEiofIHmJiRstglny9f9KabAYu9z/4QNlrPIbECsks9KgT7g==} + engines: {node: '>=18.12'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -10628,6 +10735,9 @@ packages: react: '*' tslib: '*' + react-use-websocket@4.10.1: + resolution: {integrity: sha512-PrZbKj3BSy9kRU9otKEoMi0FOcEVh1abyYxJDzB/oL7kMBDBs+ZXhnWWed/sc679nPHAWMOn1gotoV04j5gJUw==} + react-use@17.5.1: resolution: {integrity: sha512-LG/uPEVRflLWMwi3j/sZqR00nF6JGqTTDblkXK2nzXsIvij06hXl1V/MZIlwj1OKIQUtlh1l9jK8gLsRyCQxMg==} peerDependencies: @@ -15128,6 +15238,24 @@ snapshots: '@types/react': 18.3.10 '@types/react-dom': 18.3.1 + '@radix-ui/react-menubar@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.10)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.10)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.10)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.10)(react@18.3.1) + '@radix-ui/react-menu': 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.10)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.10 + '@types/react-dom': 18.3.1 + '@radix-ui/react-navigation-menu@1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -17706,6 +17834,37 @@ snapshots: '@xobotyi/scrollbar-width@1.9.5': {} + '@xterm/addon-attach@0.11.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-clipboard@0.1.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + js-base64: 3.7.7 + + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/addon-webgl@0.18.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -17743,6 +17902,8 @@ snapshots: acorn@8.14.0: {} + add@2.0.6: {} + agent-base@6.0.2: dependencies: debug: 4.3.7 @@ -19389,16 +19550,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.14.0(jiti@2.3.3)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.14.0(jiti@2.3.3)): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.13.0(eslint@9.14.0(jiti@2.3.3))(typescript@5.6.3) eslint: 9.14.0(jiti@2.3.3) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(eslint@9.14.0(jiti@2.3.3)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.14.0(jiti@2.3.3)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -19409,7 +19571,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.14.0(jiti@2.3.3) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.14.0(jiti@2.3.3)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.13.0(eslint@9.14.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.14.0(jiti@2.3.3)) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -19420,6 +19582,8 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.8 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.13.0(eslint@9.14.0(jiti@2.3.3))(typescript@5.6.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -21075,6 +21239,8 @@ snapshots: joycon@3.1.1: {} + js-base64@3.7.7: {} + js-cookie@2.2.1: {} js-file-download@0.4.12: {} @@ -22848,6 +23014,8 @@ snapshots: pluralize@8.0.0: {} + pnpm@9.12.3: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -23358,6 +23526,8 @@ snapshots: react: 18.3.1 tslib: 2.7.0 + react-use-websocket@4.10.1: {} + react-use@17.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@types/js-cookie': 2.2.7 diff --git a/turbo.json b/turbo.json index 2e748d06..dc695943 100644 --- a/turbo.json +++ b/turbo.json @@ -15,7 +15,8 @@ "next-env.d.ts", ".expo/**", ".output/**", - ".vercel/output/**" + ".vercel/output/**", + "dist/**" ] }, "dev": {