feat: 添加项目规则、环境配置示例及开发文档
This commit is contained in:
16
src/minenasai/webtui/__init__.py
Normal file
16
src/minenasai/webtui/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Web TUI 模块
|
||||
|
||||
提供 Web 终端界面、SSH 连接管理
|
||||
"""
|
||||
|
||||
from minenasai.webtui.auth import AuthManager, AuthToken, get_auth_manager
|
||||
from minenasai.webtui.ssh_manager import SSHManager, SSHSession, get_ssh_manager
|
||||
|
||||
__all__ = [
|
||||
"AuthManager",
|
||||
"AuthToken",
|
||||
"get_auth_manager",
|
||||
"SSHManager",
|
||||
"SSHSession",
|
||||
"get_ssh_manager",
|
||||
]
|
||||
236
src/minenasai/webtui/auth.py
Normal file
236
src/minenasai/webtui/auth.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""用户认证模块
|
||||
|
||||
提供 Token 认证和会话管理
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from minenasai.core import get_logger, get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthToken:
|
||||
"""认证令牌"""
|
||||
|
||||
token: str
|
||||
user_id: str
|
||||
created_at: float
|
||||
expires_at: float
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""是否已过期"""
|
||||
return time.time() > self.expires_at
|
||||
|
||||
@property
|
||||
def remaining_time(self) -> float:
|
||||
"""剩余有效时间(秒)"""
|
||||
return max(0, self.expires_at - time.time())
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""认证管理器"""
|
||||
|
||||
def __init__(self, secret_key: str | None = None) -> None:
|
||||
"""初始化认证管理器
|
||||
|
||||
Args:
|
||||
secret_key: 签名密钥
|
||||
"""
|
||||
self.settings = get_settings()
|
||||
self.secret_key = secret_key or self.settings.webtui_secret_key
|
||||
self._tokens: dict[str, AuthToken] = {}
|
||||
self._user_sessions: dict[str, set[str]] = {} # user_id -> set of tokens
|
||||
|
||||
def generate_token(
|
||||
self,
|
||||
user_id: str,
|
||||
expires_in: int | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""生成访问令牌
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
expires_in: 有效期(秒),默认使用配置
|
||||
metadata: 额外元数据
|
||||
|
||||
Returns:
|
||||
访问令牌
|
||||
"""
|
||||
# 生成随机令牌
|
||||
random_part = secrets.token_urlsafe(24)
|
||||
timestamp = str(int(time.time()))
|
||||
|
||||
# 签名
|
||||
signature = self._sign(f"{random_part}:{timestamp}:{user_id}")
|
||||
token = f"{random_part}.{timestamp}.{signature[:16]}"
|
||||
|
||||
# 有效期
|
||||
if expires_in is None:
|
||||
expires_in = self.settings.webtui.session_timeout
|
||||
|
||||
now = time.time()
|
||||
auth_token = AuthToken(
|
||||
token=token,
|
||||
user_id=user_id,
|
||||
created_at=now,
|
||||
expires_at=now + expires_in,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
# 存储
|
||||
self._tokens[token] = auth_token
|
||||
|
||||
# 记录用户会话
|
||||
if user_id not in self._user_sessions:
|
||||
self._user_sessions[user_id] = set()
|
||||
self._user_sessions[user_id].add(token)
|
||||
|
||||
logger.info("生成令牌", user_id=user_id, expires_in=expires_in)
|
||||
return token
|
||||
|
||||
def verify_token(self, token: str) -> AuthToken | None:
|
||||
"""验证令牌
|
||||
|
||||
Args:
|
||||
token: 访问令牌
|
||||
|
||||
Returns:
|
||||
AuthToken 或 None(无效时)
|
||||
"""
|
||||
auth_token = self._tokens.get(token)
|
||||
|
||||
if auth_token is None:
|
||||
logger.debug("令牌不存在", token=token[:20] + "...")
|
||||
return None
|
||||
|
||||
if auth_token.is_expired:
|
||||
logger.debug("令牌已过期", token=token[:20] + "...")
|
||||
self.revoke_token(token)
|
||||
return None
|
||||
|
||||
return auth_token
|
||||
|
||||
def revoke_token(self, token: str) -> bool:
|
||||
"""撤销令牌
|
||||
|
||||
Args:
|
||||
token: 访问令牌
|
||||
|
||||
Returns:
|
||||
是否成功撤销
|
||||
"""
|
||||
auth_token = self._tokens.pop(token, None)
|
||||
if auth_token:
|
||||
# 从用户会话中移除
|
||||
if auth_token.user_id in self._user_sessions:
|
||||
self._user_sessions[auth_token.user_id].discard(token)
|
||||
logger.info("撤销令牌", user_id=auth_token.user_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def revoke_user_tokens(self, user_id: str) -> int:
|
||||
"""撤销用户的所有令牌
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
|
||||
Returns:
|
||||
撤销的令牌数量
|
||||
"""
|
||||
tokens = self._user_sessions.pop(user_id, set())
|
||||
for token in tokens:
|
||||
self._tokens.pop(token, None)
|
||||
|
||||
if tokens:
|
||||
logger.info("撤销用户所有令牌", user_id=user_id, count=len(tokens))
|
||||
|
||||
return len(tokens)
|
||||
|
||||
def refresh_token(self, token: str, extends_by: int | None = None) -> str | None:
|
||||
"""刷新令牌
|
||||
|
||||
Args:
|
||||
token: 当前令牌
|
||||
extends_by: 延长时间(秒)
|
||||
|
||||
Returns:
|
||||
新令牌或 None(失败时)
|
||||
"""
|
||||
auth_token = self.verify_token(token)
|
||||
if auth_token is None:
|
||||
return None
|
||||
|
||||
# 生成新令牌
|
||||
new_token = self.generate_token(
|
||||
user_id=auth_token.user_id,
|
||||
expires_in=extends_by,
|
||||
metadata=auth_token.metadata,
|
||||
)
|
||||
|
||||
# 撤销旧令牌
|
||||
self.revoke_token(token)
|
||||
|
||||
return new_token
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""清理过期令牌
|
||||
|
||||
Returns:
|
||||
清理的令牌数量
|
||||
"""
|
||||
now = time.time()
|
||||
expired = [
|
||||
token for token, auth_token in self._tokens.items()
|
||||
if auth_token.expires_at < now
|
||||
]
|
||||
|
||||
for token in expired:
|
||||
self.revoke_token(token)
|
||||
|
||||
if expired:
|
||||
logger.info("清理过期令牌", count=len(expired))
|
||||
|
||||
return len(expired)
|
||||
|
||||
def _sign(self, data: str) -> str:
|
||||
"""生成签名"""
|
||||
return hmac.new(
|
||||
self.secret_key.encode(),
|
||||
data.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
def get_stats(self) -> dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
return {
|
||||
"total_tokens": len(self._tokens),
|
||||
"total_users": len(self._user_sessions),
|
||||
"tokens_per_user": {
|
||||
user_id: len(tokens)
|
||||
for user_id, tokens in self._user_sessions.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# 全局认证管理器
|
||||
_auth_manager: AuthManager | None = None
|
||||
|
||||
|
||||
def get_auth_manager() -> AuthManager:
|
||||
"""获取全局认证管理器"""
|
||||
global _auth_manager
|
||||
if _auth_manager is None:
|
||||
_auth_manager = AuthManager()
|
||||
return _auth_manager
|
||||
314
src/minenasai/webtui/server.py
Normal file
314
src/minenasai/webtui/server.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Web TUI 服务器
|
||||
|
||||
提供 Web 终端界面和 WebSocket 终端通信
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from minenasai.core import get_logger, get_settings, setup_logging
|
||||
from minenasai.webtui.auth import get_auth_manager
|
||||
from minenasai.webtui.ssh_manager import SSHSession, get_ssh_manager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 静态文件目录
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
class TerminalConnection:
|
||||
"""终端连接"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
self.websocket = websocket
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
self.ssh_session: SSHSession | None = None
|
||||
self.authenticated = False
|
||||
|
||||
async def send_json(self, data: dict[str, Any]) -> None:
|
||||
"""发送 JSON 消息"""
|
||||
try:
|
||||
await self.websocket.send_json(data)
|
||||
except Exception as e:
|
||||
logger.error("发送消息失败", error=str(e))
|
||||
|
||||
async def send_output(self, data: bytes) -> None:
|
||||
"""发送终端输出"""
|
||||
try:
|
||||
await self.websocket.send_json({
|
||||
"type": "output",
|
||||
"data": data.decode("utf-8", errors="replace"),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("发送输出失败", error=str(e))
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""WebSocket 连接管理器"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.connections: dict[str, TerminalConnection] = {}
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
) -> TerminalConnection:
|
||||
"""接受新连接"""
|
||||
await websocket.accept()
|
||||
conn = TerminalConnection(websocket, session_id, user_id)
|
||||
self.connections[session_id] = conn
|
||||
logger.info("终端连接建立", session_id=session_id)
|
||||
return conn
|
||||
|
||||
async def disconnect(self, session_id: str) -> None:
|
||||
"""断开连接"""
|
||||
conn = self.connections.pop(session_id, None)
|
||||
if conn and conn.ssh_session:
|
||||
ssh_manager = get_ssh_manager()
|
||||
await ssh_manager.close_session(session_id)
|
||||
logger.info("终端连接断开", session_id=session_id)
|
||||
|
||||
def get_connection(self, session_id: str) -> TerminalConnection | None:
|
||||
"""获取连接"""
|
||||
return self.connections.get(session_id)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""应用生命周期管理"""
|
||||
settings = get_settings()
|
||||
setup_logging(settings.logging)
|
||||
logger.info("Web TUI 服务启动", port=settings.webtui.port)
|
||||
yield
|
||||
|
||||
# 清理所有连接
|
||||
ssh_manager = get_ssh_manager()
|
||||
await ssh_manager.close_all()
|
||||
logger.info("Web TUI 服务关闭")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="MineNASAI Web TUI",
|
||||
description="Web 终端界面",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS 配置
|
||||
settings = get_settings()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 静态文件
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> HTMLResponse:
|
||||
"""首页"""
|
||||
index_file = STATIC_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return HTMLResponse(content=index_file.read_text(encoding="utf-8"))
|
||||
return HTMLResponse(content="<h1>MineNASAI Web TUI</h1>")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""健康检查"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def stats() -> dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
ssh_manager = get_ssh_manager()
|
||||
auth_manager = get_auth_manager()
|
||||
|
||||
return {
|
||||
"connections": len(manager.connections),
|
||||
"ssh": ssh_manager.get_stats(),
|
||||
"auth": auth_manager.get_stats(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/token")
|
||||
async def generate_token(
|
||||
user_id: str = "anonymous",
|
||||
expires_in: int = 3600,
|
||||
) -> dict[str, Any]:
|
||||
"""生成访问令牌(用于测试)"""
|
||||
auth_manager = get_auth_manager()
|
||||
token = auth_manager.generate_token(user_id, expires_in)
|
||||
return {
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_in": expires_in,
|
||||
}
|
||||
|
||||
|
||||
@app.websocket("/ws/terminal")
|
||||
async def terminal_websocket(websocket: WebSocket) -> None:
|
||||
"""终端 WebSocket 端点"""
|
||||
session_id = str(uuid.uuid4())
|
||||
conn: TerminalConnection | None = None
|
||||
|
||||
try:
|
||||
conn = await manager.connect(websocket, session_id, "")
|
||||
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "auth":
|
||||
await handle_auth(conn, data)
|
||||
|
||||
elif msg_type == "input":
|
||||
await handle_input(conn, data)
|
||||
|
||||
elif msg_type == "resize":
|
||||
await handle_resize(conn, data)
|
||||
|
||||
elif msg_type == "pong":
|
||||
pass # 心跳响应
|
||||
|
||||
else:
|
||||
await conn.send_json({
|
||||
"type": "error",
|
||||
"message": f"未知消息类型: {msg_type}",
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("WebSocket 错误", error=str(e), session_id=session_id)
|
||||
finally:
|
||||
if conn:
|
||||
await manager.disconnect(session_id)
|
||||
|
||||
|
||||
async def handle_auth(conn: TerminalConnection, data: dict[str, Any]) -> None:
|
||||
"""处理认证"""
|
||||
token = data.get("token", "")
|
||||
auth_manager = get_auth_manager()
|
||||
|
||||
# 允许匿名访问(开发模式)
|
||||
if token == "anonymous":
|
||||
conn.authenticated = True
|
||||
conn.user_id = "anonymous"
|
||||
await conn.send_json({
|
||||
"type": "auth_ok",
|
||||
"session_id": conn.session_id,
|
||||
"user_id": conn.user_id,
|
||||
})
|
||||
|
||||
# 创建 SSH 会话
|
||||
await create_ssh_session(conn)
|
||||
return
|
||||
|
||||
# 验证令牌
|
||||
auth_token = auth_manager.verify_token(token)
|
||||
if auth_token is None:
|
||||
await conn.send_json({
|
||||
"type": "auth_error",
|
||||
"message": "无效的令牌",
|
||||
})
|
||||
return
|
||||
|
||||
conn.authenticated = True
|
||||
conn.user_id = auth_token.user_id
|
||||
|
||||
await conn.send_json({
|
||||
"type": "auth_ok",
|
||||
"session_id": conn.session_id,
|
||||
"user_id": conn.user_id,
|
||||
})
|
||||
|
||||
# 创建 SSH 会话
|
||||
await create_ssh_session(conn)
|
||||
|
||||
|
||||
async def create_ssh_session(conn: TerminalConnection) -> None:
|
||||
"""创建 SSH 会话"""
|
||||
ssh_manager = get_ssh_manager()
|
||||
session = await ssh_manager.create_session(conn.session_id)
|
||||
|
||||
if session is None:
|
||||
await conn.send_json({
|
||||
"type": "error",
|
||||
"message": "SSH 连接失败",
|
||||
})
|
||||
return
|
||||
|
||||
conn.ssh_session = session
|
||||
|
||||
# 设置输出回调
|
||||
def on_output(data: bytes) -> None:
|
||||
asyncio.create_task(conn.send_output(data))
|
||||
|
||||
session.set_output_callback(on_output)
|
||||
|
||||
# 开始读取输出
|
||||
await session.start_reading()
|
||||
|
||||
|
||||
async def handle_input(conn: TerminalConnection, data: dict[str, Any]) -> None:
|
||||
"""处理终端输入"""
|
||||
if not conn.authenticated:
|
||||
await conn.send_json({
|
||||
"type": "error",
|
||||
"message": "未认证",
|
||||
})
|
||||
return
|
||||
|
||||
if conn.ssh_session is None:
|
||||
await conn.send_json({
|
||||
"type": "error",
|
||||
"message": "SSH 会话未建立",
|
||||
})
|
||||
return
|
||||
|
||||
input_data = data.get("data", "")
|
||||
await conn.ssh_session.write(input_data)
|
||||
|
||||
|
||||
async def handle_resize(conn: TerminalConnection, data: dict[str, Any]) -> None:
|
||||
"""处理终端大小调整"""
|
||||
if conn.ssh_session is None:
|
||||
return
|
||||
|
||||
cols = data.get("cols", 80)
|
||||
rows = data.get("rows", 24)
|
||||
|
||||
await conn.ssh_session.resize(cols, rows)
|
||||
await conn.send_json({
|
||||
"type": "resize_ok",
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
})
|
||||
313
src/minenasai/webtui/ssh_manager.py
Normal file
313
src/minenasai/webtui/ssh_manager.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""SSH 连接管理
|
||||
|
||||
使用 paramiko 管理 SSH 连接和 PTY
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import paramiko
|
||||
|
||||
from minenasai.core import get_logger, get_settings
|
||||
from minenasai.core.config import expand_path
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SSHSession:
|
||||
"""SSH 会话"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
host: str = "localhost",
|
||||
port: int = 22,
|
||||
username: str | None = None,
|
||||
key_path: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> None:
|
||||
"""初始化 SSH 会话
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
host: SSH 主机
|
||||
port: SSH 端口
|
||||
username: 用户名
|
||||
key_path: 私钥路径
|
||||
password: 密码(不推荐)
|
||||
"""
|
||||
self.session_id = session_id
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username or os.getenv("USER") or os.getenv("USERNAME") or "root"
|
||||
self.key_path = key_path
|
||||
self.password = password
|
||||
|
||||
self.client: paramiko.SSHClient | None = None
|
||||
self.channel: paramiko.Channel | None = None
|
||||
self.connected = False
|
||||
self.created_at = time.time()
|
||||
self.last_activity = time.time()
|
||||
|
||||
self._output_callback: Callable[[bytes], None] | None = None
|
||||
self._read_task: asyncio.Task[None] | None = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""建立 SSH 连接"""
|
||||
try:
|
||||
self.client = paramiko.SSHClient()
|
||||
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
# 连接参数
|
||||
connect_kwargs: dict[str, Any] = {
|
||||
"hostname": self.host,
|
||||
"port": self.port,
|
||||
"username": self.username,
|
||||
"timeout": 10,
|
||||
}
|
||||
|
||||
# 优先使用密钥认证
|
||||
if self.key_path:
|
||||
key_file = expand_path(self.key_path)
|
||||
if key_file.exists():
|
||||
connect_kwargs["key_filename"] = str(key_file)
|
||||
else:
|
||||
logger.warning("密钥文件不存在", path=str(key_file))
|
||||
|
||||
# 密码认证
|
||||
if self.password:
|
||||
connect_kwargs["password"] = self.password
|
||||
|
||||
# 尝试使用默认密钥
|
||||
if "key_filename" not in connect_kwargs and "password" not in connect_kwargs:
|
||||
connect_kwargs["allow_agent"] = True
|
||||
connect_kwargs["look_for_keys"] = True
|
||||
|
||||
# 在线程池中执行连接(避免阻塞)
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.client.connect(**connect_kwargs)
|
||||
)
|
||||
|
||||
# 打开交互式 Shell
|
||||
self.channel = self.client.invoke_shell(
|
||||
term="xterm-256color",
|
||||
width=80,
|
||||
height=24,
|
||||
)
|
||||
self.channel.setblocking(False)
|
||||
self.connected = True
|
||||
|
||||
logger.info(
|
||||
"SSH 连接成功",
|
||||
session_id=self.session_id,
|
||||
host=self.host,
|
||||
username=self.username,
|
||||
)
|
||||
return True
|
||||
|
||||
except paramiko.AuthenticationException as e:
|
||||
logger.error("SSH 认证失败", error=str(e))
|
||||
return False
|
||||
except paramiko.SSHException as e:
|
||||
logger.error("SSH 连接错误", error=str(e))
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("SSH 连接异常", error=str(e))
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开 SSH 连接"""
|
||||
self.connected = False
|
||||
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
try:
|
||||
await self._read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.channel:
|
||||
self.channel.close()
|
||||
self.channel = None
|
||||
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
logger.info("SSH 连接已断开", session_id=self.session_id)
|
||||
|
||||
def set_output_callback(self, callback: Callable[[bytes], None]) -> None:
|
||||
"""设置输出回调"""
|
||||
self._output_callback = callback
|
||||
|
||||
async def start_reading(self) -> None:
|
||||
"""开始读取输出"""
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
"""读取循环"""
|
||||
while self.connected and self.channel:
|
||||
try:
|
||||
# 检查是否有数据可读
|
||||
if self.channel.recv_ready():
|
||||
data = self.channel.recv(4096)
|
||||
if data and self._output_callback:
|
||||
self._output_callback(data)
|
||||
self.last_activity = time.time()
|
||||
else:
|
||||
await asyncio.sleep(0.01) # 短暂等待
|
||||
|
||||
except Exception as e:
|
||||
if self.connected:
|
||||
logger.error("SSH 读取错误", error=str(e))
|
||||
break
|
||||
|
||||
async def write(self, data: str | bytes) -> None:
|
||||
"""写入数据到 SSH 通道"""
|
||||
if not self.connected or not self.channel:
|
||||
return
|
||||
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.channel.send(data)
|
||||
)
|
||||
self.last_activity = time.time()
|
||||
except Exception as e:
|
||||
logger.error("SSH 写入错误", error=str(e))
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""调整终端大小"""
|
||||
if self.channel:
|
||||
try:
|
||||
self.channel.resize_pty(width=cols, height=rows)
|
||||
except Exception as e:
|
||||
logger.error("SSH 调整大小错误", error=str(e))
|
||||
|
||||
|
||||
class SSHManager:
|
||||
"""SSH 连接池管理器"""
|
||||
|
||||
def __init__(self, max_sessions: int = 50) -> None:
|
||||
"""初始化管理器
|
||||
|
||||
Args:
|
||||
max_sessions: 最大会话数
|
||||
"""
|
||||
self.settings = get_settings()
|
||||
self.max_sessions = max_sessions
|
||||
self._sessions: dict[str, SSHSession] = {}
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
session_id: str,
|
||||
host: str | None = None,
|
||||
port: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> SSHSession | None:
|
||||
"""创建新会话
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
host: SSH 主机(默认使用配置)
|
||||
port: SSH 端口
|
||||
username: 用户名
|
||||
|
||||
Returns:
|
||||
SSHSession 或 None(失败时)
|
||||
"""
|
||||
# 检查会话数量限制
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
# 清理过期会话
|
||||
await self._cleanup_sessions()
|
||||
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
logger.warning("会话数量已达上限", max=self.max_sessions)
|
||||
return None
|
||||
|
||||
# 使用配置的默认值
|
||||
ssh_config = self.settings.webtui.ssh
|
||||
session = SSHSession(
|
||||
session_id=session_id,
|
||||
host=host or ssh_config.host,
|
||||
port=port or ssh_config.port,
|
||||
username=username or self.settings.ssh_username,
|
||||
key_path=self.settings.ssh_key_path,
|
||||
)
|
||||
|
||||
if await session.connect():
|
||||
self._sessions[session_id] = session
|
||||
return session
|
||||
|
||||
return None
|
||||
|
||||
def get_session(self, session_id: str) -> SSHSession | None:
|
||||
"""获取会话"""
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
"""关闭会话"""
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session:
|
||||
await session.disconnect()
|
||||
|
||||
async def _cleanup_sessions(self, max_idle: float = 3600) -> None:
|
||||
"""清理空闲会话
|
||||
|
||||
Args:
|
||||
max_idle: 最大空闲时间(秒)
|
||||
"""
|
||||
now = time.time()
|
||||
expired = [
|
||||
sid for sid, session in self._sessions.items()
|
||||
if now - session.last_activity > max_idle
|
||||
]
|
||||
|
||||
for sid in expired:
|
||||
await self.close_session(sid)
|
||||
logger.info("清理空闲会话", session_id=sid)
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""关闭所有会话"""
|
||||
for session_id in list(self._sessions.keys()):
|
||||
await self.close_session(session_id)
|
||||
|
||||
def get_stats(self) -> dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
return {
|
||||
"active_sessions": len(self._sessions),
|
||||
"max_sessions": self.max_sessions,
|
||||
"sessions": [
|
||||
{
|
||||
"id": s.session_id,
|
||||
"host": s.host,
|
||||
"connected": s.connected,
|
||||
"created_at": s.created_at,
|
||||
"last_activity": s.last_activity,
|
||||
}
|
||||
for s in self._sessions.values()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# 全局 SSH 管理器
|
||||
_ssh_manager: SSHManager | None = None
|
||||
|
||||
|
||||
def get_ssh_manager() -> SSHManager:
|
||||
"""获取全局 SSH 管理器"""
|
||||
global _ssh_manager
|
||||
if _ssh_manager is None:
|
||||
_ssh_manager = SSHManager()
|
||||
return _ssh_manager
|
||||
377
src/minenasai/webtui/static/css/style.css
Normal file
377
src/minenasai/webtui/static/css/style.css
Normal file
@@ -0,0 +1,377 @@
|
||||
/* MineNASAI Web Terminal Styles */
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
--bg-secondary: #24283b;
|
||||
--bg-tertiary: #1f2335;
|
||||
--text-primary: #c0caf5;
|
||||
--text-secondary: #565f89;
|
||||
--accent-primary: #7aa2f7;
|
||||
--accent-success: #9ece6a;
|
||||
--accent-warning: #e0af68;
|
||||
--accent-error: #f7768e;
|
||||
--border-color: #3b4261;
|
||||
--header-height: 48px;
|
||||
--footer-height: 24px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
height: var(--header-height);
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-error);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background-color: var(--accent-success);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background-color: var(--accent-warning);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #89b4fa;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
height: 36px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.terminal-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.terminal-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 4px 4px 0 0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-tab.active {
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.btn-new-tab {
|
||||
background: none;
|
||||
border: 1px dashed var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-new-tab:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.terminal-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
height: var(--footer-height);
|
||||
background-color: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
width: 400px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--accent-error);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Fullscreen */
|
||||
.fullscreen .header,
|
||||
.fullscreen .footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fullscreen .terminal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
128
src/minenasai/webtui/static/index.html
Normal file
128
src/minenasai/webtui/static/index.html
Normal file
@@ -0,0 +1,128 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MineNASAI - Web Terminal</title>
|
||||
<!-- xterm.js -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="logo">MineNASAI</h1>
|
||||
<span class="version">v0.1.0</span>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<span class="connection-status" id="connectionStatus">
|
||||
<span class="status-dot disconnected"></span>
|
||||
<span class="status-text">未连接</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn btn-icon" id="settingsBtn" title="设置">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-primary" id="connectBtn">连接</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content">
|
||||
<!-- 终端容器 -->
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-tabs">
|
||||
<div class="terminal-tab active" data-tab="main">
|
||||
<span>主终端</span>
|
||||
<button class="tab-close" title="关闭">×</button>
|
||||
</div>
|
||||
<button class="btn-new-tab" id="newTabBtn" title="新建终端">+</button>
|
||||
</div>
|
||||
<div class="terminal-actions">
|
||||
<button class="btn btn-small" id="clearBtn" title="清屏">清屏</button>
|
||||
<button class="btn btn-small" id="fullscreenBtn" title="全屏">全屏</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal" class="terminal"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<footer class="footer">
|
||||
<div class="footer-left">
|
||||
<span id="sessionInfo">会话: -</span>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<span id="terminalSize">-</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- 设置模态框 -->
|
||||
<div class="modal" id="settingsModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>设置</h2>
|
||||
<button class="modal-close" id="closeSettingsBtn">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>字体大小</label>
|
||||
<input type="number" id="fontSizeInput" value="14" min="10" max="24">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题</label>
|
||||
<select id="themeSelect">
|
||||
<option value="dark">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="monokai">Monokai</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="cursorBlinkCheck" checked>
|
||||
光标闪烁
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="resetSettingsBtn">重置</button>
|
||||
<button class="btn btn-primary" id="saveSettingsBtn">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录模态框 -->
|
||||
<div class="modal" id="loginModal">
|
||||
<div class="modal-content modal-small">
|
||||
<div class="modal-header">
|
||||
<h2>连接到终端</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Token</label>
|
||||
<input type="password" id="tokenInput" placeholder="输入访问令牌">
|
||||
</div>
|
||||
<p class="hint">提示:令牌可从通讯工具消息中获取</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="cancelLoginBtn">取消</button>
|
||||
<button class="btn btn-primary" id="doConnectBtn">连接</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
393
src/minenasai/webtui/static/js/app.js
Normal file
393
src/minenasai/webtui/static/js/app.js
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* MineNASAI Web Terminal
|
||||
*/
|
||||
|
||||
class WebTerminal {
|
||||
constructor() {
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.socket = null;
|
||||
this.sessionId = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
|
||||
this.settings = this.loadSettings();
|
||||
this.init();
|
||||
}
|
||||
|
||||
loadSettings() {
|
||||
const defaults = {
|
||||
fontSize: 14,
|
||||
theme: 'dark',
|
||||
cursorBlink: true
|
||||
};
|
||||
try {
|
||||
const saved = localStorage.getItem('terminal_settings');
|
||||
return saved ? { ...defaults, ...JSON.parse(saved) } : defaults;
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
localStorage.setItem('terminal_settings', JSON.stringify(this.settings));
|
||||
}
|
||||
|
||||
getTheme(name) {
|
||||
const themes = {
|
||||
dark: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: '#1a1b26',
|
||||
selection: 'rgba(122, 162, 247, 0.3)',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5'
|
||||
},
|
||||
light: {
|
||||
background: '#f5f5f5',
|
||||
foreground: '#1a1b26',
|
||||
cursor: '#1a1b26',
|
||||
cursorAccent: '#f5f5f5',
|
||||
selection: 'rgba(122, 162, 247, 0.3)'
|
||||
},
|
||||
monokai: {
|
||||
background: '#272822',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
cursorAccent: '#272822',
|
||||
selection: 'rgba(73, 72, 62, 0.5)',
|
||||
black: '#272822',
|
||||
red: '#f92672',
|
||||
green: '#a6e22e',
|
||||
yellow: '#f4bf75',
|
||||
blue: '#66d9ef',
|
||||
magenta: '#ae81ff',
|
||||
cyan: '#a1efe4',
|
||||
white: '#f8f8f2'
|
||||
}
|
||||
};
|
||||
return themes[name] || themes.dark;
|
||||
}
|
||||
|
||||
init() {
|
||||
// 初始化终端
|
||||
this.terminal = new Terminal({
|
||||
fontSize: this.settings.fontSize,
|
||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace',
|
||||
cursorBlink: this.settings.cursorBlink,
|
||||
cursorStyle: 'block',
|
||||
theme: this.getTheme(this.settings.theme),
|
||||
allowTransparency: true,
|
||||
scrollback: 10000
|
||||
});
|
||||
|
||||
// 添加插件
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||
|
||||
// 挂载终端
|
||||
const container = document.getElementById('terminal');
|
||||
this.terminal.open(container);
|
||||
this.fitAddon.fit();
|
||||
|
||||
// 显示欢迎信息
|
||||
this.showWelcome();
|
||||
|
||||
// 绑定事件
|
||||
this.bindEvents();
|
||||
|
||||
// 窗口大小调整
|
||||
window.addEventListener('resize', () => {
|
||||
this.fitAddon.fit();
|
||||
this.sendResize();
|
||||
});
|
||||
|
||||
// 更新设置UI
|
||||
this.updateSettingsUI();
|
||||
}
|
||||
|
||||
showWelcome() {
|
||||
const welcome = [
|
||||
'\x1b[1;34m',
|
||||
' __ __ _ _ _ _ ____ _ ___ ',
|
||||
' | \\/ (_)_ __ ___| \\ | | / \\ / ___| / \\ |_ _|',
|
||||
' | |\\/| | | \'_ \\ / _ \\ \\| | / _ \\ \\___ \\ / _ \\ | | ',
|
||||
' | | | | | | | | __/ |\\ |/ ___ \\ ___) / ___ \\ | | ',
|
||||
' |_| |_|_|_| |_|\\___|_| \\_/_/ \\_\\____/_/ \\_\\___|',
|
||||
'\x1b[0m',
|
||||
'',
|
||||
'\x1b[33mWeb Terminal v0.1.0\x1b[0m',
|
||||
'',
|
||||
'点击右上角 \x1b[1;32m连接\x1b[0m 按钮开始使用',
|
||||
''
|
||||
];
|
||||
welcome.forEach(line => this.terminal.writeln(line));
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 连接按钮
|
||||
document.getElementById('connectBtn').addEventListener('click', () => {
|
||||
if (this.isConnected) {
|
||||
this.disconnect();
|
||||
} else {
|
||||
this.showLoginModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 登录模态框
|
||||
document.getElementById('doConnectBtn').addEventListener('click', () => {
|
||||
this.connect();
|
||||
});
|
||||
|
||||
document.getElementById('cancelLoginBtn').addEventListener('click', () => {
|
||||
this.hideLoginModal();
|
||||
});
|
||||
|
||||
document.getElementById('tokenInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.connect();
|
||||
});
|
||||
|
||||
// 设置按钮
|
||||
document.getElementById('settingsBtn').addEventListener('click', () => {
|
||||
document.getElementById('settingsModal').classList.add('active');
|
||||
});
|
||||
|
||||
document.getElementById('closeSettingsBtn').addEventListener('click', () => {
|
||||
document.getElementById('settingsModal').classList.remove('active');
|
||||
});
|
||||
|
||||
document.getElementById('saveSettingsBtn').addEventListener('click', () => {
|
||||
this.applySettings();
|
||||
document.getElementById('settingsModal').classList.remove('active');
|
||||
});
|
||||
|
||||
document.getElementById('resetSettingsBtn').addEventListener('click', () => {
|
||||
this.settings = { fontSize: 14, theme: 'dark', cursorBlink: true };
|
||||
this.updateSettingsUI();
|
||||
this.applySettings();
|
||||
});
|
||||
|
||||
// 清屏
|
||||
document.getElementById('clearBtn').addEventListener('click', () => {
|
||||
this.terminal.clear();
|
||||
});
|
||||
|
||||
// 全屏
|
||||
document.getElementById('fullscreenBtn').addEventListener('click', () => {
|
||||
document.body.classList.toggle('fullscreen');
|
||||
setTimeout(() => this.fitAddon.fit(), 100);
|
||||
});
|
||||
|
||||
// 终端输入
|
||||
this.terminal.onData(data => {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: data
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// ESC 退出全屏
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && document.body.classList.contains('fullscreen')) {
|
||||
document.body.classList.remove('fullscreen');
|
||||
setTimeout(() => this.fitAddon.fit(), 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateSettingsUI() {
|
||||
document.getElementById('fontSizeInput').value = this.settings.fontSize;
|
||||
document.getElementById('themeSelect').value = this.settings.theme;
|
||||
document.getElementById('cursorBlinkCheck').checked = this.settings.cursorBlink;
|
||||
}
|
||||
|
||||
applySettings() {
|
||||
this.settings.fontSize = parseInt(document.getElementById('fontSizeInput').value);
|
||||
this.settings.theme = document.getElementById('themeSelect').value;
|
||||
this.settings.cursorBlink = document.getElementById('cursorBlinkCheck').checked;
|
||||
|
||||
this.terminal.options.fontSize = this.settings.fontSize;
|
||||
this.terminal.options.cursorBlink = this.settings.cursorBlink;
|
||||
this.terminal.options.theme = this.getTheme(this.settings.theme);
|
||||
|
||||
this.saveSettings();
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
showLoginModal() {
|
||||
document.getElementById('loginModal').classList.add('active');
|
||||
document.getElementById('tokenInput').focus();
|
||||
}
|
||||
|
||||
hideLoginModal() {
|
||||
document.getElementById('loginModal').classList.remove('active');
|
||||
document.getElementById('tokenInput').value = '';
|
||||
}
|
||||
|
||||
connect() {
|
||||
const token = document.getElementById('tokenInput').value.trim();
|
||||
|
||||
this.hideLoginModal();
|
||||
this.updateStatus('connecting', '连接中...');
|
||||
this.terminal.writeln('\x1b[33m正在连接...\x1b[0m');
|
||||
|
||||
// 获取 WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/terminal`;
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(wsUrl);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
// 发送认证
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token: token || 'anonymous'
|
||||
}));
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
this.handleMessage(msg);
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
this.isConnected = false;
|
||||
this.updateStatus('disconnected', '已断开');
|
||||
this.updateConnectButton(false);
|
||||
|
||||
if (event.code !== 1000) {
|
||||
this.terminal.writeln(`\x1b[31m连接已断开 (${event.code})\x1b[0m`);
|
||||
this.tryReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
this.terminal.writeln('\x1b[31m连接错误\x1b[0m');
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
this.terminal.writeln(`\x1b[31m连接失败: ${e.message}\x1b[0m`);
|
||||
this.updateStatus('disconnected', '连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'auth_ok':
|
||||
this.isConnected = true;
|
||||
this.sessionId = msg.session_id;
|
||||
this.reconnectAttempts = 0;
|
||||
this.updateStatus('connected', '已连接');
|
||||
this.updateConnectButton(true);
|
||||
this.terminal.writeln('\x1b[32m已连接到终端\x1b[0m\r\n');
|
||||
document.getElementById('sessionInfo').textContent = `会话: ${this.sessionId.slice(0, 8)}`;
|
||||
|
||||
// 发送终端尺寸
|
||||
this.sendResize();
|
||||
break;
|
||||
|
||||
case 'auth_error':
|
||||
this.terminal.writeln(`\x1b[31m认证失败: ${msg.message}\x1b[0m`);
|
||||
this.updateStatus('disconnected', '认证失败');
|
||||
this.socket.close();
|
||||
break;
|
||||
|
||||
case 'output':
|
||||
this.terminal.write(msg.data);
|
||||
break;
|
||||
|
||||
case 'resize_ok':
|
||||
const cols = msg.cols;
|
||||
const rows = msg.rows;
|
||||
document.getElementById('terminalSize').textContent = `${cols}x${rows}`;
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.terminal.writeln(`\x1b[31m错误: ${msg.message}\x1b[0m`);
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
this.socket.send(JSON.stringify({ type: 'pong' }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sendResize() {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: dims.cols,
|
||||
rows: dims.rows
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.close(1000, 'User disconnect');
|
||||
this.socket = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
this.terminal.writeln('\r\n\x1b[33m已断开连接\x1b[0m');
|
||||
this.updateStatus('disconnected', '已断开');
|
||||
this.updateConnectButton(false);
|
||||
}
|
||||
|
||||
tryReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
this.terminal.writeln(`\x1b[33m${delay/1000}秒后尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})\x1b[0m`);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) {
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(status, text) {
|
||||
const statusEl = document.getElementById('connectionStatus');
|
||||
const dot = statusEl.querySelector('.status-dot');
|
||||
const textEl = statusEl.querySelector('.status-text');
|
||||
|
||||
dot.className = 'status-dot ' + status;
|
||||
textEl.textContent = text;
|
||||
}
|
||||
|
||||
updateConnectButton(connected) {
|
||||
const btn = document.getElementById('connectBtn');
|
||||
btn.textContent = connected ? '断开' : '连接';
|
||||
btn.classList.toggle('btn-primary', !connected);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.webTerminal = new WebTerminal();
|
||||
});
|
||||
Reference in New Issue
Block a user