feat: web terminal

This commit is contained in:
CyberL1 2025-01-16 06:10:37 -05:00
parent 8d5a57614e
commit d7dcabc3da
10 changed files with 280 additions and 5 deletions

View File

@ -14,6 +14,9 @@
"@mui/icons-material": "^6.1.8",
"@mui/lab": "^6.0.0-beta.23",
"@mui/material": "^6.1.8",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^7.0.1"
@ -2316,6 +2319,30 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
}
},
"node_modules/@xterm/addon-attach": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.11.0.tgz",
"integrity": "sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",

View File

@ -16,6 +16,9 @@
"@mui/icons-material": "^6.1.8",
"@mui/lab": "^6.0.0-beta.23",
"@mui/material": "^6.1.8",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^7.0.1"

View File

@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { AttachAddon } from "@xterm/addon-attach";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { useLoaderData } from "react-router";
import { Container } from "../types";
export default function WebTerminal() {
const container = useLoaderData() as Container;
const terminalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.title = `Terminal: ${container.name}`;
if (terminalRef.current) {
const terminal = new Terminal({ rows: 67 });
const socket = new WebSocket(
`ws://127.0.0.1:3000/containers/${container.id}/terminal`,
);
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalRef.current);
fitAddon.fit();
terminal.write("Connecting to the container\r\n");
socket.onopen = () => {
const attachAddon = new AttachAddon(socket);
terminal.loadAddon(attachAddon);
terminal.clear();
terminal.focus();
socket.send(
JSON.stringify({
rows: terminal.rows,
cols: terminal.cols,
}),
);
};
window.addEventListener("resize", () => {
fitAddon.fit();
});
terminal.onResize(({ rows, cols }) => {
socket.send(JSON.stringify({ rows, cols }));
});
return () => {
socket.close();
terminal.dispose();
};
}
}, []);
return <div ref={terminalRef} style={{ height: "auto" }} />;
}

View File

@ -7,8 +7,9 @@ import Containers, {
Loader as ContainersLoader,
} from "./pages/containers/index.tsx";
import ContainerPage, {
Loader as ContainerPageLoader,
} from "./pages/containers/[name].tsx";
Loader as ContainerDataLoader,
} from "./pages/containers/[name]/index.tsx";
import TerminalPage from "./pages/containers/[name]/terminal.tsx";
const router = createBrowserRouter([
{
@ -24,7 +25,12 @@ const router = createBrowserRouter([
{
path: "/containers/:name",
element: <ContainerPage />,
loader: ContainerPageLoader,
loader: ContainerDataLoader,
},
{
path: "/containers/:name/terminal",
element: <TerminalPage />,
loader: ContainerDataLoader,
},
],
},

View File

@ -1,8 +1,8 @@
import { useLoaderData, useRevalidator } from "react-router";
import { Container } from "../../types";
import { Container } from "../../../types";
import { Button, ButtonGroup, Paper, Typography } from "@mui/material";
import { useState } from "react";
import ContainerStats from "../../components/ContainerStats";
import ContainerStats from "../../../components/ContainerStats";
interface Params {
name: string;
@ -32,6 +32,9 @@ export default function ContainerPage() {
Managing: {container.name}
</Typography>
<ButtonGroup>
<Button href={`/containers/${container.name}/terminal`}>
Terminal
</Button>
<Button
loading={isPowerStateLocked}
onClick={async () => {

View File

@ -0,0 +1,34 @@
import { useLoaderData } from "react-router";
import { Container } from "../../../types";
import { Paper, Typography } from "@mui/material";
import WebTerminal from "../../../components/WebTerminal";
interface Params {
name: string;
}
export async function Loader({ params }: { params: Params }) {
const container = await fetch(`/api/containers/${params.name}`);
const data = await container.json();
return data;
}
export default function TerminalPage() {
const container = useLoaderData() as Container & { statusCode: number };
if (container.statusCode === 404) {
return "Container not found";
}
return (
<Paper square sx={{ padding: 1 }}>
<Paper sx={{ display: "flex" }} variant="outlined">
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Terminal: {container.name}
</Typography>
</Paper>
<WebTerminal />
</Paper>
);
}

84
package-lock.json generated
View File

@ -9,6 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@fastify/websocket": "^11.0.2",
"@xterm/addon-fit": "^0.10.0",
"dockerode": "^4.0.3",
"dotenv": "^16.4.7",
"fastify": "^5.2.1"
@ -258,6 +260,27 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/websocket": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.0.2.tgz",
"integrity": "sha512-1oyJkNSZNJGjo/A5fXvlpEcm1kTBD91nRAN9lA7RNVsVNsyC5DuhOXdNL9/4UawVe7SKvzPT/QVI4RdtE9ylnA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"duplexify": "^4.1.3",
"fastify-plugin": "^5.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.12.5",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.5.tgz",
@ -372,6 +395,22 @@
"undici-types": "~6.20.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@ -659,6 +698,18 @@
"url": "https://dotenvx.com"
}
},
"node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -783,6 +834,12 @@
"toad-cache": "^3.7.0"
}
},
"node_modules/fastify-plugin": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.0.1.tgz",
"integrity": "sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
@ -1228,6 +1285,12 @@
"nan": "^2.20.0"
}
},
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -1363,6 +1426,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -15,6 +15,8 @@
"license": "ISC",
"description": "",
"dependencies": {
"@fastify/websocket": "^11.0.2",
"@xterm/addon-fit": "^0.10.0",
"dockerode": "^4.0.3",
"dotenv": "^16.4.7",
"fastify": "^5.2.1"

View File

@ -1,8 +1,10 @@
import fastifyWebsocket from "@fastify/websocket";
import "dotenv/config";
import fastify from "fastify";
import { readdirSync } from "fs";
const app = fastify();
app.register(fastifyWebsocket);
const routes = readdirSync(`${import.meta.dirname}/routes`, {
recursive: true,

View File

@ -0,0 +1,52 @@
import type { Container } from "#src/types/Container.ts";
import { getContainer } from "#src/utils/containers.ts";
import type { FastifyInstance, FastifyRequest } from "fastify";
import { PassThrough } from "stream";
export default (fastify: FastifyInstance) => {
fastify.get(
"/",
{ websocket: true },
async (connection, req: FastifyRequest<{ Params: Container }>) => {
const container = getContainer(req.params.id);
const exec = await container.exec({
Cmd: ["/bin/bash"],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
});
const stream = await exec.start({ hijack: true, stdin: true });
const stdout = new PassThrough();
const stderr = new PassThrough();
container.modem.demuxStream(stream, stdout, stderr);
connection.on("message", (data: Buffer) => {
console.log(new TextDecoder().decode(data));
if (new TextDecoder().decode(data).startsWith("{")) {
const parsed = JSON.parse(data.toString());
exec.resize({
h: parsed.rows,
w: parsed.cols,
});
} else {
stream.write(data);
}
});
stdout.on("data", (chunk) => {
connection.send(chunk.toString());
});
stderr.on("data", (chunk) => {
connection.send(chunk.toString());
});
},
);
};