From 0176fa75ef3249ca75d468fb5cdf3327995624f3 Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Sat, 1 Feb 2025 13:41:20 +0800 Subject: [PATCH] dev: terminal --- napcat.webui/package.json | 3 + napcat.webui/src/components/icons.tsx | 247 ++++++++++++++++- napcat.webui/src/components/sortable_tab.tsx | 74 +++++ napcat.webui/src/components/tabs/index.tsx | 83 ++++++ .../components/terminal/terminal-instance.tsx | 57 ++++ .../src/components/under_construction.tsx | 12 + napcat.webui/src/components/xterm.tsx | 253 +++++++++--------- napcat.webui/src/config/site.tsx | 24 +- napcat.webui/src/controllers/webui_manager.ts | 98 +++++++ napcat.webui/src/pages/dashboard/terminal.tsx | 122 +++++++++ napcat.webui/src/pages/index.tsx | 5 + src/webui/index.ts | 3 + src/webui/src/api/Log.ts | 63 +++++ src/webui/src/router/Log.ts | 22 +- src/webui/src/terminal/terminal_manager.ts | 155 +++++++++++ 15 files changed, 1078 insertions(+), 143 deletions(-) create mode 100644 napcat.webui/src/components/sortable_tab.tsx create mode 100644 napcat.webui/src/components/tabs/index.tsx create mode 100644 napcat.webui/src/components/terminal/terminal-instance.tsx create mode 100644 napcat.webui/src/components/under_construction.tsx create mode 100644 napcat.webui/src/pages/dashboard/terminal.tsx create mode 100644 src/webui/src/terminal/terminal_manager.ts diff --git a/napcat.webui/package.json b/napcat.webui/package.json index 0d1d8476..320d297b 100644 --- a/napcat.webui/package.json +++ b/napcat.webui/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@heroui/avatar": "2.2.7", "@heroui/breadcrumbs": "2.2.7", "@heroui/button": "2.2.10", diff --git a/napcat.webui/src/components/icons.tsx b/napcat.webui/src/components/icons.tsx index 0253ee81..bea77422 100644 --- a/napcat.webui/src/components/icons.tsx +++ b/napcat.webui/src/components/icons.tsx @@ -1152,7 +1152,7 @@ export const WebUIIcon = (props: IconSvgProps) => ( begin="0ms" > ( begin="800ms" > ( begin="1600ms" > ( begin="2400ms" > ( begin="3200ms" > ( begin="0ms" > ( begin="600ms" > ( begin="1200ms" > ( begin="1800ms" > ( begin="2400ms" > ( begin="3000ms" > ( begin="3600ms" > ( begin="4200ms" > ( ) + +export const FileIcon = (props: IconSvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const LogIcon = (props: IconSvgProps) => ( + + + + + + + + + + + +) diff --git a/napcat.webui/src/components/sortable_tab.tsx b/napcat.webui/src/components/sortable_tab.tsx new file mode 100644 index 00000000..e90398b0 --- /dev/null +++ b/napcat.webui/src/components/sortable_tab.tsx @@ -0,0 +1,74 @@ +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import clsx from 'clsx' +import { useRef } from 'react' + +import { Tab } from './tabs' + +interface SortableTabProps { + id: string + value: string + children: React.ReactNode + className?: string +} + +export function SortableTab({ + id, + value, + children, + className +}: SortableTabProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id }) + + const mouseDownTime = useRef(0) + const mouseDownPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 1 : 0 + } + + const handleMouseDown = (e: React.MouseEvent) => { + mouseDownTime.current = Date.now() + mouseDownPos.current = { x: e.clientX, y: e.clientY } + } + + const handleMouseUp = (e: React.MouseEvent) => { + const timeDiff = Date.now() - mouseDownTime.current + const distanceX = Math.abs(e.clientX - mouseDownPos.current.x) + const distanceY = Math.abs(e.clientY - mouseDownPos.current.y) + + // 如果时间小于200ms且移动距离小于5px,认为是点击而不是拖拽 + if (timeDiff < 200 && distanceX < 5 && distanceY < 5) { + listeners?.onClick?.(e) + } + } + + return ( + + {children} + + ) +} diff --git a/napcat.webui/src/components/tabs/index.tsx b/napcat.webui/src/components/tabs/index.tsx new file mode 100644 index 00000000..4b623af6 --- /dev/null +++ b/napcat.webui/src/components/tabs/index.tsx @@ -0,0 +1,83 @@ +import clsx from 'clsx' +import { type ReactNode, createContext, forwardRef, useContext } from 'react' + +interface TabsContextValue { + activeKey: string + onChange: (key: string) => void +} + +const TabsContext = createContext({ + activeKey: '', + onChange: () => {} +}) + +interface TabsProps { + activeKey: string + onChange: (key: string) => void + children: ReactNode + className?: string +} + +export function Tabs({ activeKey, onChange, children, className }: TabsProps) { + return ( + +
{children}
+
+ ) +} + +interface TabListProps { + children: ReactNode + className?: string +} + +export function TabList({ children, className }: TabListProps) { + return ( +
{children}
+ ) +} + +interface TabProps extends React.ButtonHTMLAttributes { + value: string + className?: string + children: ReactNode +} + +export const Tab = forwardRef( + ({ value, className, children, ...props }, ref) => { + const { activeKey, onChange } = useContext(TabsContext) + + return ( + + ) + } +) + +Tab.displayName = 'Tab' + +interface TabPanelProps { + value: string + children: ReactNode + className?: string +} + +export function TabPanel({ value, children, className }: TabPanelProps) { + const { activeKey } = useContext(TabsContext) + + if (value !== activeKey) return null + + return
{children}
+} diff --git a/napcat.webui/src/components/terminal/terminal-instance.tsx b/napcat.webui/src/components/terminal/terminal-instance.tsx new file mode 100644 index 00000000..6cdf14f8 --- /dev/null +++ b/napcat.webui/src/components/terminal/terminal-instance.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react' + +import WebUIManager from '@/controllers/webui_manager' + +import XTerm, { XTermRef } from '../xterm' + +interface TerminalInstanceProps { + id: string +} + +export function TerminalInstance({ id }: TerminalInstanceProps) { + const termRef = useRef(null) + const wsRef = useRef(null) + + useEffect(() => { + const ws = WebUIManager.connectTerminal(id, (data) => { + termRef.current?.write(data) + }) + wsRef.current = ws + + // 添加连接状态监听 + ws.onopen = () => { + console.log('Terminal connected:', id) + } + + ws.onerror = (error) => { + console.error('Terminal connection error:', error) + termRef.current?.write( + '\r\n\x1b[31mConnection error. Please try reconnecting.\x1b[0m\r\n' + ) + } + + ws.onclose = () => { + console.log('Terminal disconnected:', id) + termRef.current?.write('\r\n\x1b[31mConnection closed.\x1b[0m\r\n') + } + + return () => { + ws.close() + } + }, [id]) + + const handleInput = (data: string) => { + const ws = wsRef.current + if (ws?.readyState === WebSocket.OPEN) { + try { + ws.send(JSON.stringify({ type: 'input', data })) + } catch (error) { + console.error('Failed to send terminal input:', error) + } + } else { + console.warn('WebSocket is not in OPEN state') + } + } + + return +} diff --git a/napcat.webui/src/components/under_construction.tsx b/napcat.webui/src/components/under_construction.tsx new file mode 100644 index 00000000..56097c26 --- /dev/null +++ b/napcat.webui/src/components/under_construction.tsx @@ -0,0 +1,12 @@ +export default function UnderConstruction() { + return ( +
+
+
🚧
+
+ Under Construction +
+
+
+ ) +} diff --git a/napcat.webui/src/components/xterm.tsx b/napcat.webui/src/components/xterm.tsx index 9b2b7a5f..506187b6 100644 --- a/napcat.webui/src/components/xterm.tsx +++ b/napcat.webui/src/components/xterm.tsx @@ -22,132 +22,141 @@ export type XTermRef = { clear: () => void } -const XTerm = forwardRef>( - (props, ref) => { - const domRef = useRef(null) - const terminalRef = useRef(null) - const { className, ...rest } = props - const { theme } = useTheme() - useEffect(() => { - if (!domRef.current) { - return - } - const terminal = new Terminal({ - allowTransparency: true, - fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', - cursorInactiveStyle: 'outline', - drawBoldTextInBrightColors: false, - letterSpacing: 0, - lineHeight: 1.0 +export interface XTermProps + extends Omit, 'onInput'> { + onInput?: (data: string) => void +} + +const XTerm = forwardRef((props, ref) => { + const domRef = useRef(null) + const terminalRef = useRef(null) + const { className, onInput, ...rest } = props + const { theme } = useTheme() + useEffect(() => { + if (!domRef.current) { + return + } + const terminal = new Terminal({ + allowTransparency: true, + fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace', + cursorInactiveStyle: 'outline', + drawBoldTextInBrightColors: false, + letterSpacing: 0, + lineHeight: 1.0 + }) + terminalRef.current = terminal + const fitAddon = new FitAddon() + terminal.loadAddon( + new WebLinksAddon((event, uri) => { + if (event.ctrlKey) { + window.open(uri, '_blank') + } }) - terminalRef.current = terminal - const fitAddon = new FitAddon() - terminal.loadAddon( - new WebLinksAddon((event, uri) => { - if (event.ctrlKey) { - window.open(uri, '_blank') - } + ) + terminal.loadAddon(fitAddon) + terminal.loadAddon(new WebglAddon()) + terminal.open(domRef.current) + + terminal.writeln( + gradientText( + 'Welcome to NapCat WebUI', + [255, 0, 0], + [0, 255, 0], + true, + true, + true + ) + ) + + terminal.onData((data) => { + if (onInput) { + onInput(data) + } + }) + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit() + }) + + // 字体加载完成后重新调整终端大小 + document.fonts.ready.then(() => { + fitAddon.fit() + + resizeObserver.observe(domRef.current!) + }) + + return () => { + resizeObserver.disconnect() + setTimeout(() => { + terminal.dispose() + }, 0) + } + }, []) + + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.options.theme = { + background: theme === 'dark' ? '#00000000' : '#ffffff00', + foreground: theme === 'dark' ? '#fff' : '#000', + selectionBackground: + theme === 'dark' + ? 'rgba(179, 0, 0, 0.3)' + : 'rgba(255, 167, 167, 0.3)', + cursor: theme === 'dark' ? '#fff' : '#000', + cursorAccent: theme === 'dark' ? '#000' : '#fff', + black: theme === 'dark' ? '#fff' : '#000' + } + terminalRef.current.options.fontWeight = + theme === 'dark' ? 'normal' : '600' + terminalRef.current.options.fontWeightBold = + theme === 'dark' ? 'bold' : '900' + } + }, [theme]) + + useImperativeHandle( + ref, + () => ({ + write: (...args) => { + return terminalRef.current?.write(...args) + }, + writeAsync: async (data) => { + return new Promise((resolve) => { + terminalRef.current?.write(data, resolve) }) - ) - terminal.loadAddon(fitAddon) - terminal.loadAddon(new WebglAddon()) - terminal.open(domRef.current) - - terminal.writeln( - gradientText( - 'Welcome to NapCat WebUI', - [255, 0, 0], - [0, 255, 0], - true, - true, - true - ) - ) - - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit() - }) - - // 字体加载完成后重新调整终端大小 - document.fonts.ready.then(() => { - fitAddon.fit() - - resizeObserver.observe(domRef.current!) - }) - - return () => { - resizeObserver.disconnect() - setTimeout(() => { - terminal.dispose() - }, 0) + }, + writeln: (...args) => { + return terminalRef.current?.writeln(...args) + }, + writelnAsync: async (data) => { + return new Promise((resolve) => { + terminalRef.current?.writeln(data, resolve) + }) + }, + clear: () => { + terminalRef.current?.clear() } - }, []) + }), + [] + ) - useEffect(() => { - if (terminalRef.current) { - terminalRef.current.options.theme = { - background: theme === 'dark' ? '#00000000' : '#ffffff00', - foreground: theme === 'dark' ? '#fff' : '#000', - selectionBackground: - theme === 'dark' - ? 'rgba(179, 0, 0, 0.3)' - : 'rgba(255, 167, 167, 0.3)', - cursor: theme === 'dark' ? '#fff' : '#000', - cursorAccent: theme === 'dark' ? '#000' : '#fff', - black: theme === 'dark' ? '#fff' : '#000' - } - terminalRef.current.options.fontWeight = - theme === 'dark' ? 'normal' : '600' - terminalRef.current.options.fontWeightBold = - theme === 'dark' ? 'bold' : '900' - } - }, [theme]) - - useImperativeHandle( - ref, - () => ({ - write: (...args) => { - return terminalRef.current?.write(...args) - }, - writeAsync: async (data) => { - return new Promise((resolve) => { - terminalRef.current?.write(data, resolve) - }) - }, - writeln: (...args) => { - return terminalRef.current?.writeln(...args) - }, - writelnAsync: async (data) => { - return new Promise((resolve) => { - terminalRef.current?.writeln(data, resolve) - }) - }, - clear: () => { - terminalRef.current?.clear() - } - }), - [] - ) - - return ( + return ( +
-
-
- ) - } -) + style={{ + width: '100%', + height: '100%' + }} + ref={domRef} + >
+ + ) +}) export default XTerm diff --git a/napcat.webui/src/config/site.tsx b/napcat.webui/src/config/site.tsx index 6e25253d..9dfa7ee6 100644 --- a/napcat.webui/src/config/site.tsx +++ b/napcat.webui/src/config/site.tsx @@ -1,6 +1,8 @@ import { BugIcon2, + FileIcon, InfoIcon, + LogIcon, RouteIcon, SettingsIcon, SignalTowerIcon, @@ -49,10 +51,10 @@ export const siteConfig = { href: '/config' }, { - label: '系统日志', + label: 'NapCat日志', icon: (
- +
), href: '/logs' @@ -75,6 +77,24 @@ export const siteConfig = { } ] }, + { + label: '文件管理', + icon: ( +
+ +
+ ), + href: '/file_manager' + }, + { + label: '系统终端', + icon: ( +
+ +
+ ), + href: '/terminal' + }, { label: '关于我们', icon: ( diff --git a/napcat.webui/src/controllers/webui_manager.ts b/napcat.webui/src/controllers/webui_manager.ts index 5a3904c3..5f682118 100644 --- a/napcat.webui/src/controllers/webui_manager.ts +++ b/napcat.webui/src/controllers/webui_manager.ts @@ -9,6 +9,14 @@ export interface Log { message: string } +export interface TerminalSession { + id: string +} + +export interface TerminalInfo { + id: string +} + export default class WebUIManager { public static async checkWebUiLogined() { const { data } = @@ -130,4 +138,94 @@ export default class WebUIManager { return eventSource } + + public static async createTerminal( + cols: number, + rows: number + ): Promise { + const { data } = await serverRequest.post>( + '/Log/terminal/create', + { cols, rows } + ) + return data.data + } + + public static async sendTerminalInput( + id: string, + input: string + ): Promise { + await serverRequest.post(`/Log/terminal/${id}/input`, { input }) + } + + public static getTerminalStream(id: string, onData: (data: string) => void) { + const token = localStorage.getItem('token') + if (!token) throw new Error('未登录') + + const _token = JSON.parse(token) + const eventSource = new EventSourcePolyfill( + `/api/Log/terminal/${id}/stream`, + { + headers: { + Authorization: `Bearer ${_token}`, + Accept: 'text/event-stream' + }, + withCredentials: true + } + ) + + eventSource.onmessage = (event) => { + try { + const { data } = JSON.parse(event.data) + onData(data) + } catch (error) { + console.error(error) + } + } + + return eventSource + } + + public static async closeTerminal(id: string): Promise { + await serverRequest.post(`/Log/terminal/${id}/close`) + } + + public static async getTerminalList(): Promise { + const { data } = + await serverRequest.get>( + '/Log/terminal/list' + ) + return data.data + } + + public static connectTerminal( + id: string, + onData: (data: string) => void + ): WebSocket { + const token = localStorage.getItem('token') + if (!token) throw new Error('未登录') + + const _token = JSON.parse(token) + const ws = new WebSocket( + `ws://${window.location.host}/api/ws/terminal?id=${id}&token=${_token}` + ) + + ws.onmessage = (event) => { + try { + const { data } = JSON.parse(event.data) + onData(data) + } catch (error) { + console.error(error) + } + } + + ws.onerror = (error) => { + console.error('WebSocket连接出错:', error) + } + + ws.onclose = () => { + console.log('WebSocket连接关闭') + } + + return ws + } } diff --git a/napcat.webui/src/pages/dashboard/terminal.tsx b/napcat.webui/src/pages/dashboard/terminal.tsx new file mode 100644 index 00000000..610b1bc2 --- /dev/null +++ b/napcat.webui/src/pages/dashboard/terminal.tsx @@ -0,0 +1,122 @@ +import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + horizontalListSortingStrategy +} from '@dnd-kit/sortable' +import { Button } from '@heroui/button' +import { useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import { IoAdd, IoClose } from 'react-icons/io5' + +import { SortableTab } from '@/components/sortable_tab' +import { TabList, TabPanel, Tabs } from '@/components/tabs' +import { TerminalInstance } from '@/components/terminal/terminal-instance' + +import WebUIManager from '@/controllers/webui_manager' + +interface TerminalTab { + id: string + title: string +} + +export default function TerminalPage() { + const [tabs, setTabs] = useState([]) + const [selectedTab, setSelectedTab] = useState('') + + useEffect(() => { + // 获取已存在的终端列表 + WebUIManager.getTerminalList().then((terminals) => { + if (terminals.length === 0) return + + const newTabs = terminals.map((terminal, index) => ({ + id: terminal.id, + title: `Terminal ${index + 1}` + })) + + setTabs(newTabs) + setSelectedTab(newTabs[0].id) + }) + }, []) + + const createNewTerminal = async () => { + try { + const { id } = await WebUIManager.createTerminal(80, 24) + const newTab = { + id, + title: `Terminal ${tabs.length + 1}` + } + + setTabs((prev) => [...prev, newTab]) + setSelectedTab(id) + } catch (error) { + console.error('Failed to create terminal:', error) + toast.error('创建终端失败') + } + } + + const closeTerminal = async (id: string) => { + try { + await WebUIManager.closeTerminal(id) + setTabs((prev) => prev.filter((tab) => tab.id !== id)) + if (selectedTab === id) { + setSelectedTab(tabs[0]?.id || '') + } + } catch (error) { + toast.error('关闭终端失败') + } + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (active.id !== over?.id) { + setTabs((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id) + const newIndex = items.findIndex((item) => item.id === over?.id) + return arrayMove(items, oldIndex, newIndex) + }) + } + } + + return ( +
+ + +
+ + + {tabs.map((tab) => ( + + {tab.title} + + + ))} + + +
+ {tabs.map((tab) => ( + + + + ))} +
+
+
+ ) +} diff --git a/napcat.webui/src/pages/index.tsx b/napcat.webui/src/pages/index.tsx index 7fe42c6c..62e53fd5 100644 --- a/napcat.webui/src/pages/index.tsx +++ b/napcat.webui/src/pages/index.tsx @@ -1,6 +1,8 @@ import { AnimatePresence, motion } from 'motion/react' import { Route, Routes, useLocation } from 'react-router-dom' +import UnderConstruction from '@/components/under_construction' + import DefaultLayout from '@/layouts/default' import DashboardIndexPage from './dashboard' @@ -11,6 +13,7 @@ import HttpDebug from './dashboard/debug/http' import WSDebug from './dashboard/debug/websocket' import LogsPage from './dashboard/logs' import NetworkPage from './dashboard/network' +import TerminalPage from './dashboard/terminal' export default function IndexPage() { const location = useLocation() @@ -33,6 +36,8 @@ export default function IndexPage() { } /> } /> + } path="/file_manager" /> + } path="/terminal" /> } path="/about" /> diff --git a/src/webui/index.ts b/src/webui/index.ts index 80b2e7ba..cf5fa656 100644 --- a/src/webui/index.ts +++ b/src/webui/index.ts @@ -11,6 +11,7 @@ import { cors } from '@webapi/middleware/cors'; import { createUrl } from '@webapi/utils/url'; import { sendSuccess } from '@webapi/utils/response'; import { join } from 'node:path'; +import { terminalManager } from '@webapi/terminal/terminal_manager'; // 实例化Express const app = express(); @@ -45,6 +46,8 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp // ------------挂载路由------------ // 挂载静态路由(前端),路径为 [/前缀]/webui app.use('/webui', express.static(pathWrapper.staticPath)); + // 初始化WebSocket服务器 + terminalManager.initialize(app); // 挂载API接口 app.use('/api', ALLRouter); // 所有剩下的请求都转到静态页面 diff --git a/src/webui/src/api/Log.ts b/src/webui/src/api/Log.ts index eca333c5..1767f3c1 100644 --- a/src/webui/src/api/Log.ts +++ b/src/webui/src/api/Log.ts @@ -2,6 +2,7 @@ import type { RequestHandler } from 'express'; import { sendError, sendSuccess } from '../utils/response'; import { WebUiConfigWrapper } from '../helper/config'; import { logSubscription } from '@/common/log'; +import { terminalManager } from '../terminal/terminal_manager'; // 日志记录 export const LogHandler: RequestHandler = async (req, res) => { @@ -35,3 +36,65 @@ export const LogRealTimeHandler: RequestHandler = async (req, res) => { logSubscription.unsubscribe(listener); }); }; + +// 终端相关处理器 +export const CreateTerminalHandler: RequestHandler = async (req, res) => { + try { + const id = Math.random().toString(36).substring(2); + terminalManager.createTerminal(id); + return sendSuccess(res, { id }); + } catch (error) { + console.error('Failed to create terminal:', error); + return sendError(res, '创建终端失败'); + } +}; + +export const GetTerminalListHandler: RequestHandler = (req, res) => { + const list = terminalManager.getTerminalList(); + return sendSuccess(res, list); +}; + +export const CloseTerminalHandler: RequestHandler = (req, res) => { + const id = req.params.id; + terminalManager.closeTerminal(id); + return sendSuccess(res, {}); +}; + +// 终端数据交换 +export const TerminalHandler: RequestHandler = (req, res) => { + const id = req.params.id; + if (!terminalManager.getTerminal(id)) { + return sendError(res, '终端不存在'); + } + + if (req.body.input) { + terminalManager.writeTerminal(id, req.body.input); + } + + return sendSuccess(res, {}); +}; + +// 终端数据流(SSE) +export const TerminalStreamHandler: RequestHandler = (req, res) => { + const id = req.params.id; + const instance = terminalManager.getTerminal(id); + + if (!instance) { + return sendError(res, '终端不存在'); + } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Connection', 'keep-alive'); + + const dataHandler = (data: string) => { + if (!res.writableEnded) { + res.write(`data: ${JSON.stringify({ type: 'output', data })}\n\n`); + } + }; + + const dispose = terminalManager.onTerminalData(id, dataHandler); + + req.on('close', () => { + dispose(); + }); +}; diff --git a/src/webui/src/router/Log.ts b/src/webui/src/router/Log.ts index b72a2245..9ec28887 100644 --- a/src/webui/src/router/Log.ts +++ b/src/webui/src/router/Log.ts @@ -1,13 +1,23 @@ import { Router } from 'express'; -import { LogHandler, LogListHandler, LogRealTimeHandler } from '../api/Log'; +import { + LogHandler, + LogListHandler, + LogRealTimeHandler, + CreateTerminalHandler, + GetTerminalListHandler, + CloseTerminalHandler, +} from '../api/Log'; const router = Router(); -// router:读取日志内容 -router.get('/GetLog', LogHandler); -// router:读取日志列表 -router.get('/GetLogList', LogListHandler); -// router:实时日志 +// 日志相关路由 +router.get('/GetLog', LogHandler); +router.get('/GetLogList', LogListHandler); router.get('/GetLogRealTime', LogRealTimeHandler); +// 终端相关路由 +router.get('/terminal/list', GetTerminalListHandler); +router.post('/terminal/create', CreateTerminalHandler); +router.post('/terminal/:id/close', CloseTerminalHandler); + export { router as LogRouter }; diff --git a/src/webui/src/terminal/terminal_manager.ts b/src/webui/src/terminal/terminal_manager.ts new file mode 100644 index 00000000..671c7552 --- /dev/null +++ b/src/webui/src/terminal/terminal_manager.ts @@ -0,0 +1,155 @@ +import { WebUiConfig } from '@/webui'; +import { AuthHelper } from '../helper/SignToken'; +import { spawn, type ChildProcess } from 'child_process'; +import * as os from 'os'; +import { WebSocket, WebSocketServer } from 'ws'; + +interface TerminalInstance { + process: ChildProcess; + lastAccess: number; + dataHandlers: Set<(data: string) => void>; +} + +class TerminalManager { + private terminals: Map = new Map(); + private wss: WebSocketServer | null = null; + + initialize(server: any) { + this.wss = new WebSocketServer({ + server, + path: '/api/ws/terminal', + }); + + this.wss.on('connection', async (ws, req) => { + try { + const url = new URL(req.url || '', 'ws://localhost'); + const token = url.searchParams.get('token'); + const terminalId = url.searchParams.get('id'); + + if (!token || !terminalId) { + ws.close(); + return; + } + + // 验证 token + // 解析token + let Credential: WebUiCredentialJson; + try { + Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8')); + } catch (e) { + ws.close(); + return; + } + const config = await WebUiConfig.GetWebUIConfig(); + const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential); + + if (!validate) { + ws.close(); + return; + } + + const instance = this.terminals.get(terminalId); + if (!instance) { + ws.close(); + return; + } + + const dataHandler = (data: string) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'output', data })); + } + }; + instance.dataHandlers.add(dataHandler); + + ws.on('message', (message) => { + try { + const data = JSON.parse(message.toString()); + if (data.type === 'input') { + this.writeTerminal(terminalId, data.data); + } + } catch (error) { + console.error('Failed to process terminal input:', error); + } + }); + + ws.on('close', () => { + instance.dataHandlers.delete(dataHandler); + }); + } catch (err) { + console.error('WebSocket authentication failed:', err); + ws.close(); + } + }); + } + + createTerminal(id: string) { + const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; + const shellProcess = spawn(shell, [], { + env: process.env, + shell: true, + }); + + const instance: TerminalInstance = { + process: shellProcess, + lastAccess: Date.now(), + dataHandlers: new Set(), + }; + + // 修改这里,使用 shellProcess 而不是 process + shellProcess.stdout.on('data', (data) => { + const str = data.toString(); + instance.dataHandlers.forEach((handler) => handler(str)); + }); + + shellProcess.stderr.on('data', (data) => { + const str = data.toString(); + instance.dataHandlers.forEach((handler) => handler(str)); + }); + + this.terminals.set(id, instance); + return instance; + } + + getTerminal(id: string) { + return this.terminals.get(id); + } + + closeTerminal(id: string) { + const instance = this.terminals.get(id); + if (instance) { + instance.process.kill(); + this.terminals.delete(id); + } + } + + onTerminalData(id: string, handler: (data: string) => void) { + const instance = this.terminals.get(id); + if (instance) { + instance.dataHandlers.add(handler); + return () => { + instance.dataHandlers.delete(handler); + }; + } + return () => {}; + } + + writeTerminal(id: string, data: string) { + const instance = this.terminals.get(id); + if (instance && instance.process.stdin) { + instance.process.stdin.write(data, (error) => { + if (error) { + console.error('Failed to write to terminal:', error); + } + }); + } + } + + getTerminalList() { + return Array.from(this.terminals.keys()).map((id) => ({ + id, + lastAccess: this.terminals.get(id)!.lastAccess, + })); + } +} + +export const terminalManager = new TerminalManager();