作ったもの
AWSのクラウドシェルや、監視装置などにたまについているWebコンソールを作ってみました。
コードはほとんどChatGPTに生成してもらったので、理解度がかなり浅く拡張が効かないのが難点・・
バックエンド
バックエンドはFastAPIにしました。
main.py
import os import pty import asyncio import signal import logging from fastapi import FastAPI, WebSocket from starlette.websockets import WebSocketDisconnect # Uvicorn のログを使う logger = logging.getLogger("uvicorn.error") # FastAPI インスタンス app = FastAPI() # 作業ディレクトリ(仮想端末の起動先) BASE_DIR = "/home/usaen/test" @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() # 子プロセス(bash)と仮想端末作成 pid, fd = pty.fork() if pid == 0: # ===== 子プロセス:bash 起動側 ===== try: os.chdir(BASE_DIR) except Exception as e: print(f"chdir failed: {e}") os.execvp("bash", ["bash"]) # 子プロセスを bash に置き換え else: # ===== 親プロセス:中継側 ===== loop = asyncio.get_running_loop() # 表示(PTY)をWebsocketに返す # PTY → WebSocket async def read_from_pty(): try: while True: data = await loop.run_in_executor(None, os.read, fd, 1024) await websocket.send_text(data.decode(errors="ignore")) except (asyncio.CancelledError, Exception): logger.info("[PTY] reader stopped") # Websocket(ユーザーインプット)をPTYに渡す # WebSocket → PTY async def read_from_ws(): try: while True: data = await websocket.receive_text() os.write(fd, data.encode()) except WebSocketDisconnect: logger.info("[WS] client disconnected") # ★ WebSocketが切れたら子プロセスを殺す try: os.kill(pid, signal.SIGKILL) logger.info(f"[WS] killed child process pid={pid}") except Exception as e: logger.warning(f"[WS] failed to kill pid={pid}: {e}") except asyncio.CancelledError: logger.info("[WS] reader cancelled") try: await asyncio.gather(read_from_pty(), read_from_ws()) except asyncio.CancelledError: logger.info("[Main] websocket handler cancelled") finally: try: # ① PTYファイルディスクリプタを閉じる os.close(fd) logger.info("[Main] PTY closed") # ② 子プロセス(bash)に SIGKILL を送る os.kill(pid, signal.SIGKILL) logger.info(f"[Main] Sent SIGKILL to bash (pid={pid})") # ③ 終了を待ってゾンビ化を防止 _, status = os.waitpid(pid, 0) logger.info(f"[Main] bash exited with status {status}") except Exception as e: logger.warning(f"[Main] cleanup failed: {e}")
起動
uv run uvicorn main:app --reload --host 0.0.0.0 --port 3002
フロントエンド
React + Vite + TailwindCSS + shadcnで構築
毎回shadcnのインストールで手こずります。。この手のインストールはChatGPTに聞くより公式ドキュメント見ながらやるほうが早いなって思います。
ButtonとCardのコンポーネントを追加しています。
terminal.tsx
import React, { useRef, useEffect, useCallback, useState } from 'react'; import { Terminal } from 'xterm'; // 仮想ターミナル本体 import { FitAddon } from 'xterm-addon-fit'; // サイズ自動調整用アドオン import 'xterm/css/xterm.css'; // xterm のスタイル読み込み import { Button } from "./components/ui/button" import { Card, CardContent, } from "./components/ui/card" const TerminalView: React.FC = () => { // DOM 要素の参照(ターミナル表示先) const containerRef = useRef<HTMLDivElement>(null); // ターミナル、アドオン、WebSocket を useRef で保持 const terminalRef = useRef<Terminal | null>(null); const fitAddon = useRef<FitAddon | null>(null); const socketRef = useRef<WebSocket | null>(null); // 接続状態のトラッキング(ボタンの制御用) const [connected, setConnected] = useState(false); // ========== セッションリセット用関数 ========== const cleanup = useCallback(() => { // 旧ターミナルの破棄 terminalRef.current?.dispose(); terminalRef.current = null; // WebSocket のイベント解除と明示的 close if (socketRef.current) { socketRef.current.onmessage = null; socketRef.current.onclose = null; socketRef.current.onerror = null; socketRef.current.close(); socketRef.current = null; } // FitAddon は参照削除だけでOK fitAddon.current = null; // 状態リセット setConnected(false); }, []); // ========== ターミナル+WebSocket 初期化 ========== const initializeTerminal = useCallback(() => { cleanup(); // 古いセッションが残っていれば掃除 // xterm 初期化 const term = new Terminal({ cursorBlink: true }); const fit = new FitAddon(); term.loadAddon(fit); term.open(containerRef.current!); // DOM に描画 // ★ 描画タイミングで fit() 実行しないと描画が壊れることがある requestAnimationFrame(() => { try { fit.fit(); // 親要素サイズに合わせて調整 } catch (e) { console.warn('fit() failed:', e); } }); // WebSocket 接続(ポート番号・エンドポイントに注意) const socket = new WebSocket(`ws://${window.location.hostname}:3002/ws`); // 接続成功時の処理 socket.onopen = () => { setConnected(true); }; // サーバ → クライアント socket.onmessage = (event) => { term.write(event.data); }; // エラー時のログ socket.onerror = (err) => { console.error("WebSocket error", err); }; // 切断された場合の通知 socket.onclose = (e) => { console.warn('WebSocket Closed', e); term.write('\r\n[Connection closed !]\r\n'); setConnected(false); }; // クライアント → サーバ term.onData((data: string) => { if (socket.readyState === WebSocket.OPEN) { socket.send(data); } }); // 参照登録 terminalRef.current = term; fitAddon.current = fit; socketRef.current = socket; }, [cleanup]); // ========== コンポーネント破棄時のクリーンアップ ========== useEffect(() => { return () => cleanup(); // unmount 時にもセッション破棄 }, [cleanup]); // ========== 描画部分 ========== return ( <div className='px-5 py-1' style={{ height: '52vh', display: 'flex', flexDirection: 'column' }}> <Card> <CardContent className='p-0.5'> <div className="p-1 flex gap-2"> {/* 接続ボタン */} <Button onClick={initializeTerminal} disabled={connected}> 接続 </Button> {/* 切断ボタン */} <Button onClick={cleanup} disabled={!connected}> 切断 </Button> </div> {/* ターミナルの描画領域 */} <div className='p-3' ref={containerRef} style={{ flexGrow: 1, }} /> </CardContent> </Card> </div> ); }; export default TerminalView;
main.tsx
import TerminalView from './terminal'; function App() { return ( <> <div className='py-5'> <h1 className="scroll-m-20 text-center text-4xl font-extrabold tracking-tight text-balance"> Webコンソール </h1> <TerminalView /> </div> </> ); } export default App;
起動
npm run dev