Merge branch 'dev'
v1.1.0
This commit is contained in:
commit
59f4f89ac9
35 files changed
+4272
-2706
No files matched your search
@@ -42,3 +42,6 @@ frontend/data/
|
||||
.coverage
|
||||
coverage/
|
||||
htmlcov/
|
||||
|
||||
# Tasks (local only)
|
||||
tasks/
|
||||
@@ -11,6 +11,15 @@ One of its core capabilities is the persistent session design for long-running w
|
||||

|
||||

|
||||
|
||||
## Mobile Experience
|
||||
|
||||
A dedicated mobile layout provides touch-friendly interactions for terminal work, file management, and system monitoring in phone browsers.
|
||||
|
||||
<img src="./docs/images/mobile-overview.jpg" alt="Mobile Overview" width="320" style="max-width: 100%;" />
|
||||
<img src="./docs/images/mobile-terminal.jpg" alt="Mobile Terminal" width="320" style="max-width: 100%;" />
|
||||
<img src="./docs/images/mobile-fileManagement.jpg" alt="Mobile File Management" width="320" style="max-width: 100%;" />
|
||||
<img src="./docs/images/mobile-systemStatus.jpg" alt="Mobile System Status" width="320" style="max-width: 100%;" />
|
||||
|
||||
## Community Edition Notice
|
||||
|
||||
- This repository is the **Community Edition (CE)** of WebSSH Gateway.
|
||||
@@ -24,6 +33,7 @@ One of its core capabilities is the persistent session design for long-running w
|
||||
- Connection asset management with encrypted credentials
|
||||
- Session lifecycle management with reconnect and notes
|
||||
- Enhanced persistent session design powered by `tmux`, with keepalive and auto-retry
|
||||
- Mobile-friendly experience for phone browsers with touch-first layouts and controls
|
||||
- System monitoring for CPU, memory, network, process, and disk
|
||||
- File management: browse, upload/download, rename, delete, chmod, batch upload
|
||||
- Security baseline: JWT auth, password policy, lockout policy, request-id logging
|
||||
|
||||
@@ -11,6 +11,15 @@ WebSSH Gateway 是一个面向运维和开发场景的浏览器 SSH 网关。你
|
||||

|
||||

|
||||
|
||||
## 移动端适配
|
||||
|
||||
移动端提供专门的触摸友好布局与交互,支持在手机浏览器中完成终端操作、文件管理与系统监控。
|
||||
|
||||
<img src="./docs/images/mobile-overview.jpg" alt="移动端概览" width="320" style="max-width: 100%;" />
|
||||
<img src="./docs/images/mobile-terminal.jpg" alt="移动端终端" width="320" style="max-width: 100%;" />
|
||||
<img src="./docs/images/mobile-fileManagement.jpg" alt="移动端文件管理" width="320" style="max-width: 100%;" />
|
||||
<img src="./docs/images/mobile-systemStatus.jpg" alt="移动端系统状态" width="320" style="max-width: 100%;" />
|
||||
|
||||
## 社区版声明
|
||||
|
||||
- 本仓库为 **WebSSH Gateway 社区版(Community Edition)**。
|
||||
@@ -24,6 +33,7 @@ WebSSH Gateway 是一个面向运维和开发场景的浏览器 SSH 网关。你
|
||||
- 连接资产管理:支持保存主机连接信息,密码/私钥凭据加密存储。
|
||||
- 会话生命周期管理:会话状态跟踪、断开重连、会话备注。
|
||||
- 增强会话持久化:支持基于 `tmux` 的持久会话设计、增强保活与自动重试机制。
|
||||
- 移动端适配:面向手机浏览器的触摸友好操作与布局。
|
||||
- 系统监控面板:CPU、内存、网络、进程、磁盘等实时查看。
|
||||
- 文件管理能力:浏览、上传、下载、重命名、删除、权限修改、批量上传。
|
||||
- 基础安全能力:JWT 鉴权、密码复杂度校验、登录失败锁定、请求追踪 ID 日志。
|
||||
|
||||
@@ -8,7 +8,7 @@ import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.api.dependencies import AppState, get_current_user, get_current_user_from_ws, get_db, get_state
|
||||
from app.core.db import utc_now
|
||||
@@ -18,6 +18,7 @@ from app.models.connection import Connection
|
||||
from app.models.session import SessionRecord
|
||||
from app.schemas.api import (
|
||||
SessionCreateRequest,
|
||||
SessionOrderUpdateRequest,
|
||||
SessionNoteUpdateRequest,
|
||||
SessionResponse,
|
||||
SessionStatusResponse,
|
||||
@@ -140,6 +141,7 @@ def _build_session_response(
|
||||
username=conn.username,
|
||||
name=conn.name,
|
||||
note=record.note,
|
||||
session_order=record.session_order or 0,
|
||||
enhanced_enabled=bool(record.enhanced_enabled),
|
||||
remote_arch=conn.remote_arch,
|
||||
remote_os=conn.remote_os,
|
||||
@@ -165,7 +167,11 @@ def list_sessions(
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> list[SessionResponse]:
|
||||
records = db.execute(select(SessionRecord).where(SessionRecord.user_id == user.id)).scalars().all()
|
||||
records = db.execute(
|
||||
select(SessionRecord)
|
||||
.where(SessionRecord.user_id == user.id)
|
||||
.order_by(SessionRecord.session_order.asc(), SessionRecord.started_at.asc())
|
||||
).scalars().all()
|
||||
responses = []
|
||||
for record in records:
|
||||
conn = db.execute(select(Connection).where(Connection.id == record.connection_id)).scalar_one_or_none()
|
||||
@@ -310,6 +316,11 @@ async def create_session(
|
||||
if conn.enhance_prompt_shown is False and payload.enable_enhanced_persistence and binary_match is not None:
|
||||
conn.enhance_prompt_shown = True
|
||||
|
||||
max_order = db.execute(
|
||||
select(func.max(SessionRecord.session_order)).where(SessionRecord.user_id == user.id)
|
||||
).scalar_one()
|
||||
next_order = (max_order or 0) + 1
|
||||
|
||||
managed = await session_manager.create_session(
|
||||
connection=conn,
|
||||
auth_payload=auth_payload,
|
||||
@@ -328,6 +339,7 @@ async def create_session(
|
||||
last_activity=utc_now(),
|
||||
pty_info=session_manager.serialize_pty(pty),
|
||||
note=None,
|
||||
session_order=next_order,
|
||||
enhanced_enabled=enable_enhanced,
|
||||
enhanced_fingerprint=enhanced_fingerprint,
|
||||
tmux_binary_path=tmux_binary_path,
|
||||
@@ -376,7 +388,8 @@ async def retry_enhanced_session(
|
||||
pty = session_manager.deserialize_pty(record.pty_info)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, 4):
|
||||
retry_schedule = [2, 4, 8, 16, 32]
|
||||
for attempt in range(1, len(retry_schedule) + 1):
|
||||
try:
|
||||
await session_manager.close_session(session_id)
|
||||
managed = await session_manager.create_session(
|
||||
@@ -400,7 +413,8 @@ async def retry_enhanced_session(
|
||||
return response
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
await asyncio.sleep(5)
|
||||
if attempt < len(retry_schedule):
|
||||
await asyncio.sleep(retry_schedule[attempt - 1])
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(last_error) if last_error else "重试失败")
|
||||
|
||||
@@ -495,6 +509,48 @@ async def update_session_note(
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/order")
|
||||
def update_session_order(
|
||||
payload: SessionOrderUpdateRequest,
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> dict:
|
||||
records = db.execute(select(SessionRecord).where(SessionRecord.user_id == user.id)).scalars().all()
|
||||
if not records:
|
||||
return {"status": "ok"}
|
||||
|
||||
record_map = {record.id: record for record in records}
|
||||
ordered_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for session_id in payload.ordered_ids:
|
||||
if session_id in seen:
|
||||
continue
|
||||
if session_id not in record_map:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session not found")
|
||||
ordered_ids.append(session_id)
|
||||
seen.add(session_id)
|
||||
|
||||
existing_sorted = sorted(
|
||||
records,
|
||||
key=lambda record: (
|
||||
1 if (record.session_order is None or record.session_order <= 0) else 0,
|
||||
record.session_order or 0,
|
||||
record.started_at,
|
||||
),
|
||||
)
|
||||
for record in existing_sorted:
|
||||
if record.id in seen:
|
||||
continue
|
||||
ordered_ids.append(record.id)
|
||||
seen.add(record.id)
|
||||
|
||||
for index, session_id in enumerate(ordered_ids, start=1):
|
||||
record_map[session_id].session_order = index
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.websocket("/ws/terminal/{session_id}")
|
||||
async def terminal_socket(
|
||||
websocket: WebSocket,
|
||||
@@ -622,4 +678,3 @@ async def terminal_socket(
|
||||
await websocket.close(code=1008)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+334
-333
@@ -1,19 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
import os
|
||||
import tarfile
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, AsyncIterator
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.dependencies import AppState, get_current_user, get_state
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.dependencies import AppState, get_current_user, get_state, get_db
|
||||
from app.models.connection import Connection
|
||||
from app.models.session import SessionRecord
|
||||
from app.services.crypto import CryptoService, EncryptedPayload
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
@@ -153,11 +160,48 @@ def build_upload_result(
|
||||
return UploadResult(uploaded=uploaded, skipped=skipped, failed=failed, errors=errors or []).model_dump()
|
||||
|
||||
|
||||
async def run_ssh_command(session, command: str, timeout: float = 5.0) -> str:
|
||||
@asynccontextmanager
|
||||
async def resolve_ssh_client(
|
||||
session_id: str,
|
||||
state: AppState,
|
||||
user,
|
||||
db,
|
||||
) -> AsyncIterator:
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if session:
|
||||
yield session.client
|
||||
return
|
||||
|
||||
record = db.execute(
|
||||
select(SessionRecord).where(SessionRecord.id == session_id, SessionRecord.user_id == user.id)
|
||||
).scalar_one_or_none()
|
||||
if not record or record.status != "active":
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
|
||||
conn = db.execute(
|
||||
select(Connection).where(Connection.id == record.connection_id, Connection.user_id == user.id)
|
||||
).scalar_one_or_none()
|
||||
if not conn:
|
||||
raise HTTPException(status_code=404, detail="连接不存在")
|
||||
|
||||
crypto = CryptoService(state.config.secret_keys)
|
||||
auth_data = json.loads(conn.auth_data)
|
||||
decrypted = crypto.decrypt(EncryptedPayload(nonce=auth_data["nonce"], ciphertext=auth_data["ciphertext"]))
|
||||
auth_payload = json.loads(decrypted)
|
||||
|
||||
client = await state.session_manager.connect_client(conn, auth_payload)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
async def run_ssh_command(session_or_client, command: str, timeout: float = 5.0) -> str:
|
||||
"""在 SSH 会话中执行命令并返回输出"""
|
||||
try:
|
||||
client = getattr(session_or_client, "client", session_or_client)
|
||||
result = await asyncio.wait_for(
|
||||
session.client.run(command, check=False),
|
||||
client.run(command, check=False),
|
||||
timeout=timeout
|
||||
)
|
||||
return result.stdout or ""
|
||||
@@ -401,74 +445,62 @@ async def collect_system_overview(session) -> SystemOverview:
|
||||
async def get_system_stats(
|
||||
session_id: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> SystemStats:
|
||||
"""获取远程服务器系统状态信息(CPU、内存、交换区)"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
stat1, meminfo, cpuinfo = await asyncio.gather(
|
||||
run_ssh_command(client, "cat /proc/stat"),
|
||||
run_ssh_command(client, "cat /proc/meminfo"),
|
||||
run_ssh_command(client, "cat /proc/cpuinfo"),
|
||||
)
|
||||
|
||||
# 骞惰鑾峰彇绗竴娆℃暟鎹?
|
||||
stat1, meminfo, cpuinfo = await asyncio.gather(
|
||||
run_ssh_command(session, "cat /proc/stat"),
|
||||
run_ssh_command(session, "cat /proc/meminfo"),
|
||||
run_ssh_command(session, "cat /proc/cpuinfo"),
|
||||
)
|
||||
interval = 0.3
|
||||
await asyncio.sleep(interval)
|
||||
stat2 = await run_ssh_command(client, "cat /proc/stat")
|
||||
|
||||
# 等待一小段时间再次获取 CPU 统计
|
||||
interval = 0.3
|
||||
await asyncio.sleep(interval)
|
||||
stat2 = await run_ssh_command(session, "cat /proc/stat")
|
||||
memory, swap = parse_memory_info(meminfo)
|
||||
cpu = parse_cpu_info(stat1, stat2, cpuinfo)
|
||||
|
||||
# 解析数据
|
||||
memory, swap = parse_memory_info(meminfo)
|
||||
cpu = parse_cpu_info(stat1, stat2, cpuinfo)
|
||||
|
||||
return SystemStats(cpu=cpu, memory=memory, swap=swap)
|
||||
return SystemStats(cpu=cpu, memory=memory, swap=swap)
|
||||
|
||||
|
||||
@router.get("/network/{session_id}", response_model=NetworkInfo)
|
||||
async def get_network_stats(
|
||||
session_id: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> NetworkInfo:
|
||||
"""获取远程服务器网络速度"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
net1 = await run_ssh_command(client, "cat /proc/net/dev")
|
||||
interval = 0.5
|
||||
await asyncio.sleep(interval)
|
||||
net2 = await run_ssh_command(client, "cat /proc/net/dev")
|
||||
|
||||
# 获取两次网络统计
|
||||
net1 = await run_ssh_command(session, "cat /proc/net/dev")
|
||||
interval = 0.5
|
||||
await asyncio.sleep(interval)
|
||||
net2 = await run_ssh_command(session, "cat /proc/net/dev")
|
||||
|
||||
return parse_network_stats(net1, net2, interval)
|
||||
return parse_network_stats(net1, net2, interval)
|
||||
|
||||
|
||||
@router.get("/processes/{session_id}", response_model=ProcessList)
|
||||
async def get_processes(
|
||||
session_id: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> ProcessList:
|
||||
"""获取远程服务器进程列表"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
ps_output = await run_ssh_command(
|
||||
client,
|
||||
"ps aux 2>/dev/null || ps -ef 2>/dev/null",
|
||||
timeout=5.0
|
||||
)
|
||||
|
||||
# 使用 ps 命令获取进程信息
|
||||
ps_output = await run_ssh_command(
|
||||
session,
|
||||
"ps aux 2>/dev/null || ps -ef 2>/dev/null",
|
||||
timeout=5.0
|
||||
)
|
||||
|
||||
processes = parse_processes(ps_output)
|
||||
# 鎸夊唴瀛樻帓搴忓彇鍓?20
|
||||
processes.sort(key=lambda x: x.memory_bytes, reverse=True)
|
||||
return ProcessList(processes=processes[:20])
|
||||
processes = parse_processes(ps_output)
|
||||
processes.sort(key=lambda x: x.memory_bytes, reverse=True)
|
||||
return ProcessList(processes=processes[:20])
|
||||
|
||||
|
||||
def parse_ls_output(ls_output: str, base_path: str) -> List[FileInfo]:
|
||||
@@ -557,59 +589,52 @@ async def list_directory(
|
||||
session_id: str,
|
||||
path: str = "/",
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> DirectoryListing:
|
||||
"""列出远程服务器目录内容"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
if not clean_path.startswith("/"):
|
||||
clean_path = "/" + clean_path
|
||||
|
||||
# 规范化路径,防止路径遍历攻击
|
||||
clean_path = sanitize_shell_path(path)
|
||||
if not clean_path.startswith("/"):
|
||||
clean_path = "/" + clean_path
|
||||
ls_output = await run_ssh_command(
|
||||
client,
|
||||
f"ls -la '{clean_path}' 2>/dev/null",
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
# 使用 ls -la 获取目录内容
|
||||
ls_output = await run_ssh_command(
|
||||
session,
|
||||
f"ls -la '{clean_path}' 2>/dev/null",
|
||||
timeout=10.0
|
||||
)
|
||||
if not ls_output:
|
||||
raise HTTPException(status_code=404, detail="目录不存在或无权访问")
|
||||
|
||||
if not ls_output:
|
||||
raise HTTPException(status_code=404, detail="目录不存在或无权访问")
|
||||
|
||||
files = parse_ls_output(ls_output, clean_path)
|
||||
return DirectoryListing(path=clean_path, files=files)
|
||||
files = parse_ls_output(ls_output, clean_path)
|
||||
return DirectoryListing(path=clean_path, files=files)
|
||||
|
||||
|
||||
@router.get("/disks/{session_id}", response_model=DiskList)
|
||||
async def get_disks(
|
||||
session_id: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> DiskList:
|
||||
"""获取磁盘挂载信息"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
df_output = await run_ssh_command(client, "df -B1 | grep '^/'", timeout=5.0)
|
||||
|
||||
df_output = await run_ssh_command(session, "df -B1 | grep '^/'", timeout=5.0)
|
||||
|
||||
return DiskList(disks=parse_disks(df_output))
|
||||
return DiskList(disks=parse_disks(df_output))
|
||||
|
||||
|
||||
@router.get("/overview/{session_id}", response_model=SystemOverview)
|
||||
async def get_system_overview(
|
||||
session_id: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> SystemOverview:
|
||||
"""一次返回系统状态、网络、进程和磁盘信息。"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
return await collect_system_overview(session)
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
return await collect_system_overview(client)
|
||||
|
||||
|
||||
@router.get("/file/{session_id}")
|
||||
@@ -618,24 +643,21 @@ async def read_file(
|
||||
path: str,
|
||||
force: bool = False,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
) -> dict:
|
||||
"""读取文件内容"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
|
||||
clean_path = sanitize_shell_path(path)
|
||||
size_check = await run_ssh_command(client, f"stat -c %s '{clean_path}' 2>/dev/null || echo 0")
|
||||
size = int(size_check.strip() or 0)
|
||||
|
||||
# 检查文件大小
|
||||
size_check = await run_ssh_command(session, f"stat -c %s '{clean_path}' 2>/dev/null || echo 0")
|
||||
size = int(size_check.strip() or 0)
|
||||
if size > 1024 * 1024 and not force:
|
||||
return {"size": size, "too_large": True}
|
||||
|
||||
if size > 1024 * 1024 and not force:
|
||||
return {"size": size, "too_large": True}
|
||||
|
||||
content = await run_ssh_command(session, f"cat '{clean_path}' 2>/dev/null", timeout=30.0)
|
||||
return {"content": content, "size": size}
|
||||
content = await run_ssh_command(client, f"cat '{clean_path}' 2>/dev/null", timeout=30.0)
|
||||
return {"content": content, "size": size}
|
||||
|
||||
|
||||
@router.post("/file/{session_id}")
|
||||
@@ -644,26 +666,24 @@ async def write_file(
|
||||
path: str,
|
||||
payload: FileContent,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""写入文件内容"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
|
||||
clean_path = sanitize_shell_path(path)
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
|
||||
tmp.write(payload.content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
|
||||
tmp.write(payload.content)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.put(tmp_path, clean_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
try:
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.put(tmp_path, clean_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/upload/{session_id}")
|
||||
@@ -673,46 +693,44 @@ async def upload_file(
|
||||
compress: bool = False,
|
||||
file: UploadFile = File(...),
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""上传文件到远程服务器"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
|
||||
clean_path = sanitize_shell_path(path)
|
||||
safe_filename = sanitize_upload_filename(file.filename)
|
||||
|
||||
safe_filename = sanitize_upload_filename(file.filename)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
local_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, safe_filename))
|
||||
content = await file.read()
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
local_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, safe_filename))
|
||||
content = await file.read()
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(content)
|
||||
errors: List[str] = []
|
||||
uploaded = 0
|
||||
|
||||
errors: List[str] = []
|
||||
uploaded = 0
|
||||
try:
|
||||
if compress:
|
||||
tar_path = os.path.join(tmpdir, f"{safe_filename}.tar.gz")
|
||||
create_tar_gz(tar_path, local_path, safe_filename)
|
||||
|
||||
try:
|
||||
if compress:
|
||||
tar_path = os.path.join(tmpdir, f"{safe_filename}.tar.gz")
|
||||
create_tar_gz(tar_path, local_path, safe_filename)
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.put(tar_path, f"{clean_path}/{safe_filename}.tar.gz")
|
||||
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.put(tar_path, f"{clean_path}/{safe_filename}.tar.gz")
|
||||
|
||||
await run_ssh_command(
|
||||
session,
|
||||
f"cd '{clean_path}' && tar -xzf '{safe_filename}.tar.gz' && rm -f '{safe_filename}.tar.gz'",
|
||||
timeout=30.0,
|
||||
)
|
||||
uploaded = 1
|
||||
else:
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.put(local_path, f"{clean_path}/{safe_filename}")
|
||||
uploaded = 1
|
||||
except Exception as exc:
|
||||
errors.append(f"{safe_filename}: {exc}")
|
||||
await run_ssh_command(
|
||||
client,
|
||||
f"cd '{clean_path}' && tar -xzf '{safe_filename}.tar.gz' && rm -f '{safe_filename}.tar.gz'",
|
||||
timeout=30.0,
|
||||
)
|
||||
uploaded = 1
|
||||
else:
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.put(local_path, f"{clean_path}/{safe_filename}")
|
||||
uploaded = 1
|
||||
except Exception as exc:
|
||||
errors.append(f"{safe_filename}: {exc}")
|
||||
|
||||
if errors:
|
||||
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
|
||||
@@ -726,37 +744,35 @@ async def upload_targz(
|
||||
path: str,
|
||||
file: UploadFile = File(...),
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""上传 tar.gz 压缩包并解压"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
|
||||
clean_path = sanitize_shell_path(path)
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
errors: List[str] = []
|
||||
uploaded = 0
|
||||
|
||||
errors: List[str] = []
|
||||
uploaded = 0
|
||||
try:
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.put(tmp_path, f"{clean_path}/upload.tar.gz")
|
||||
await run_ssh_command(client, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=120.0)
|
||||
uploaded = 1
|
||||
except Exception as exc:
|
||||
errors.append(f"upload.tar.gz: {exc}")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
try:
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.put(tmp_path, f"{clean_path}/upload.tar.gz")
|
||||
await run_ssh_command(session, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=120.0)
|
||||
uploaded = 1
|
||||
except Exception as exc:
|
||||
errors.append(f"upload.tar.gz: {exc}")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
if errors:
|
||||
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
|
||||
|
||||
if errors:
|
||||
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
|
||||
|
||||
return build_upload_result(uploaded=uploaded)
|
||||
return build_upload_result(uploaded=uploaded)
|
||||
|
||||
|
||||
@router.post("/upload-zip/{session_id}")
|
||||
@@ -765,37 +781,35 @@ async def upload_zip(
|
||||
path: str,
|
||||
file: UploadFile = File(...),
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""上传 zip 压缩包并解压"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
|
||||
clean_path = sanitize_shell_path(path)
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
errors: List[str] = []
|
||||
uploaded = 0
|
||||
|
||||
errors: List[str] = []
|
||||
uploaded = 0
|
||||
try:
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.put(tmp_path, f"{clean_path}/upload.zip")
|
||||
await run_ssh_command(client, f"cd '{clean_path}' && unzip -q upload.zip && rm -f upload.zip", timeout=120.0)
|
||||
uploaded = 1
|
||||
except Exception as exc:
|
||||
errors.append(f"upload.zip: {exc}")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
try:
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.put(tmp_path, f"{clean_path}/upload.zip")
|
||||
await run_ssh_command(session, f"cd '{clean_path}' && unzip -q upload.zip && rm -f upload.zip", timeout=120.0)
|
||||
uploaded = 1
|
||||
except Exception as exc:
|
||||
errors.append(f"upload.zip: {exc}")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
if errors:
|
||||
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
|
||||
|
||||
if errors:
|
||||
return build_upload_result(uploaded=uploaded, failed=len(errors), errors=errors)
|
||||
|
||||
return build_upload_result(uploaded=uploaded)
|
||||
return build_upload_result(uploaded=uploaded)
|
||||
|
||||
|
||||
@router.post("/upload-batch/{session_id}")
|
||||
@@ -806,72 +820,70 @@ async def upload_batch(
|
||||
concurrent: int = 3,
|
||||
files: List[UploadFile] = File(...),
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""批量上传文件(支持文件夹结构)"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
|
||||
clean_path = sanitize_shell_path(path)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
written_files = 0
|
||||
staged_files: List[tuple[str, str]] = []
|
||||
errors: List[str] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
written_files = 0
|
||||
staged_files: List[tuple[str, str]] = []
|
||||
errors: List[str] = []
|
||||
for file in files:
|
||||
relative_path = sanitize_upload_relative_path(file.filename)
|
||||
file_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, relative_path))
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
content = await file.read()
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
for file in files:
|
||||
relative_path = sanitize_upload_relative_path(file.filename)
|
||||
file_path = ensure_within_directory(tmpdir, os.path.join(tmpdir, relative_path))
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
content = await file.read()
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
staged_files.append((relative_path, file_path))
|
||||
written_files += 1
|
||||
|
||||
staged_files.append((relative_path, file_path))
|
||||
written_files += 1
|
||||
if compress:
|
||||
tar_path = os.path.join(tmpdir, "upload.tar.gz")
|
||||
with tarfile.open(tar_path, "w:gz") as tar:
|
||||
for relative_path, file_path in staged_files:
|
||||
tar.add(file_path, arcname=relative_path)
|
||||
|
||||
if compress:
|
||||
tar_path = os.path.join(tmpdir, "upload.tar.gz")
|
||||
with tarfile.open(tar_path, "w:gz") as tar:
|
||||
for relative_path, file_path in staged_files:
|
||||
tar.add(file_path, arcname=relative_path)
|
||||
try:
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.put(tar_path, f"{clean_path}/upload.tar.gz")
|
||||
|
||||
try:
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.put(tar_path, f"{clean_path}/upload.tar.gz")
|
||||
await run_ssh_command(client, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=60.0)
|
||||
except Exception as exc:
|
||||
errors.append(f"upload.tar.gz: {exc}")
|
||||
return build_upload_result(uploaded=0, failed=written_files, errors=errors)
|
||||
|
||||
await run_ssh_command(session, f"cd '{clean_path}' && tar -xzf upload.tar.gz && rm -f upload.tar.gz", timeout=60.0)
|
||||
except Exception as exc:
|
||||
errors.append(f"upload.tar.gz: {exc}")
|
||||
return build_upload_result(uploaded=0, failed=written_files, errors=errors)
|
||||
return build_upload_result(uploaded=written_files)
|
||||
|
||||
return build_upload_result(uploaded=written_files)
|
||||
uploaded = 0
|
||||
queue = list(staged_files)
|
||||
concurrency = max(1, min(concurrent, 8))
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
uploaded = 0
|
||||
queue = list(staged_files)
|
||||
concurrency = max(1, min(concurrent, 8))
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
async def upload_one(relative_path: str, file_path: str) -> bool:
|
||||
remote_path = f"{clean_path}/{relative_path}"
|
||||
remote_dir = os.path.dirname(remote_path)
|
||||
|
||||
async def upload_one(relative_path: str, file_path: str) -> bool:
|
||||
remote_path = f"{clean_path}/{relative_path}"
|
||||
remote_dir = os.path.dirname(remote_path)
|
||||
try:
|
||||
async with semaphore:
|
||||
await run_ssh_command(client, f"mkdir -p '{remote_dir}'")
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.put(file_path, remote_path)
|
||||
return True
|
||||
except Exception as exc:
|
||||
errors.append(f"{relative_path}: {exc}")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with semaphore:
|
||||
await run_ssh_command(session, f"mkdir -p '{remote_dir}'")
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.put(file_path, remote_path)
|
||||
return True
|
||||
except Exception as exc:
|
||||
errors.append(f"{relative_path}: {exc}")
|
||||
return False
|
||||
results = await asyncio.gather(*(upload_one(relative_path, file_path) for relative_path, file_path in queue))
|
||||
uploaded = sum(1 for ok in results if ok)
|
||||
|
||||
results = await asyncio.gather(*(upload_one(relative_path, file_path) for relative_path, file_path in queue))
|
||||
uploaded = sum(1 for ok in results if ok)
|
||||
|
||||
failed = len(errors)
|
||||
return build_upload_result(uploaded=uploaded, failed=failed, errors=errors)
|
||||
failed = len(errors)
|
||||
return build_upload_result(uploaded=uploaded, failed=failed, errors=errors)
|
||||
|
||||
|
||||
@router.post("/mkdir/{session_id}")
|
||||
@@ -879,15 +891,14 @@ async def make_directory(
|
||||
session_id: str,
|
||||
path: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""创建目录"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
clean_path = sanitize_shell_path(path)
|
||||
await run_ssh_command(session, f"mkdir -p '{clean_path}'")
|
||||
return {"status": "ok"}
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
await run_ssh_command(client, f"mkdir -p '{clean_path}'")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/touch/{session_id}")
|
||||
@@ -895,15 +906,14 @@ async def touch_file(
|
||||
session_id: str,
|
||||
path: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""创建文件"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
clean_path = sanitize_shell_path(path)
|
||||
await run_ssh_command(session, f"touch '{clean_path}'")
|
||||
return {"status": "ok"}
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
await run_ssh_command(client, f"touch '{clean_path}'")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/rename/{session_id}")
|
||||
@@ -912,16 +922,15 @@ async def rename_file(
|
||||
old_path: str,
|
||||
new_path: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""重命名文件或目录"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
clean_old = sanitize_shell_path(old_path)
|
||||
clean_new = sanitize_shell_path(new_path)
|
||||
await run_ssh_command(session, f"mv '{clean_old}' '{clean_new}'")
|
||||
return {"status": "ok"}
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_old = sanitize_shell_path(old_path)
|
||||
clean_new = sanitize_shell_path(new_path)
|
||||
await run_ssh_command(client, f"mv '{clean_old}' '{clean_new}'")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/chmod/{session_id}")
|
||||
@@ -931,17 +940,16 @@ async def change_mode(
|
||||
mode: str,
|
||||
recursive: bool = False,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""修改文件权限"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
clean_path = sanitize_shell_path(path)
|
||||
clean_mode = sanitize_shell_path(mode)
|
||||
recursive_flag = "-R " if recursive else ""
|
||||
await run_ssh_command(session, f"chmod {recursive_flag}{clean_mode} '{clean_path}'")
|
||||
return {"status": "ok"}
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
clean_mode = sanitize_shell_path(mode)
|
||||
recursive_flag = "-R " if recursive else ""
|
||||
await run_ssh_command(client, f"chmod {recursive_flag}{clean_mode} '{clean_path}'")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/delete/{session_id}")
|
||||
@@ -949,15 +957,14 @@ async def delete_file(
|
||||
session_id: str,
|
||||
path: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""删除文件或目录"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
clean_path = sanitize_shell_path(path)
|
||||
await run_ssh_command(session, f"rm -rf '{clean_path}'")
|
||||
return {"status": "ok"}
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
await run_ssh_command(client, f"rm -rf '{clean_path}'")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/download/{session_id}")
|
||||
@@ -965,71 +972,65 @@ async def download_file(
|
||||
session_id: str,
|
||||
path: str,
|
||||
state: AppState = Depends(get_state),
|
||||
_user: dict = Depends(get_current_user)
|
||||
user=Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""从远程服务器下载文件"""
|
||||
session = state.session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
async with resolve_ssh_client(session_id, state, user, db) as client:
|
||||
clean_path = sanitize_shell_path(path)
|
||||
|
||||
clean_path = sanitize_shell_path(path)
|
||||
check_result = await run_ssh_command(client, f"[ -d '{clean_path}' ] && echo 'dir' || echo 'file'")
|
||||
is_dir = check_result.strip() == "dir"
|
||||
|
||||
# 检查是否为目录
|
||||
check_result = await run_ssh_command(session, f"[ -d '{clean_path}' ] && echo 'dir' || echo 'file'")
|
||||
is_dir = check_result.strip() == "dir"
|
||||
filename = os.path.basename(clean_path)
|
||||
|
||||
filename = os.path.basename(clean_path)
|
||||
if is_dir:
|
||||
tar_name = f"{filename}.tar.gz"
|
||||
remote_tar = f"/tmp/{tar_name}"
|
||||
await run_ssh_command(client, f"tar -czf {remote_tar} -C {os.path.dirname(clean_path)} {filename}", timeout=60.0)
|
||||
|
||||
if is_dir:
|
||||
# 压缩目录
|
||||
tar_name = f"{filename}.tar.gz"
|
||||
remote_tar = f"/tmp/{tar_name}"
|
||||
await run_ssh_command(session, f"tar -czf {remote_tar} -C {os.path.dirname(clean_path)} {filename}", timeout=60.0)
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.get(remote_tar, tmp_path)
|
||||
|
||||
try:
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.get(remote_tar, tmp_path)
|
||||
await run_ssh_command(client, f"rm {remote_tar}")
|
||||
|
||||
await run_ssh_command(session, f"rm {remote_tar}")
|
||||
with open(tmp_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
with open(tmp_path, "rb") as f:
|
||||
content = f.read()
|
||||
encoded_filename = quote(tar_name)
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="application/gzip",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
else:
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
encoded_filename = quote(tar_name)
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="application/gzip",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
else:
|
||||
# 直接下载文件
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
async with client.start_sftp_client() as sftp:
|
||||
await sftp.get(clean_path, tmp_path)
|
||||
|
||||
try:
|
||||
async with session.client.start_sftp_client() as sftp:
|
||||
await sftp.get(clean_path, tmp_path)
|
||||
|
||||
with open(tmp_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
encoded_filename = quote(filename)
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
with open(tmp_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
encoded_filename = quote(filename)
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
@@ -35,10 +35,11 @@ def _serialize_session_status(record: SessionRecord) -> str:
|
||||
|
||||
|
||||
def _required_elapsed_seconds_for_next_retry(retry_cycle_count: int) -> int:
|
||||
# Retry spacing: 5s, 10s, 15s...
|
||||
# Retry spacing (seconds): 2, 4, 8, 16, 32
|
||||
# retry_cycle_count is the number of attempts already made in current cycle.
|
||||
next_attempt = retry_cycle_count + 1
|
||||
return 5 * next_attempt * (next_attempt + 1) // 2
|
||||
schedule = [2, 4, 8, 16, 32]
|
||||
index = min(retry_cycle_count, len(schedule) - 1)
|
||||
return schedule[index]
|
||||
|
||||
|
||||
def build_state() -> tuple[
|
||||
@@ -95,7 +96,7 @@ def build_state() -> tuple[
|
||||
SessionRecord.allow_auto_retry.is_(True),
|
||||
SessionRecord.enhanced_fingerprint.is_not(None),
|
||||
SessionRecord.tmux_binary_path.is_not(None),
|
||||
SessionRecord.retry_cycle_count < 3,
|
||||
SessionRecord.retry_cycle_count < 5,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
+3
-1
@@ -21,6 +21,7 @@ from app.services.bootstrap import (
|
||||
ensure_connection_arch_columns,
|
||||
ensure_session_enhanced_columns,
|
||||
ensure_session_note_column,
|
||||
ensure_session_order_column,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -44,6 +45,7 @@ def create_app() -> FastAPI:
|
||||
ensure_session_note_column(database)
|
||||
ensure_connection_arch_columns(database)
|
||||
ensure_session_enhanced_columns(database)
|
||||
ensure_session_order_column(database)
|
||||
|
||||
with database.session() as db_session:
|
||||
password = ensure_admin_user(db_session, auth_service)
|
||||
@@ -158,7 +160,7 @@ def create_app() -> FastAPI:
|
||||
if response.status_code != 404 or request.method != "GET":
|
||||
return response
|
||||
path = request.url.path
|
||||
if path.startswith("/api") or path.startswith("/auth") or path.startswith("/sessions"):
|
||||
if path.startswith("/api") or path.startswith("/auth") or path.startswith("/sessions") or path.startswith("/system"):
|
||||
return response
|
||||
return index_response
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ class SessionRecord(TimestampMixin, Base):
|
||||
last_activity: Mapped[datetime] = mapped_column(UTCDateTime())
|
||||
pty_info: Mapped[str] = mapped_column(Text)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
session_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
enhanced_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
enhanced_fingerprint: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
|
||||
tmux_binary_path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -94,6 +94,7 @@ class SessionResponse(BaseModel):
|
||||
username: str
|
||||
name: str
|
||||
note: Optional[str] = None
|
||||
session_order: int = 0
|
||||
enhanced_enabled: bool = False
|
||||
remote_arch: Optional[str] = None
|
||||
remote_os: Optional[str] = None
|
||||
@@ -119,6 +120,10 @@ class SessionNoteUpdateRequest(BaseModel):
|
||||
note: Optional[str] = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class SessionOrderUpdateRequest(BaseModel):
|
||||
ordered_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TerminalMessage(BaseModel):
|
||||
type: str
|
||||
data: Optional[str] = None
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import AppConfig
|
||||
from app.core.db import Database
|
||||
from app.models.session import SessionRecord
|
||||
from app.models.user import User
|
||||
from app.services.auth import AuthService
|
||||
|
||||
@@ -94,6 +95,37 @@ def ensure_session_enhanced_columns(database: Database) -> None:
|
||||
connection.execute(text("ALTER TABLE sessions ADD COLUMN allow_auto_retry BOOLEAN DEFAULT 1"))
|
||||
|
||||
|
||||
def ensure_session_order_column(database: Database) -> None:
|
||||
inspector = inspect(database._engine)
|
||||
if "sessions" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("sessions")}
|
||||
with database._engine.begin() as connection:
|
||||
if "session_order" not in columns:
|
||||
connection.execute(text("ALTER TABLE sessions ADD COLUMN session_order INTEGER"))
|
||||
|
||||
# 为历史数据补齐顺序(按 started_at 排序)
|
||||
with database.session() as db_session:
|
||||
records = db_session.execute(
|
||||
select(SessionRecord).order_by(SessionRecord.user_id, SessionRecord.started_at)
|
||||
).scalars().all()
|
||||
last_order_by_user: dict[int, int] = {}
|
||||
|
||||
for record in records:
|
||||
if record.session_order and record.session_order > 0:
|
||||
last_order_by_user[record.user_id] = max(
|
||||
last_order_by_user.get(record.user_id, 0),
|
||||
record.session_order,
|
||||
)
|
||||
|
||||
for record in records:
|
||||
if record.session_order and record.session_order > 0:
|
||||
continue
|
||||
next_order = last_order_by_user.get(record.user_id, 0) + 1
|
||||
record.session_order = next_order
|
||||
last_order_by_user[record.user_id] = next_order
|
||||
|
||||
|
||||
def is_user_locked(user: User, auth_service: AuthService) -> bool:
|
||||
return auth_service.is_locked(user)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 399 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
@@ -59,6 +59,7 @@ type FileBrowserProps = {
|
||||
currentDir?: string;
|
||||
onFileSelect?: (path: string | null) => void;
|
||||
networkProfile?: NetworkProfile;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
type UploadStats = {
|
||||
@@ -106,10 +107,11 @@ function createRootTree(): TreeNode[] {
|
||||
return [{ name: "/", path: "/", expanded: true, loaded: false }];
|
||||
}
|
||||
|
||||
export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, networkProfile }: FileBrowserProps) {
|
||||
export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, networkProfile, compact }: FileBrowserProps) {
|
||||
const { networkProfile: globalNetworkProfile, language } = useApp();
|
||||
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
|
||||
const effectiveNetworkProfile = networkProfile ?? globalNetworkProfile;
|
||||
const isCompact = compact === true;
|
||||
const directoryCacheTtlMs =
|
||||
effectiveNetworkProfile === "poor" ? 12000 : effectiveNetworkProfile === "degraded" ? 6000 : 3000;
|
||||
const currentDirDebounceMs =
|
||||
@@ -146,6 +148,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
const [dialogInput, setDialogInput] = React.useState("");
|
||||
const [chmodRecursive, setChmodRecursive] = React.useState(false);
|
||||
const [chmodLoading, setChmodLoading] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const directoryCacheRef = React.useRef<Map<string, DirectoryCacheItem>>(new Map());
|
||||
const treeRef = React.useRef<TreeNode[]>(tree);
|
||||
const selectedPathRef = React.useRef<string>(selectedPath);
|
||||
@@ -515,11 +518,11 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
[mapBatchFailures, uploadBatchWithRetry, t]
|
||||
);
|
||||
|
||||
// 处理文件拖拽上传
|
||||
const handleDrop = React.useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const handleUploadFiles = React.useCallback(async (allFiles: Array<{ file: File; path: string }>) => {
|
||||
if (allFiles.length === 0) {
|
||||
push(t("没有可上传的文件", "No files to upload"));
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setUploadProgress([]);
|
||||
setLastUploadFailures([]);
|
||||
@@ -528,15 +531,6 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
setUploadStats(INITIAL_UPLOAD_STATS);
|
||||
|
||||
try {
|
||||
// 读取所有文件(包括文件夹内容)
|
||||
const dropSnapshot = snapshotDropPayload(e.dataTransfer);
|
||||
const allFiles = await collectDroppedFiles(dropSnapshot);
|
||||
|
||||
if (allFiles.length === 0) {
|
||||
push(t("没有可上传的文件", "No files to upload"));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = calculateTotalBytes(allFiles);
|
||||
setUploadStats({ current: 0, total: allFiles.length, uploadedBytes: 0, totalBytes });
|
||||
|
||||
@@ -637,6 +631,34 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
}
|
||||
}, [sessionId, selectedPath, compress, push, loadDirectory, uploadFilesByBatch, t, language]);
|
||||
|
||||
// 处理文件拖拽上传
|
||||
const handleDrop = React.useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
try {
|
||||
// 读取所有文件(包括文件夹内容)
|
||||
const dropSnapshot = snapshotDropPayload(e.dataTransfer);
|
||||
const allFiles = await collectDroppedFiles(dropSnapshot);
|
||||
await handleUploadFiles(allFiles);
|
||||
} catch (err) {
|
||||
push(err instanceof Error ? err.message : t("上传失败", "Upload failed"));
|
||||
}
|
||||
}, [handleUploadFiles, push, t]);
|
||||
|
||||
const handleFileInputChange = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const filesList = event.target.files;
|
||||
if (!filesList || filesList.length === 0) {
|
||||
return;
|
||||
}
|
||||
const allFiles = Array.from(filesList).map((file) => ({
|
||||
file,
|
||||
path: file.webkitRelativePath || file.name,
|
||||
}));
|
||||
await handleUploadFiles(allFiles);
|
||||
event.target.value = "";
|
||||
}, [handleUploadFiles]);
|
||||
|
||||
const handleCancelUpload = React.useCallback(() => {
|
||||
uploadCancelledRef.current = true;
|
||||
}, []);
|
||||
@@ -776,45 +798,51 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
}
|
||||
}, [sessionId, push, t]);
|
||||
|
||||
const handleContextDownload = React.useCallback(async () => {
|
||||
if (!contextMenu) return;
|
||||
setContextMenu(null);
|
||||
const getSelectedFiles = React.useCallback(() => {
|
||||
if (selectedFiles.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(selectedFiles)
|
||||
.map(path => files.find(f => f.path === path))
|
||||
.filter(Boolean) as FileInfo[];
|
||||
}, [files, selectedFiles]);
|
||||
|
||||
const filesToDownload =
|
||||
selectedFiles.size > 0
|
||||
? (Array.from(selectedFiles).map(path => files.find(f => f.path === path)).filter(Boolean) as FileInfo[])
|
||||
: contextMenu.files;
|
||||
if (filesToDownload.length === 0) return;
|
||||
await downloadItemsWithProgress(filesToDownload);
|
||||
}, [contextMenu, selectedFiles, files, downloadItemsWithProgress]);
|
||||
const handleActionForFiles = React.useCallback((
|
||||
action: "mkdir" | "touch" | "rename" | "chmod" | "delete" | "download",
|
||||
filesForAction: FileInfo[]
|
||||
) => {
|
||||
if (action === "delete") {
|
||||
if (filesForAction.length === 0) return;
|
||||
if (confirm(t(`确定删除 ${filesForAction.length} 个项目?`, `Delete ${filesForAction.length} item(s)?`))) {
|
||||
Promise.all(filesForAction.map(file => deletePath(sessionId, file.path))).then(() => {
|
||||
push(t("删除成功", "Deleted successfully"));
|
||||
setSelectedFiles(new Set());
|
||||
loadDirectory(selectedPathRef.current, false, { forceRefresh: true, preferCache: false });
|
||||
}).catch(() => push(t("删除失败", "Delete failed")));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "download") {
|
||||
if (filesForAction.length === 0) return;
|
||||
void downloadItemsWithProgress(filesForAction);
|
||||
return;
|
||||
}
|
||||
|
||||
setDialog({ type: action, files: filesForAction });
|
||||
if (action === "rename") {
|
||||
setDialogInput(filesForAction[0]?.name || "");
|
||||
} else if (action === "chmod") {
|
||||
setDialogInput(permissionsToOctal(filesForAction[0]?.permissions || ""));
|
||||
} else {
|
||||
setDialogInput("");
|
||||
}
|
||||
}, [sessionId, push, t, downloadItemsWithProgress, loadDirectory]);
|
||||
|
||||
const handleContextAction = React.useCallback((action: "mkdir" | "touch" | "rename" | "chmod" | "delete" | "download") => {
|
||||
if (!contextMenu) return;
|
||||
const { files } = contextMenu;
|
||||
setContextMenu(null);
|
||||
|
||||
if (action === "delete") {
|
||||
if (files.length === 0) return;
|
||||
if (confirm(t(`确定删除 ${files.length} 个项目?`, `Delete ${files.length} item(s)?`))) {
|
||||
Promise.all(files.map(file => deletePath(sessionId, file.path))).then(() => {
|
||||
push(t("删除成功", "Deleted successfully"));
|
||||
setSelectedFiles(new Set());
|
||||
loadDirectory(selectedPath, false, { forceRefresh: true, preferCache: false });
|
||||
}).catch(() => push(t("删除失败", "Delete failed")));
|
||||
}
|
||||
} else if (action === "download") {
|
||||
handleContextDownload();
|
||||
} else {
|
||||
setDialog({ type: action, files });
|
||||
if (action === "rename") {
|
||||
setDialogInput(files[0]?.name || "");
|
||||
} else if (action === "chmod") {
|
||||
setDialogInput(permissionsToOctal(files[0]?.permissions || ""));
|
||||
} else {
|
||||
setDialogInput("");
|
||||
}
|
||||
}
|
||||
}, [contextMenu, sessionId, push, loadDirectory, selectedPath, t]);
|
||||
handleActionForFiles(action, contextMenu.files);
|
||||
}, [contextMenu, handleActionForFiles]);
|
||||
|
||||
const handleDialogSubmit = React.useCallback(async () => {
|
||||
if (!dialog || !dialogInput.trim()) return;
|
||||
@@ -982,6 +1010,12 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
onFileSelect?.(null);
|
||||
}, [onFileSelect]);
|
||||
|
||||
const handleClearSelection = React.useCallback(() => {
|
||||
setSelectedFile(null);
|
||||
setSelectedFiles(new Set());
|
||||
onFileSelect?.(null);
|
||||
}, [onFileSelect]);
|
||||
|
||||
const loadDirectoryRef = React.useRef(loadDirectory);
|
||||
const toggleNodeRef = React.useRef(toggleNode);
|
||||
|
||||
@@ -1018,7 +1052,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`flex items-center gap-1 py-0.5 px-1 rounded cursor-pointer text-xs truncate ${
|
||||
className={`flex items-center gap-1 ${isCompact ? "py-1 text-sm" : "py-0.5 text-xs"} px-1 rounded cursor-pointer truncate ${
|
||||
isSelected
|
||||
? (isDark ? "bg-indigo-600/50 text-white" : "bg-indigo-100 text-indigo-700")
|
||||
: (isDark ? "hover:bg-slate-700 text-slate-300" : "hover:bg-slate-100 text-slate-600")
|
||||
@@ -1072,9 +1106,17 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
downloadStats && downloadStats.totalBytes > 0
|
||||
? calculatePercent(downloadStats.loadedBytes, downloadStats.totalBytes)
|
||||
: null;
|
||||
const selectedItems = getSelectedFiles();
|
||||
const selectedCount = selectedItems.length;
|
||||
const canRename = selectedCount === 1;
|
||||
const hasSelection = selectedCount > 0;
|
||||
|
||||
const rowPaddingClass = isCompact ? "py-2" : "py-1";
|
||||
const rowTextClass = isCompact ? "text-sm" : "text-xs";
|
||||
const tableColSpan = isCompact ? 2 : 5;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 h-full flex flex-col">
|
||||
<div className={`space-y-2 h-full flex flex-col ${isCompact ? "text-sm" : ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`text-sm font-semibold ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{t("文件浏览", "File Browser")}
|
||||
@@ -1082,48 +1124,127 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
</div>
|
||||
|
||||
{/* 路径输入框 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex ${isCompact ? "flex-col items-stretch gap-2" : "items-center gap-2"}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathSubmit}
|
||||
placeholder={t("输入路径,按回车跳转", "Input path and press Enter")}
|
||||
className={`flex-1 px-2 py-1 text-xs rounded border ${
|
||||
className={`flex-1 px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border ${
|
||||
isDark
|
||||
? "bg-slate-800 border-slate-600 text-slate-200 placeholder:text-slate-500 focus:border-indigo-500"
|
||||
: "bg-white border-slate-300 text-slate-700 placeholder:text-slate-400 focus:border-indigo-400"
|
||||
} focus:outline-none`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className={`px-2 py-1 text-xs rounded border ${
|
||||
<div className={`flex items-center gap-2 ${isCompact ? "flex-wrap" : ""}`}>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className={`px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border ${
|
||||
isDark
|
||||
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
|
||||
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
|
||||
} ${refreshing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
title={t("刷新", "Refresh")}
|
||||
>
|
||||
{refreshing ? <span className="animate-spin inline-block">⟳</span> : "↻"}
|
||||
</button>
|
||||
<label className={`flex items-center gap-1 px-2 ${isCompact ? "py-2 text-sm" : "py-1 text-xs"} rounded border cursor-pointer ${
|
||||
isDark
|
||||
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
|
||||
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
|
||||
} ${refreshing ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
title={t("刷新", "Refresh")}
|
||||
>
|
||||
{refreshing ? <span className="animate-spin inline-block">⟳</span> : "↻"}
|
||||
</button>
|
||||
<label className={`flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer ${
|
||||
isDark
|
||||
? "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600"
|
||||
: "bg-slate-100 border-slate-300 text-slate-600 hover:bg-slate-200"
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compress}
|
||||
onChange={(e) => setCompress(e.target.checked)}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
<span>{t("压缩上传", "Compressed upload")}</span>
|
||||
</label>
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compress}
|
||||
onChange={(e) => setCompress(e.target.checked)}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
<span>{t("压缩上传", "Compressed upload")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCompact ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`rounded-full px-2 py-1 ${isDark ? "bg-slate-800 text-slate-300" : "bg-slate-200 text-slate-600"}`}>
|
||||
{t("已选", "Selected")} {selectedCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActionForFiles("mkdir", [])}
|
||||
className={`rounded-full px-3 py-2 ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
|
||||
>
|
||||
{t("新建文件夹", "New folder")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className={`rounded-full px-3 py-2 ${uploading ? "opacity-50 cursor-not-allowed" : ""} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
|
||||
>
|
||||
{t("上传文件", "Upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActionForFiles("touch", [])}
|
||||
className={`rounded-full px-3 py-2 ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
|
||||
>
|
||||
{t("新建文件", "New file")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActionForFiles("rename", selectedItems)}
|
||||
disabled={!canRename}
|
||||
className={`rounded-full px-3 py-2 ${canRename ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
|
||||
>
|
||||
{t("重命名", "Rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActionForFiles("chmod", selectedItems)}
|
||||
disabled={!hasSelection}
|
||||
className={`rounded-full px-3 py-2 ${hasSelection ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
|
||||
>
|
||||
{t("权限", "Permissions")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActionForFiles("download", selectedItems)}
|
||||
disabled={!hasSelection}
|
||||
className={`rounded-full px-3 py-2 ${hasSelection ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
|
||||
>
|
||||
{t("下载", "Download")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleActionForFiles("delete", selectedItems)}
|
||||
disabled={!hasSelection}
|
||||
className={`rounded-full px-3 py-2 ${hasSelection ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-rose-900/60 text-rose-200" : "bg-rose-50 text-rose-600 border border-rose-200"}`}
|
||||
>
|
||||
{t("删除", "Delete")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSelection}
|
||||
disabled={!hasSelection}
|
||||
className={`rounded-full px-3 py-2 ${hasSelection ? "" : "opacity-40 cursor-not-allowed"} ${isDark ? "bg-slate-800 text-slate-200" : "bg-white text-slate-700 border border-slate-200"}`}
|
||||
>
|
||||
{t("取消选择", "Clear")}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`flex rounded border flex-1 min-h-0 ${isDark ? "border-slate-700" : "border-slate-200"} ${(uploading || downloading) ? "opacity-50" : ""} relative`}
|
||||
className={`flex rounded border flex-1 min-h-0 ${isCompact ? "flex-col" : ""} ${isDark ? "border-slate-700" : "border-slate-200"} ${(uploading || downloading) ? "opacity-50" : ""} relative`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1251,7 +1372,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
</div>
|
||||
)}
|
||||
{/* 左侧树形目录 */}
|
||||
<div className={`w-1/3 overflow-auto border-r ${isDark ? "border-slate-700 bg-slate-800/50 dark-scrollbar" : "border-slate-200 bg-slate-50 light-scrollbar"}`}>
|
||||
<div className={`${isCompact ? "w-full max-h-40 border-b" : "w-1/3 border-r"} overflow-auto ${isDark ? "border-slate-700 bg-slate-800/50 dark-scrollbar" : "border-slate-200 bg-slate-50 light-scrollbar"}`}>
|
||||
<div className="p-1">
|
||||
{tree.map(node => renderTreeNode(node))}
|
||||
</div>
|
||||
@@ -1276,23 +1397,29 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<table className={`w-full ${rowTextClass}`}>
|
||||
<thead className={`sticky top-0 ${isDark ? "bg-slate-800" : "bg-slate-100"}`}>
|
||||
<tr className={isDark ? "text-slate-400" : "text-slate-500"}>
|
||||
<th className="text-left py-1 px-2 font-medium">{t("名称", "Name")}</th>
|
||||
<th className="text-right py-1 px-2 font-medium relative" style={{ width: colWidths.size }}>
|
||||
<th className={`text-left ${rowPaddingClass} px-2 font-medium`}>{t("名称", "Name")}</th>
|
||||
<th className={`text-right ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.size }}>
|
||||
{t("大小", "Size")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("size")} />
|
||||
{!isCompact ? (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("size")} />
|
||||
) : null}
|
||||
</th>
|
||||
<th className="text-left py-1 px-2 font-medium relative" style={{ width: colWidths.permissions }}>
|
||||
{t("权限", "Permissions")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("permissions")} />
|
||||
</th>
|
||||
<th className="text-left py-1 px-2 font-medium relative" style={{ width: colWidths.owner }}>
|
||||
{t("用户", "Owner")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("owner")} />
|
||||
</th>
|
||||
<th className="text-left py-1 px-2 font-medium" style={{ width: colWidths.modified }}>{t("修改时间", "Modified Time")}</th>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<th className={`text-left ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.permissions }}>
|
||||
{t("权限", "Permissions")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("permissions")} />
|
||||
</th>
|
||||
<th className={`text-left ${rowPaddingClass} px-2 font-medium relative`} style={{ width: colWidths.owner }}>
|
||||
{t("用户", "Owner")}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500" onMouseDown={() => setResizingCol("owner")} />
|
||||
</th>
|
||||
<th className={`text-left ${rowPaddingClass} px-2 font-medium`} style={{ width: colWidths.modified }}>{t("修改时间", "Modified Time")}</th>
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1303,14 +1430,18 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
onClick={handleParentItemClick}
|
||||
onDoubleClick={handleNavigateToParent}
|
||||
>
|
||||
<td className="py-1 px-2 truncate max-w-[120px]" title={t(`返回上一级: ${parentPath}`, `Go to parent: ${parentPath}`)}>
|
||||
<td className={`${rowPaddingClass} px-2 truncate max-w-[120px]`} title={t(`返回上一级: ${parentPath}`, `Go to parent: ${parentPath}`)}>
|
||||
<span className="mr-1">📁</span>
|
||||
...
|
||||
</td>
|
||||
<td className={`py-1 px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`py-1 px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`py-1 px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`py-1 px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>{t("返回上一级", "Go to parent")}</td>
|
||||
<td className={`${rowPaddingClass} px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<td className={`${rowPaddingClass} px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`${rowPaddingClass} px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`}>-</td>
|
||||
<td className={`${rowPaddingClass} px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>{t("返回上一级", "Go to parent")}</td>
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
@@ -1327,27 +1458,31 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
handleContextMenu(e, file);
|
||||
}}
|
||||
>
|
||||
<td className="py-1 px-2 truncate max-w-[120px]" title={file.name}>
|
||||
<td className={`${rowPaddingClass} px-2 truncate max-w-[120px]`} title={file.name}>
|
||||
<span className="mr-1">{file.is_dir ? "📁" : "📄"}</span>
|
||||
{file.name}
|
||||
</td>
|
||||
<td className={`py-1 px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
<td className={`${rowPaddingClass} px-2 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.is_dir ? "-" : formatFileSize(file.size)}
|
||||
</td>
|
||||
<td className={`py-1 px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.permissions}
|
||||
</td>
|
||||
<td className={`py-1 px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`} title={`${file.owner}:${file.group}`}>
|
||||
{file.owner}
|
||||
</td>
|
||||
<td className={`py-1 px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.modified}
|
||||
</td>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<td className={`${rowPaddingClass} px-2 font-mono ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.permissions}
|
||||
</td>
|
||||
<td className={`${rowPaddingClass} px-2 truncate ${isDark ? "text-slate-400" : "text-slate-500"}`} title={`${file.owner}:${file.group}`}>
|
||||
{file.owner}
|
||||
</td>
|
||||
<td className={`${rowPaddingClass} px-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{file.modified}
|
||||
</td>
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
{files.length === 0 && !filesLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className={`py-4 text-center ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
<td colSpan={tableColSpan} className={`py-4 text-center ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{t("空目录", "Empty directory")}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1475,4 +1610,3 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { getSystemOverview, readFile, writeFile, SystemStats, NetworkInfo, ProcessInfo, DiskInfo } from "../lib/api";
|
||||
import { downloadFile, getSystemOverview, readFile, writeFile, SystemStats, NetworkInfo, ProcessInfo, DiskInfo } from "../lib/api";
|
||||
import { useApp } from "../context/AppContext";
|
||||
import type { NetworkProfile } from "../context/AppContext";
|
||||
import { useToast } from "./Toast";
|
||||
@@ -27,6 +27,16 @@ function formatSpeed(bytesPerSec: number): string {
|
||||
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "tif", "tiff"]);
|
||||
|
||||
function isImagePath(path: string): boolean {
|
||||
const normalized = path.split("?")[0].split("#")[0];
|
||||
const lastDot = normalized.lastIndexOf(".");
|
||||
if (lastDot === -1) return false;
|
||||
const ext = normalized.slice(lastDot + 1).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
type ProgressBarProps = {
|
||||
percent: number;
|
||||
isDark: boolean;
|
||||
@@ -65,18 +75,28 @@ type SystemMonitorProps = {
|
||||
isDark: boolean;
|
||||
selectedFilePath?: string;
|
||||
networkProfile?: NetworkProfile;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProfile }: SystemMonitorProps) {
|
||||
export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProfile, compact }: SystemMonitorProps) {
|
||||
const { networkProfile: globalNetworkProfile, language } = useApp();
|
||||
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
|
||||
const effectiveNetworkProfile = networkProfile ?? globalNetworkProfile;
|
||||
const isCompact = compact === true;
|
||||
const [collapsedSections, setCollapsedSections] = React.useState({
|
||||
status: false,
|
||||
processes: isCompact,
|
||||
disks: isCompact,
|
||||
file: false,
|
||||
});
|
||||
const [stats, setStats] = React.useState<SystemStats | null>(null);
|
||||
const [network, setNetwork] = React.useState<NetworkInfo | null>(null);
|
||||
const [processes, setProcesses] = React.useState<ProcessInfo[]>([]);
|
||||
const [disks, setDisks] = React.useState<DiskInfo[]>([]);
|
||||
const [fileContent, setFileContent] = React.useState<string>("");
|
||||
const [originalContent, setOriginalContent] = React.useState<string>("");
|
||||
const [imageUrl, setImageUrl] = React.useState<string | null>(null);
|
||||
const [imageError, setImageError] = React.useState<string | null>(null);
|
||||
const [fileLoading, setFileLoading] = React.useState(false);
|
||||
const [fileTooLarge, setFileTooLarge] = React.useState(false);
|
||||
const [fileSize, setFileSize] = React.useState(0);
|
||||
@@ -108,15 +128,83 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
|
||||
}
|
||||
}, [sessionId, t]);
|
||||
|
||||
// 读取文件内容
|
||||
const isImageFile = React.useMemo(() => {
|
||||
return selectedFilePath ? isImagePath(selectedFilePath) : false;
|
||||
}, [selectedFilePath]);
|
||||
|
||||
const revokeImageUrl = React.useCallback((url: string | null) => {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 读取文件内容或加载图片
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!selectedFilePath) {
|
||||
setFileContent("");
|
||||
setOriginalContent("");
|
||||
setFileTooLarge(false);
|
||||
return;
|
||||
setImageError(null);
|
||||
setFileSize(0);
|
||||
setImageUrl((prev) => {
|
||||
revokeImageUrl(prev);
|
||||
return null;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (isImageFile) {
|
||||
setFileLoading(true);
|
||||
setFileTooLarge(false);
|
||||
setFileContent("");
|
||||
setOriginalContent("");
|
||||
setImageError(null);
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
const blob = await downloadFile(sessionId, selectedFilePath);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
setImageUrl((prev) => {
|
||||
revokeImageUrl(prev);
|
||||
return url;
|
||||
});
|
||||
setFileSize(blob.size);
|
||||
} catch (err) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setImageError(err instanceof Error ? err.message : t("图片加载失败", "Failed to load image"));
|
||||
setImageUrl((prev) => {
|
||||
revokeImageUrl(prev);
|
||||
return null;
|
||||
});
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFileLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadImage();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setImageError(null);
|
||||
setImageUrl((prev) => {
|
||||
revokeImageUrl(prev);
|
||||
return null;
|
||||
});
|
||||
|
||||
const loadFile = async (force = false) => {
|
||||
setFileLoading(true);
|
||||
try {
|
||||
@@ -136,15 +224,28 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
|
||||
setFileContent("");
|
||||
setOriginalContent("");
|
||||
} finally {
|
||||
setFileLoading(false);
|
||||
if (!cancelled) {
|
||||
setFileLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadFile();
|
||||
}, [sessionId, selectedFilePath, push, t]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId, selectedFilePath, isImageFile, push, t, revokeImageUrl, downloadFile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
revokeImageUrl(imageUrl);
|
||||
};
|
||||
}, [imageUrl, revokeImageUrl]);
|
||||
|
||||
const handleLoadLargeFile = React.useCallback(async () => {
|
||||
if (!selectedFilePath) return;
|
||||
if (isImageFile) return;
|
||||
setFileLoading(true);
|
||||
try {
|
||||
const result = await readFile(sessionId, selectedFilePath, true);
|
||||
@@ -156,7 +257,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
|
||||
} finally {
|
||||
setFileLoading(false);
|
||||
}
|
||||
}, [sessionId, selectedFilePath, push, t]);
|
||||
}, [sessionId, selectedFilePath, push, t, isImageFile]);
|
||||
|
||||
// 保存文件
|
||||
const handleSaveFile = React.useCallback(async () => {
|
||||
@@ -208,8 +309,37 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
|
||||
const bVal = sortField === "cpu" ? b.cpu_percent : b.memory_percent;
|
||||
return sortOrder === "desc" ? bVal - aVal : aVal - bVal;
|
||||
});
|
||||
return sorted.slice(0, 5);
|
||||
}, [processes, sortField, sortOrder]);
|
||||
const maxCount = isCompact ? 3 : 5;
|
||||
return sorted.slice(0, maxCount);
|
||||
}, [processes, sortField, sortOrder, isCompact]);
|
||||
|
||||
const toggleSection = React.useCallback((key: keyof typeof collapsedSections) => {
|
||||
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const renderSectionHeader = (key: keyof typeof collapsedSections, title: string) => {
|
||||
if (!isCompact) {
|
||||
return (
|
||||
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{title}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(key)}
|
||||
className={`w-full flex items-center justify-between text-sm font-semibold mb-2 ${
|
||||
isDark ? "text-slate-200" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{collapsedSections[key] ? t("展开", "Expand") : t("收起", "Collapse")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) return <span className="opacity-30">↕</span>;
|
||||
@@ -235,191 +365,240 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={isCompact ? "space-y-3" : "space-y-4"}>
|
||||
<div>
|
||||
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{t("系统状态", "System Status")}
|
||||
</h3>
|
||||
{renderSectionHeader("status", t("系统状态", "System Status"))}
|
||||
|
||||
{/* CPU */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{t(`CPU (${stats.cpu.count} 核)`, `CPU (${stats.cpu.count} Cores)`)}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{stats.cpu.percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={stats.cpu.percent} isDark={isDark} color="blue" />
|
||||
</div>
|
||||
|
||||
{/* 内存 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{t("内存", "Memory")}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{stats.memory.percent.toFixed(1)}% ({formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)})
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={stats.memory.percent} isDark={isDark} color="green" />
|
||||
</div>
|
||||
|
||||
{/* 交换区 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{t("交换区", "Swap")}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{stats.swap.total > 0
|
||||
? `${stats.swap.percent.toFixed(1)}% (${formatBytes(stats.swap.used)} / ${formatBytes(stats.swap.total)})`
|
||||
: t("未启用", "Disabled")
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{stats.swap.total > 0 && (
|
||||
<ProgressBar percent={stats.swap.percent} isDark={isDark} color="yellow" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 网络速度 */}
|
||||
<div className={`pt-3 mt-3 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{t("网络", "Network")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium ${isDark ? "text-purple-400" : "text-purple-600"}`}>
|
||||
↑ {network ? formatSpeed(network.upload_speed) : "-"}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-cyan-400" : "text-cyan-600"}`}>
|
||||
↓ {network ? formatSpeed(network.download_speed) : "-"}
|
||||
</span>
|
||||
{!isCompact || !collapsedSections.status ? (
|
||||
<>
|
||||
{/* CPU */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{t(`CPU (${stats.cpu.count} 核)`, `CPU (${stats.cpu.count} Cores)`)}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{stats.cpu.percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={stats.cpu.percent} isDark={isDark} color="blue" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内存 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{t("内存", "Memory")}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{stats.memory.percent.toFixed(1)}% ({formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)})
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={stats.memory.percent} isDark={isDark} color="green" />
|
||||
</div>
|
||||
|
||||
{/* 交换区 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{t("交换区", "Swap")}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{stats.swap.total > 0
|
||||
? `${stats.swap.percent.toFixed(1)}% (${formatBytes(stats.swap.used)} / ${formatBytes(stats.swap.total)})`
|
||||
: t("未启用", "Disabled")
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{stats.swap.total > 0 && (
|
||||
<ProgressBar percent={stats.swap.percent} isDark={isDark} color="yellow" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 网络速度 */}
|
||||
<div className={`pt-3 mt-3 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{t("网络", "Network")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium ${isDark ? "text-purple-400" : "text-purple-600"}`}>
|
||||
↑ {network ? formatSpeed(network.upload_speed) : "-"}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-cyan-400" : "text-cyan-600"}`}>
|
||||
↓ {network ? formatSpeed(network.download_speed) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 进程列表 */}
|
||||
<div className={`pt-4 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
|
||||
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{t("进程 TOP 5", "Top 5 Processes")}
|
||||
</h3>
|
||||
{renderSectionHeader("processes", t("进程 TOP 5", "Top 5 Processes"))}
|
||||
|
||||
{/* 表头 */}
|
||||
<div className={`flex text-xs mb-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
<div className="flex-1">{t("进程名", "Process")}</div>
|
||||
<div
|
||||
className="w-16 text-right cursor-pointer hover:text-indigo-400 select-none"
|
||||
onClick={() => handleSort("cpu")}
|
||||
>
|
||||
CPU <SortIcon field="cpu" />
|
||||
</div>
|
||||
<div
|
||||
className="w-20 text-right cursor-pointer hover:text-indigo-400 select-none"
|
||||
onClick={() => handleSort("memory")}
|
||||
>
|
||||
{t("内存", "Memory")} <SortIcon field="memory" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进程表 */}
|
||||
<div className="space-y-1">
|
||||
{sortedProcesses.map((proc) => (
|
||||
<div
|
||||
key={proc.pid}
|
||||
className={`flex text-xs py-1 rounded px-1 ${isDark ? "hover:bg-slate-800" : "hover:bg-slate-100"} group relative`}
|
||||
title={proc.command}
|
||||
>
|
||||
<div className={`flex-1 truncate ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{proc.name}
|
||||
{!isCompact || !collapsedSections.processes ? (
|
||||
<>
|
||||
{/* 表头 */}
|
||||
<div className={`flex text-xs mb-2 ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
<div className="flex-1">{t("进程名", "Process")}</div>
|
||||
<div
|
||||
className="w-16 text-right cursor-pointer hover:text-indigo-400 select-none"
|
||||
onClick={() => handleSort("cpu")}
|
||||
>
|
||||
CPU <SortIcon field="cpu" />
|
||||
</div>
|
||||
<div className={`w-16 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{proc.cpu_percent.toFixed(1)}%
|
||||
</div>
|
||||
<div className={`w-20 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{formatBytes(proc.memory_bytes)}
|
||||
<div
|
||||
className="w-20 text-right cursor-pointer hover:text-indigo-400 select-none"
|
||||
onClick={() => handleSort("memory")}
|
||||
>
|
||||
{t("内存", "Memory")} <SortIcon field="memory" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sortedProcesses.length === 0 && (
|
||||
<div className={`text-xs ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{t("暂无数据", "No data")}
|
||||
|
||||
{/* 进程表 */}
|
||||
<div className="space-y-1">
|
||||
{sortedProcesses.map((proc) => (
|
||||
<div
|
||||
key={proc.pid}
|
||||
className={`flex text-xs py-1 rounded px-1 ${isDark ? "hover:bg-slate-800" : "hover:bg-slate-100"} group relative`}
|
||||
title={proc.command}
|
||||
>
|
||||
<div className={`flex-1 truncate ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{proc.name}
|
||||
</div>
|
||||
<div className={`w-16 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{proc.cpu_percent.toFixed(1)}%
|
||||
</div>
|
||||
<div className={`w-20 text-right ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{formatBytes(proc.memory_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sortedProcesses.length === 0 && (
|
||||
<div className={`text-xs ${isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{t("暂无数据", "No data")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 磁盘信息 */}
|
||||
<div className={`pt-4 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
|
||||
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{t("磁盘挂载", "Disk Mounts")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{disks.map((disk) => (
|
||||
<div key={disk.mount}>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{disk.mount}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{disk.percent.toFixed(1)}% ({formatBytes(disk.used)} / {formatBytes(disk.total)})
|
||||
</span>
|
||||
{renderSectionHeader("disks", t("磁盘挂载", "Disk Mounts"))}
|
||||
{!isCompact || !collapsedSections.disks ? (
|
||||
<div className="space-y-2">
|
||||
{disks.map((disk) => (
|
||||
<div key={disk.mount}>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className={`text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{disk.mount}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isDark ? "text-slate-300" : "text-slate-600"}`}>
|
||||
{disk.percent.toFixed(1)}% ({formatBytes(disk.used)} / {formatBytes(disk.total)})
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={disk.percent} isDark={isDark} color="cyan" />
|
||||
</div>
|
||||
<ProgressBar percent={disk.percent} isDark={isDark} color="cyan" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 文件预览 */}
|
||||
{selectedFilePath && (
|
||||
<div className={`pt-4 ${isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className={`text-sm font-semibold ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{t("文件预览", "File Preview")}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleSaveFile}
|
||||
disabled={fileContent === originalContent || fileTooLarge}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
fileContent === originalContent || fileTooLarge
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
|
||||
} text-white`}
|
||||
>
|
||||
{t("保存", "Save")}
|
||||
</button>
|
||||
</div>
|
||||
{fileLoading ? (
|
||||
<div className={`flex items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
|
||||
<span className="animate-spin mr-2">⟳</span> {t("加载中...", "Loading...")}
|
||||
</div>
|
||||
) : fileTooLarge ? (
|
||||
<div className={`flex flex-col items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
|
||||
<p className="mb-2">{t(`文件过大 (${(fileSize / 1024 / 1024).toFixed(2)} MB)`, `File too large (${(fileSize / 1024 / 1024).toFixed(2)} MB)`)}</p>
|
||||
<button
|
||||
onClick={handleLoadLargeFile}
|
||||
className={`px-3 py-1 rounded ${isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400"} text-white`}
|
||||
>
|
||||
{t("仍然加载", "Load anyway")}
|
||||
</button>
|
||||
</div>
|
||||
{isCompact ? (
|
||||
renderSectionHeader("file", isImageFile ? t("图片预览", "Image Preview") : t("文件预览", "File Preview"))
|
||||
) : (
|
||||
<textarea
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
className={`w-full text-xs font-mono p-2 rounded border ${
|
||||
isDark
|
||||
? "bg-slate-900 border-slate-700 text-slate-200"
|
||||
: "bg-white border-slate-300 text-slate-700"
|
||||
}`}
|
||||
style={{ height: "172px" }}
|
||||
/>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className={`text-sm font-semibold ${isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{isImageFile ? t("图片预览", "Image Preview") : t("文件预览", "File Preview")}
|
||||
</h3>
|
||||
{!isImageFile ? (
|
||||
<button
|
||||
onClick={handleSaveFile}
|
||||
disabled={fileContent === originalContent || fileTooLarge}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
fileContent === originalContent || fileTooLarge
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
|
||||
} text-white`}
|
||||
>
|
||||
{t("保存", "Save")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{!isCompact || !collapsedSections.file ? (
|
||||
<>
|
||||
{isCompact ? (
|
||||
<div className="flex justify-end mb-2">
|
||||
{!isImageFile ? (
|
||||
<button
|
||||
onClick={handleSaveFile}
|
||||
disabled={fileContent === originalContent || fileTooLarge}
|
||||
className={`px-3 py-2 text-sm rounded ${
|
||||
fileContent === originalContent || fileTooLarge
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: (isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400")
|
||||
} text-white`}
|
||||
>
|
||||
{t("保存", "Save")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{fileLoading ? (
|
||||
<div className={`flex items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
|
||||
<span className="animate-spin mr-2">⟳</span> {t("加载中...", "Loading...")}
|
||||
</div>
|
||||
) : isImageFile ? (
|
||||
imageError ? (
|
||||
<div className={`flex items-center justify-center text-xs ${isDark ? "text-rose-400" : "text-rose-500"}`} style={{ height: "172px" }}>
|
||||
{imageError}
|
||||
</div>
|
||||
) : imageUrl ? (
|
||||
<div className={`flex items-center justify-center rounded border ${isDark ? "border-slate-700 bg-slate-900/40" : "border-slate-200 bg-slate-50"}`} style={{ height: "172px" }}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={selectedFilePath}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
|
||||
{t("暂无图片内容", "No image preview")}
|
||||
</div>
|
||||
)
|
||||
) : fileTooLarge ? (
|
||||
<div className={`flex flex-col items-center justify-center text-xs ${isDark ? "text-slate-400" : "text-slate-500"}`} style={{ height: "172px" }}>
|
||||
<p className="mb-2">{t(`文件过大 (${(fileSize / 1024 / 1024).toFixed(2)} MB)`, `File too large (${(fileSize / 1024 / 1024).toFixed(2)} MB)`)}</p>
|
||||
<button
|
||||
onClick={handleLoadLargeFile}
|
||||
className={`px-3 py-1 rounded ${isDark ? "bg-indigo-600 hover:bg-indigo-500" : "bg-indigo-500 hover:bg-indigo-400"} text-white`}
|
||||
>
|
||||
{t("仍然加载", "Load anyway")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
className={`w-full text-xs font-mono p-2 rounded border ${
|
||||
isDark
|
||||
? "bg-slate-900 border-slate-700 text-slate-200"
|
||||
: "bg-white border-slate-300 text-slate-700"
|
||||
}`}
|
||||
style={{ height: "172px" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ const CLIENT_TEXT_MAP: Record<string, { zh: string; en: string }> = {
|
||||
"重试连接失败": { zh: "重试连接失败", en: "Failed to retry connection" },
|
||||
"断开失败": { zh: "断开失败", en: "Failed to disconnect" },
|
||||
"保存备注失败": { zh: "保存备注失败", en: "Failed to save note" },
|
||||
"保存排序失败": { zh: "保存排序失败", en: "Failed to save order" },
|
||||
"删除会话失败": { zh: "删除会话失败", en: "Failed to delete session" },
|
||||
"删除连接失败": { zh: "删除连接失败", en: "Failed to delete connection" },
|
||||
"更新连接失败": { zh: "更新连接失败", en: "Failed to update connection" },
|
||||
@@ -163,6 +164,7 @@ export type Session = {
|
||||
username: string;
|
||||
name: string;
|
||||
note?: string | null;
|
||||
session_order?: number;
|
||||
enhanced_enabled?: boolean;
|
||||
remote_arch?: string | null;
|
||||
remote_os?: string | null;
|
||||
@@ -413,6 +415,18 @@ export async function updateSessionNote(sessionId: string, note: string | null):
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function updateSessionOrder(orderedIds: string[]): Promise<void> {
|
||||
const response = await fetch(`${HTTP_BASE}/sessions/order`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeader() },
|
||||
body: JSON.stringify({ ordered_ids: orderedIds })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await safeError(response, "保存排序失败");
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const response = await fetch(`${HTTP_BASE}/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
const DEFAULT_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile(breakpoint: number = DEFAULT_BREAKPOINT): boolean {
|
||||
const getMatch = React.useCallback(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches;
|
||||
}, [breakpoint]);
|
||||
|
||||
const [isMobile, setIsMobile] = React.useState(getMatch);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const media = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
||||
const handleChange = () => setIsMobile(media.matches);
|
||||
handleChange();
|
||||
media.addEventListener("change", handleChange);
|
||||
return () => media.removeEventListener("change", handleChange);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
+8
-1064
File diff suppressed because it is too large.
Load diff
+11
-1021
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,236 @@
|
||||
import React from "react";
|
||||
import { Button } from "../../components/Button";
|
||||
import { Card } from "../../components/Card";
|
||||
import { Input } from "../../components/Input";
|
||||
import type { SessionsState } from "./useSessionsState";
|
||||
|
||||
type SessionsConnectionsPanelProps = {
|
||||
state: SessionsState;
|
||||
};
|
||||
|
||||
export function SessionsConnectionsPanel({ state }: SessionsConnectionsPanelProps) {
|
||||
return (
|
||||
<Card
|
||||
title={state.t("新增 SSH 连接", "New SSH Connection")}
|
||||
description={state.t("保存连接信息并发起会话", "Save connection details and start sessions")}
|
||||
className={!state.isDark ? "bg-white border-slate-200 shadow-sm" : ""}
|
||||
titleClassName={!state.isDark ? "text-slate-900" : ""}
|
||||
descClassName={!state.isDark ? "text-slate-500" : ""}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant={state.showCreateForm ? "secondary" : "primary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setShowCreateForm((prev) => !prev)}
|
||||
className="w-full"
|
||||
>
|
||||
{state.showCreateForm ? state.t("收起表单", "Hide form") : state.t("新增连接", "New connection")}
|
||||
</Button>
|
||||
{state.showCreateForm ? (
|
||||
<form className="space-y-4" onSubmit={state.handleCreateConnection}>
|
||||
<div
|
||||
className={`rounded-md border px-3 py-2 text-sm ${
|
||||
state.isDark
|
||||
? "border-amber-500/40 bg-amber-500/10 text-amber-100"
|
||||
: "border-amber-200 bg-amber-50 text-amber-800"
|
||||
}`}
|
||||
>
|
||||
{state.t(
|
||||
"请确保该项目所运行的机器可以访问到目标连接。",
|
||||
"Make sure the machine running this project can reach the target connection."
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
placeholder={state.t("连接名称", "Connection name")}
|
||||
value={state.form.name}
|
||||
onChange={(event) => state.setForm({ ...state.form, name: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("主机 IP", "Host IP")}
|
||||
value={state.form.host}
|
||||
onChange={(event) => state.setForm({ ...state.form, host: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("端口", "Port")}
|
||||
type="number"
|
||||
value={state.form.port}
|
||||
onChange={(event) => state.setForm({ ...state.form, port: Number(event.target.value) })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("用户名", "Username")}
|
||||
value={state.form.username}
|
||||
onChange={(event) => state.setForm({ ...state.form, username: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.form.auth_type === "password" ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setForm({ ...state.form, auth_type: "password" })}
|
||||
>
|
||||
{state.t("密码", "Password")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.form.auth_type === "private_key" ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setForm({ ...state.form, auth_type: "private_key" })}
|
||||
>
|
||||
{state.t("私钥", "Private key")}
|
||||
</Button>
|
||||
</div>
|
||||
{state.form.auth_type === "password" ? (
|
||||
<Input
|
||||
placeholder={state.t("密码", "Password")}
|
||||
type="password"
|
||||
value={state.form.password}
|
||||
onChange={(event) => state.setForm({ ...state.form, password: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
className={`min-h-[120px] w-full rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
|
||||
placeholder={state.t("私钥内容", "Private key content")}
|
||||
value={state.form.private_key}
|
||||
onChange={(event) => state.setForm({ ...state.form, private_key: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("私钥密码(可选)", "Private key passphrase (optional)")}
|
||||
type="password"
|
||||
value={state.form.key_passphrase}
|
||||
onChange={(event) => state.setForm({ ...state.form, key_passphrase: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" lightMode={!state.isDark} className="w-full">
|
||||
{state.t("保存连接", "Save connection")}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={`mt-6 pt-6 space-y-3 ${state.isDark ? "border-t border-slate-700" : "border-t border-slate-200"}`}>
|
||||
<p className={`text-sm font-semibold ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("已保存连接", "Saved connections")}</p>
|
||||
{state.connections.map((conn) => (
|
||||
<div key={conn.id} className={`rounded-md border p-3 text-sm ${state.isDark ? "border-slate-700 bg-slate-900/60 text-slate-300" : "border-slate-200 bg-slate-50 text-slate-600"}`}>
|
||||
{state.editingConnection?.id === conn.id ? (
|
||||
<form className="space-y-3" onSubmit={state.handleUpdateConnection}>
|
||||
<Input
|
||||
placeholder={state.t("连接名称", "Connection name")}
|
||||
value={state.editForm.name}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, name: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("主机 IP", "Host IP")}
|
||||
value={state.editForm.host}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, host: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("端口", "Port")}
|
||||
type="number"
|
||||
value={state.editForm.port}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, port: Number(event.target.value) })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("用户名", "Username")}
|
||||
value={state.editForm.username}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, username: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.editForm.auth_type === "password" ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setEditForm({ ...state.editForm, auth_type: "password" })}
|
||||
>
|
||||
{state.t("密码", "Password")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={state.editForm.auth_type === "private_key" ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setEditForm({ ...state.editForm, auth_type: "private_key" })}
|
||||
>
|
||||
{state.t("私钥", "Private key")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("留空则保持原凭据不变", "Leave empty to keep original credentials")}</p>
|
||||
{state.editForm.auth_type === "password" ? (
|
||||
<Input
|
||||
placeholder={state.t("新密码(可选)", "New password (optional)")}
|
||||
type="password"
|
||||
value={state.editForm.password}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, password: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
className={`min-h-[80px] w-full rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
|
||||
placeholder={state.t("新私钥(可选)", "New private key (optional)")}
|
||||
value={state.editForm.private_key}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, private_key: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("私钥密码(可选)", "Private key passphrase (optional)")}
|
||||
type="password"
|
||||
value={state.editForm.key_passphrase}
|
||||
onChange={(event) => state.setEditForm({ ...state.editForm, key_passphrase: event.target.value })}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" variant="primary" lightMode={!state.isDark}>
|
||||
{state.t("保存", "Save")}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" lightMode={!state.isDark} onClick={() => state.setEditingConnection(null)}>
|
||||
{state.t("取消", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className={`text-base font-semibold ${state.isDark ? "text-slate-100" : "text-slate-800"}`}>{conn.name}</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{conn.username}@{conn.host}:{conn.port}
|
||||
</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.t("创建", "Created")}: {new Date(conn.created_at).toLocaleString()} | {state.t("更新", "Updated")}: {new Date(conn.updated_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
loading={state.connectingId === conn.id}
|
||||
disabled={state.connectingId !== null}
|
||||
onClick={() => state.handleCreateSession(conn.id)}
|
||||
>
|
||||
{state.t("创建新的连接", "Create new connection")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.handleEditConnection(conn)}>
|
||||
{state.t("编辑", "Edit")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.setDeleteConfirm({ type: "connection", id: conn.id, name: conn.name })}>
|
||||
{state.t("删除", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "../../components/Button";
|
||||
import { Input } from "../../components/Input";
|
||||
import type { SessionsState } from "./useSessionsState";
|
||||
import { SessionsConnectionsPanel } from "./SessionsConnectionsPanel";
|
||||
import { SessionsDialogs } from "./SessionsDialogs";
|
||||
import { clearAuthStorage } from "../../lib/api";
|
||||
|
||||
type SessionsDesktopProps = {
|
||||
state: SessionsState;
|
||||
};
|
||||
|
||||
export function SessionsDesktop({ state }: SessionsDesktopProps) {
|
||||
if (state.loading) {
|
||||
return <div className={`p-6 ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("加载中...", "Loading...")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen px-6 py-8 transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
<div className={`flex items-center justify-between pb-6 ${state.isDark ? "border-b border-slate-700" : "border-b border-slate-200"}`}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{state.t("会话管理", "Session Management")}</h1>
|
||||
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{state.t("管理 SSH 连接与持久在线会话", "Manage SSH connections and persistent sessions")}
|
||||
<a
|
||||
href="https://github.com/beibeizi/WebSSHGateway"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`ml-2 inline-flex items-center gap-2 align-middle ${
|
||||
state.isDark ? "text-slate-300 hover:text-slate-200" : "text-slate-600 hover:text-slate-700"
|
||||
}`}
|
||||
aria-label={state.t("打开 GitHub 仓库", "Open GitHub repository")}
|
||||
title={state.t("打开 GitHub 仓库", "Open GitHub repository")}
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/github/stars/beibeizi/WebSSHGateway?style=social"
|
||||
alt={state.t("GitHub Star 数", "GitHub stars")}
|
||||
className="h-4"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${state.networkProfileTone}`}>{state.networkProfileLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.toggleLanguage}
|
||||
>
|
||||
{state.language === "en-US" ? "中文" : "EN"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.toggleTheme}
|
||||
>
|
||||
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setPasswordDialogOpen(true)}
|
||||
>
|
||||
{state.t("修改密码", "Change password")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => {
|
||||
clearAuthStorage();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
{state.t("退出登录", "Sign out")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
placeholder={state.t("搜索会话名称", "Search session name")}
|
||||
value={state.search}
|
||||
onChange={(event) => state.setSearch(event.target.value)}
|
||||
className={`max-w-xs ${state.isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: "all", label: state.t("全部", "All") },
|
||||
{ value: "active", label: state.t("在线", "Active") },
|
||||
{ value: "disconnected", label: state.t("离线", "Disconnected") },
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant={state.filter === item.value ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setFilter(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{state.filteredSessions.map((session) => {
|
||||
const noteValue = state.noteDrafts[session.id] ?? "";
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
state.ordering.cardRefs.current.set(session.id, element);
|
||||
} else {
|
||||
state.ordering.cardRefs.current.delete(session.id);
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => state.ordering.handleDragOver(session.id, event)}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
className={`relative rounded-lg border p-4 pl-14 transition-transform duration-200 ease-out will-change-transform ${
|
||||
state.ordering.draggingSessionId === session.id ? "ring-2 ring-indigo-400/60" : ""
|
||||
} ${state.isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
draggable={!state.ordering.savingOrder}
|
||||
onDragStart={(event) => state.ordering.handleDragStart(session.id, event)}
|
||||
onDragEnd={state.ordering.handleDragEnd}
|
||||
disabled={state.ordering.savingOrder}
|
||||
aria-label={state.t("拖动调整排序", "Drag to reorder")}
|
||||
title={state.t("拖动调整排序", "Drag to reorder")}
|
||||
className={`absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border p-2 shadow-sm transition ${
|
||||
state.isDark
|
||||
? "border-slate-700 bg-slate-900 text-slate-400 hover:text-slate-200"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:text-slate-700"
|
||||
} ${state.ordering.savingOrder ? "cursor-not-allowed opacity-50" : "cursor-grab active:cursor-grabbing"}`}
|
||||
>
|
||||
<span className="flex flex-col items-center leading-none">
|
||||
<ChevronUp className="h-4 w-4 -mb-1" />
|
||||
<ChevronDown className="h-4 w-4 -mt-1" />
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-semibold">{session.name}</p>
|
||||
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{session.username}@{session.host}
|
||||
</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("状态", "Status")}: {state.mapSessionStatus(session.status)}</p>
|
||||
{session.enhanced_enabled ? (
|
||||
<p className={`text-xs font-medium ${state.isDark ? "text-indigo-300" : "text-indigo-600"}`}>
|
||||
{state.t("增强持久化连接", "Enhanced persistent connection")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}</p>
|
||||
{session.disconnected_at ? (
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/5
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{session.status === "active" ? (
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={() => window.open(`/terminal/${session.id}`, "_blank")}>
|
||||
{state.t("打开会话", "Open session")}
|
||||
</Button>
|
||||
) : null}
|
||||
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
loading={state.isSystemRetrying(session) || !!state.retryingSessionIds[session.id]}
|
||||
onClick={() => state.handleRetryEnhancedSession(session.id)}
|
||||
>
|
||||
{state.t("重试连接", "Retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={() => state.handleDisconnectOrDelete(session)}>
|
||||
{session.status === "active" ? state.t("断开", "Disconnect") : state.t("删除", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Input
|
||||
placeholder={state.t("备注", "Note")}
|
||||
value={noteValue}
|
||||
maxLength={1000}
|
||||
onChange={(event) => state.handleNoteChange(session.id, event.target.value)}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<div className={`flex items-center justify-between text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
<span>{state.t("最多 1000 字", "Up to 1000 characters")}</span>
|
||||
{noteValue.trim() !== (session.note ?? "") ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.handleSaveNote(session)}
|
||||
>
|
||||
{state.t("保存备注", "Save note")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{state.filteredSessions.length === 0 ? (
|
||||
<div className={`text-sm ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("暂无会话", "No sessions")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionsConnectionsPanel state={state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionsDialogs state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import { Button } from "../../components/Button";
|
||||
import { ConfirmDialog } from "../../components/ConfirmDialog";
|
||||
import { Input } from "../../components/Input";
|
||||
import type { SessionsState } from "./useSessionsState";
|
||||
|
||||
type SessionsDialogsProps = {
|
||||
state: SessionsState;
|
||||
};
|
||||
|
||||
export function SessionsDialogs({ state }: SessionsDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={state.deleteConfirm !== null}
|
||||
title={state.deleteConfirm?.type === "session" ? state.t("删除会话", "Delete session") : state.t("删除连接", "Delete connection")}
|
||||
message={state.t(`确定要删除 "${state.deleteConfirm?.name ?? ""}" 吗?此操作不可撤销。`, `Are you sure you want to delete "${state.deleteConfirm?.name ?? ""}"? This action cannot be undone.`)}
|
||||
confirmText={state.t("删除", "Delete")}
|
||||
variant="danger"
|
||||
loading={state.deleteLoading}
|
||||
onConfirm={state.confirmDelete}
|
||||
onCancel={() => state.setDeleteConfirm(null)}
|
||||
/>
|
||||
|
||||
{state.passwordDialogOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
|
||||
<div className={`w-full max-w-md rounded-xl border p-6 shadow-xl ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-200 bg-white text-slate-900"}`}>
|
||||
<h3 className="mb-2 text-lg font-semibold">{state.t("修改密码", "Change password")}</h3>
|
||||
<p className={`mb-4 text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{state.t("请输入当前密码,并设置新的登录密码。", "Enter your current password and set a new login password.")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder={state.t("当前密码", "Current password")}
|
||||
type="password"
|
||||
value={state.passwordForm.currentPassword}
|
||||
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("新密码", "New password")}
|
||||
type="password"
|
||||
value={state.passwordForm.newPassword}
|
||||
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, newPassword: event.target.value }))}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<Input
|
||||
placeholder={state.t("确认新密码", "Confirm new password")}
|
||||
type="password"
|
||||
value={state.passwordForm.confirmPassword}
|
||||
onChange={(event) => state.setPasswordForm((prev) => ({ ...prev, confirmPassword: event.target.value }))}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.handleClosePasswordDialog}
|
||||
disabled={state.passwordSaving}
|
||||
>
|
||||
{state.t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
lightMode={!state.isDark}
|
||||
loading={state.passwordSaving}
|
||||
onClick={state.handleSubmitPasswordChange}
|
||||
>
|
||||
{state.t("确认修改", "Update password")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.enhancePrompt?.open ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
|
||||
<div className={`w-full max-w-lg rounded-xl border p-6 shadow-xl ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-100" : "border-slate-200 bg-white text-slate-900"}`}>
|
||||
<h3 className="text-lg font-semibold mb-2">{state.t("是否开启增强持久化连接", "Enable enhanced persistent connection?")}</h3>
|
||||
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{state.t(
|
||||
`检测到远端系统为 ${state.enhancePrompt.remoteOs || "unknown"} (${state.enhancePrompt.remoteArch || "unknown"}),建议开启增强持久化连接以提高稳定性。`,
|
||||
`Detected remote system ${state.enhancePrompt.remoteOs || "unknown"} (${state.enhancePrompt.remoteArch || "unknown"}). Enable enhanced persistence for better stability.`
|
||||
)}
|
||||
</p>
|
||||
<label className={`mt-4 flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${state.isDark ? "border-slate-700 bg-slate-800/60" : "border-slate-200 bg-slate-50"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.enhancePrompt.checked}
|
||||
onChange={(event) => state.setEnhancePrompt((prev) => (prev ? { ...prev, checked: event.target.checked } : prev))}
|
||||
/>
|
||||
<span>{state.t("开启增强持久化连接", "Enable enhanced persistent connection")}</span>
|
||||
</label>
|
||||
<div className={`mt-3 text-xs ${state.isDark ? "text-slate-500" : "text-slate-500"}`}>
|
||||
{state.t(
|
||||
"普通连接的可靠性由项目所在机器和目标之间的网络决定。开启此功能会向目标机器搭建 tmux 通道,防止网络波动造成的连接断开。",
|
||||
"Normal connections depend on network stability between this host and the target. Enabling this will create a tmux channel to keep the session alive during network fluctuations."
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setEnhancePrompt(null)}
|
||||
>
|
||||
{state.t("取消", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.handleConfirmEnhance}
|
||||
>
|
||||
{state.t("确认连接", "Confirm connection")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "../../components/Button";
|
||||
import { Input } from "../../components/Input";
|
||||
import type { SessionsState } from "./useSessionsState";
|
||||
import { SessionsConnectionsPanel } from "./SessionsConnectionsPanel";
|
||||
import { SessionsDialogs } from "./SessionsDialogs";
|
||||
import { clearAuthStorage } from "../../lib/api";
|
||||
|
||||
type SessionsMobileProps = {
|
||||
state: SessionsState;
|
||||
};
|
||||
|
||||
export function SessionsMobile({ state }: SessionsMobileProps) {
|
||||
if (state.loading) {
|
||||
return <div className={`p-6 ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>{state.t("加载中...", "Loading...")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen px-4 py-6 transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
|
||||
<div className="space-y-6">
|
||||
<div className={`space-y-3 pb-4 ${state.isDark ? "border-b border-slate-800" : "border-b border-slate-200"}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{state.t("会话管理", "Session Management")}</h1>
|
||||
<p className={`text-xs ${state.networkProfileTone}`}>{state.networkProfileLabel}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.toggleLanguage}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.language === "en-US" ? "中文" : "EN"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.toggleTheme}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setPasswordDialogOpen(true)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.t("修改密码", "Change password")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => {
|
||||
clearAuthStorage();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.t("退出登录", "Sign out")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder={state.t("搜索会话名称", "Search session name")}
|
||||
value={state.search}
|
||||
onChange={(event) => state.setSearch(event.target.value)}
|
||||
className={`${state.isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`}
|
||||
/>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ value: "all", label: state.t("全部", "All") },
|
||||
{ value: "active", label: state.t("在线", "Active") },
|
||||
{ value: "disconnected", label: state.t("离线", "Disconnected") },
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
variant={state.filter === item.value ? "primary" : "secondary"}
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.setFilter(item.value)}
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{state.filteredSessions.map((session, index) => {
|
||||
const noteValue = state.noteDrafts[session.id] ?? "";
|
||||
const canMoveUp = index > 0 && !state.ordering.savingOrder;
|
||||
const canMoveDown = index < state.filteredSessions.length - 1 && !state.ordering.savingOrder;
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
state.ordering.cardRefs.current.set(session.id, element);
|
||||
} else {
|
||||
state.ordering.cardRefs.current.delete(session.id);
|
||||
}
|
||||
}}
|
||||
className={`relative rounded-lg border p-4 pl-12 transition-transform duration-200 ease-out will-change-transform ${
|
||||
state.isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className={`flex flex-col items-center gap-1 rounded-full border p-1 ${state.isDark ? "border-slate-700 bg-slate-900 text-slate-400" : "border-slate-200 bg-white text-slate-500"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.ordering.handleMoveSession(session.id, "up")}
|
||||
disabled={!canMoveUp}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full transition ${
|
||||
canMoveUp
|
||||
? (state.isDark ? "hover:bg-slate-800 text-slate-200" : "hover:bg-slate-100 text-slate-700")
|
||||
: "opacity-40 cursor-not-allowed"
|
||||
}`}
|
||||
aria-label={state.t("上移会话", "Move session up")}
|
||||
>
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.ordering.handleMoveSession(session.id, "down")}
|
||||
disabled={!canMoveDown}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-full transition ${
|
||||
canMoveDown
|
||||
? (state.isDark ? "hover:bg-slate-800 text-slate-200" : "hover:bg-slate-100 text-slate-700")
|
||||
: "opacity-40 cursor-not-allowed"
|
||||
}`}
|
||||
aria-label={state.t("下移会话", "Move session down")}
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-base font-semibold">{session.name}</p>
|
||||
<p className={`text-sm ${state.isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{session.username}@{session.host}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("状态", "Status")}: {state.mapSessionStatus(session.status)}</p>
|
||||
{session.enhanced_enabled ? (
|
||||
<p className={`text-xs font-medium ${state.isDark ? "text-indigo-300" : "text-indigo-600"}`}>
|
||||
{state.t("增强持久化连接", "Enhanced persistent connection")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}</p>
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}</p>
|
||||
{session.disconnected_at ? (
|
||||
<p className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{session.status === "active" ? (
|
||||
<Button variant="secondary" lightMode={!state.isDark} className="w-full" onClick={() => window.open(`/terminal/${session.id}`, "_blank")}>
|
||||
{state.t("打开会话", "Open session")}
|
||||
</Button>
|
||||
) : null}
|
||||
{session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
loading={state.isSystemRetrying(session) || !!state.retryingSessionIds[session.id]}
|
||||
className="w-full"
|
||||
onClick={() => state.handleRetryEnhancedSession(session.id)}
|
||||
>
|
||||
{state.t("重试连接", "Retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" lightMode={!state.isDark} className="w-full" onClick={() => state.handleDisconnectOrDelete(session)}>
|
||||
{session.status === "active" ? state.t("断开", "Disconnect") : state.t("删除", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={state.t("备注", "Note")}
|
||||
value={noteValue}
|
||||
maxLength={1000}
|
||||
onChange={(event) => state.handleNoteChange(session.id, event.target.value)}
|
||||
className={!state.isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""}
|
||||
/>
|
||||
<div className={`flex items-center justify-between text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
<span>{state.t("最多 1000 字", "Up to 1000 characters")}</span>
|
||||
{noteValue.trim() !== (session.note ?? "") ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={() => state.handleSaveNote(session)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
{state.t("保存备注", "Save note")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{state.filteredSessions.length === 0 ? (
|
||||
<div className={`text-sm ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.t("暂无会话", "No sessions")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SessionsConnectionsPanel state={state} />
|
||||
</div>
|
||||
|
||||
<SessionsDialogs state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export function localizeText(language: string, zh: string, en: string): string {
|
||||
return language === "en-US" ? en : zh;
|
||||
}
|
||||
|
||||
const NETWORK_PROFILE_RANK: Record<"good" | "degraded" | "poor", number> = {
|
||||
good: 0,
|
||||
degraded: 1,
|
||||
poor: 2,
|
||||
};
|
||||
|
||||
export function pickWorseProfile(
|
||||
a: "good" | "degraded" | "poor",
|
||||
b: "good" | "degraded" | "poor"
|
||||
): "good" | "degraded" | "poor" {
|
||||
return NETWORK_PROFILE_RANK[a] >= NETWORK_PROFILE_RANK[b] ? a : b;
|
||||
}
|
||||
|
||||
export function normalizeTargetProfile(raw: string | undefined | null): "good" | "degraded" | "poor" | "unknown" {
|
||||
if (raw === "good" || raw === "degraded" || raw === "poor") {
|
||||
return raw;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function moveItem<T>(list: T[], fromIndex: number, toIndex: number): T[] {
|
||||
const next = [...list];
|
||||
const [item] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mergeVisibleOrder(
|
||||
fullOrder: string[],
|
||||
visibleOrder: string[],
|
||||
nextVisibleOrder: string[]
|
||||
): string[] {
|
||||
const visibleSet = new Set(visibleOrder);
|
||||
let visibleIndex = 0;
|
||||
return fullOrder.map((id) => {
|
||||
if (!visibleSet.has(id)) {
|
||||
return id;
|
||||
}
|
||||
const nextId = nextVisibleOrder[visibleIndex];
|
||||
visibleIndex += 1;
|
||||
return nextId;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { changePassword } from "../../lib/api";
|
||||
|
||||
type UsePasswordDialogOptions = {
|
||||
push: (message: string) => void;
|
||||
t: (zh: string, en: string) => string;
|
||||
};
|
||||
|
||||
export function usePasswordDialog({ push, t }: UsePasswordDialogOptions) {
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = React.useState(false);
|
||||
const [passwordSaving, setPasswordSaving] = React.useState(false);
|
||||
const [passwordForm, setPasswordForm] = React.useState({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const resetPasswordForm = React.useCallback(() => {
|
||||
setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
|
||||
}, []);
|
||||
|
||||
const handleClosePasswordDialog = React.useCallback(() => {
|
||||
if (passwordSaving) {
|
||||
return;
|
||||
}
|
||||
setPasswordDialogOpen(false);
|
||||
resetPasswordForm();
|
||||
}, [passwordSaving, resetPasswordForm]);
|
||||
|
||||
const handleSubmitPasswordChange = async () => {
|
||||
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
|
||||
push(t("请完整填写密码信息", "Please complete all password fields"));
|
||||
return;
|
||||
}
|
||||
setPasswordSaving(true);
|
||||
try {
|
||||
await changePassword(
|
||||
passwordForm.currentPassword,
|
||||
passwordForm.newPassword,
|
||||
passwordForm.confirmPassword,
|
||||
);
|
||||
push(t("密码已更新", "Password updated"));
|
||||
setPasswordDialogOpen(false);
|
||||
resetPasswordForm();
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("修改失败", "Update failed"));
|
||||
} finally {
|
||||
setPasswordSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
passwordDialogOpen,
|
||||
setPasswordDialogOpen,
|
||||
passwordSaving,
|
||||
passwordForm,
|
||||
setPasswordForm,
|
||||
handleClosePasswordDialog,
|
||||
handleSubmitPasswordChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from "react";
|
||||
import type { Session } from "../../lib/api";
|
||||
import { updateSessionOrder } from "../../lib/api";
|
||||
import { mergeVisibleOrder, moveItem } from "./sessionsUtils";
|
||||
|
||||
type UseSessionsOrderingOptions = {
|
||||
orderedSessions: Session[];
|
||||
filteredSessions: Session[];
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
|
||||
loadData: () => Promise<void>;
|
||||
push: (message: string) => void;
|
||||
t: (zh: string, en: string) => string;
|
||||
draggingRef: React.MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
export function useSessionsOrdering({
|
||||
orderedSessions,
|
||||
filteredSessions,
|
||||
setSessions,
|
||||
loadData,
|
||||
push,
|
||||
t,
|
||||
draggingRef,
|
||||
}: UseSessionsOrderingOptions) {
|
||||
const [draggingSessionId, setDraggingSessionId] = React.useState<string | null>(null);
|
||||
const [savingOrder, setSavingOrder] = React.useState(false);
|
||||
const orderDirtyRef = React.useRef(false);
|
||||
const orderedIdsRef = React.useRef<string[]>([]);
|
||||
const cardRefs = React.useRef(new Map<string, HTMLDivElement>());
|
||||
const previousPositionsRef = React.useRef(new Map<string, DOMRect>());
|
||||
|
||||
React.useEffect(() => {
|
||||
orderedIdsRef.current = orderedSessions.map((session) => session.id);
|
||||
}, [orderedSessions]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const nextPositions = new Map<string, DOMRect>();
|
||||
filteredSessions.forEach((session) => {
|
||||
const element = cardRefs.current.get(session.id);
|
||||
if (element) {
|
||||
nextPositions.set(session.id, element.getBoundingClientRect());
|
||||
}
|
||||
});
|
||||
|
||||
const previousPositions = previousPositionsRef.current;
|
||||
nextPositions.forEach((nextRect, sessionId) => {
|
||||
const previousRect = previousPositions.get(sessionId);
|
||||
if (!previousRect) {
|
||||
return;
|
||||
}
|
||||
const deltaY = previousRect.top - nextRect.top;
|
||||
if (deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
const element = cardRefs.current.get(sessionId);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.style.transform = `translateY(${deltaY}px)`;
|
||||
element.getBoundingClientRect();
|
||||
element.style.transform = "";
|
||||
});
|
||||
|
||||
previousPositionsRef.current = nextPositions;
|
||||
}, [filteredSessions]);
|
||||
|
||||
const persistSessionOrder = React.useCallback(async (orderedIds: string[]) => {
|
||||
if (savingOrder) {
|
||||
return;
|
||||
}
|
||||
setSavingOrder(true);
|
||||
try {
|
||||
await updateSessionOrder(orderedIds);
|
||||
push(t("排序已保存", "Order saved"));
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("保存失败", "Save failed"));
|
||||
await loadData();
|
||||
} finally {
|
||||
setSavingOrder(false);
|
||||
}
|
||||
}, [loadData, push, savingOrder, t]);
|
||||
|
||||
const applySessionOrder = React.useCallback((orderedIds: string[]) => {
|
||||
orderedIdsRef.current = orderedIds;
|
||||
const orderMap = new Map(orderedIds.map((id, index) => [id, index + 1]));
|
||||
setSessions((prev) => prev.map((session) => (
|
||||
orderMap.has(session.id)
|
||||
? { ...session, session_order: orderMap.get(session.id) }
|
||||
: session
|
||||
)));
|
||||
}, [setSessions]);
|
||||
|
||||
const handleDragStart = React.useCallback((sessionId: string, event: React.DragEvent<HTMLButtonElement>) => {
|
||||
if (savingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
draggingRef.current = true;
|
||||
orderDirtyRef.current = false;
|
||||
setDraggingSessionId(sessionId);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", sessionId);
|
||||
}, [savingOrder]);
|
||||
|
||||
const handleDragOver = React.useCallback((sessionId: string, event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!draggingSessionId || draggingSessionId === sessionId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
const visibleOrderIds = filteredSessions.map((session) => session.id);
|
||||
if (!visibleOrderIds.includes(draggingSessionId) || !visibleOrderIds.includes(sessionId)) {
|
||||
return;
|
||||
}
|
||||
const fromIndex = visibleOrderIds.indexOf(draggingSessionId);
|
||||
const toIndex = visibleOrderIds.indexOf(sessionId);
|
||||
if (fromIndex === toIndex) {
|
||||
return;
|
||||
}
|
||||
const nextVisibleOrder = moveItem(visibleOrderIds, fromIndex, toIndex);
|
||||
const nextFullOrder = mergeVisibleOrder(orderedIdsRef.current, visibleOrderIds, nextVisibleOrder);
|
||||
applySessionOrder(nextFullOrder);
|
||||
orderDirtyRef.current = true;
|
||||
}, [applySessionOrder, draggingSessionId, filteredSessions]);
|
||||
|
||||
const handleDragEnd = React.useCallback(async () => {
|
||||
draggingRef.current = false;
|
||||
setDraggingSessionId(null);
|
||||
if (!orderDirtyRef.current) {
|
||||
return;
|
||||
}
|
||||
orderDirtyRef.current = false;
|
||||
await persistSessionOrder(orderedIdsRef.current);
|
||||
}, [persistSessionOrder]);
|
||||
|
||||
const handleMoveSession = React.useCallback(async (sessionId: string, direction: "up" | "down") => {
|
||||
if (savingOrder) {
|
||||
return;
|
||||
}
|
||||
const visibleOrderIds = filteredSessions.map((session) => session.id);
|
||||
const currentIndex = visibleOrderIds.indexOf(sessionId);
|
||||
if (currentIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= visibleOrderIds.length) {
|
||||
return;
|
||||
}
|
||||
const nextVisibleOrder = moveItem(visibleOrderIds, currentIndex, targetIndex);
|
||||
const nextFullOrder = mergeVisibleOrder(orderedIdsRef.current, visibleOrderIds, nextVisibleOrder);
|
||||
applySessionOrder(nextFullOrder);
|
||||
await persistSessionOrder(nextFullOrder);
|
||||
}, [applySessionOrder, filteredSessions, persistSessionOrder, savingOrder]);
|
||||
|
||||
return {
|
||||
draggingSessionId,
|
||||
savingOrder,
|
||||
orderedIdsRef,
|
||||
cardRefs,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleMoveSession,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
import type { Session } from "../../lib/api";
|
||||
import { clearAuthStorage, getStoredToken, listSessions, openSessionSocket } from "../../lib/api";
|
||||
|
||||
type UseSessionsPollingOptions = {
|
||||
sessionsPollIntervalMs: number;
|
||||
preserveOrderIfDragging: (list: Session[]) => Session[];
|
||||
sessionsRef: React.MutableRefObject<Session[]>;
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
|
||||
setNoteDrafts: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
push: (message: string) => void;
|
||||
t: (zh: string, en: string) => string;
|
||||
reportNetworkHint: (profile: "good" | "degraded" | "poor", durationMs: number) => void;
|
||||
};
|
||||
|
||||
export function useSessionsPolling({
|
||||
sessionsPollIntervalMs,
|
||||
preserveOrderIfDragging,
|
||||
sessionsRef,
|
||||
setSessions,
|
||||
setNoteDrafts,
|
||||
push,
|
||||
t,
|
||||
reportNetworkHint,
|
||||
}: UseSessionsPollingOptions) {
|
||||
const sessionPollInFlightRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (cancelled) return;
|
||||
timer = window.setTimeout(run, sessionsPollIntervalMs);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (sessionPollInFlightRef.current) {
|
||||
scheduleNext();
|
||||
return;
|
||||
}
|
||||
sessionPollInFlightRef.current = true;
|
||||
try {
|
||||
const sessionList = await listSessions();
|
||||
setSessions(preserveOrderIfDragging(sessionList));
|
||||
setNoteDrafts((prev) => {
|
||||
const previousSessions = sessionsRef.current;
|
||||
const next = { ...prev };
|
||||
sessionList.forEach((session) => {
|
||||
if (!(session.id in next)) {
|
||||
next[session.id] = session.note ?? "";
|
||||
return;
|
||||
}
|
||||
const previousSession = previousSessions.find((item) => item.id === session.id);
|
||||
if (next[session.id] === (previousSession?.note ?? "")) {
|
||||
next[session.id] = session.note ?? "";
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// 静默处理刷新错误
|
||||
} finally {
|
||||
sessionPollInFlightRef.current = false;
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [sessionsPollIntervalMs, preserveOrderIfDragging, setSessions, setNoteDrafts, sessionsRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const userId = localStorage.getItem("user_id") || sessionStorage.getItem("user_id");
|
||||
const token = getStoredToken();
|
||||
if (!userId || !token) {
|
||||
clearAuthStorage();
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
const socket = openSessionSocket(Number(userId));
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as Partial<Session> & { status?: string };
|
||||
if (payload.id && payload.status === "deleted") {
|
||||
setSessions((prev) => prev.filter((item) => item.id !== payload.id));
|
||||
return;
|
||||
}
|
||||
setSessions((prev) => {
|
||||
const existing = prev.find((item) => item.id === payload.id);
|
||||
if (existing) {
|
||||
return prev.map((item) => (item.id === payload.id ? { ...item, ...payload } as Session : item));
|
||||
}
|
||||
if (payload.id) {
|
||||
return [...prev, payload as Session];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
if (payload.id && "note" in payload) {
|
||||
setNoteDrafts((prev) => ({ ...prev, [payload.id as string]: payload.note ?? "" }));
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
};
|
||||
let warned = false;
|
||||
socket.onerror = () => {
|
||||
if (!warned) {
|
||||
push(t("会话状态连接失败", "Session status connection failed"));
|
||||
warned = true;
|
||||
}
|
||||
reportNetworkHint("degraded", 15000);
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (!event.wasClean && !warned) {
|
||||
push(t("会话状态连接已断开", "Session status connection closed"));
|
||||
warned = true;
|
||||
}
|
||||
if (!event.wasClean) {
|
||||
reportNetworkHint("poor", 25000);
|
||||
}
|
||||
};
|
||||
return () => socket.close();
|
||||
}, [push, reportNetworkHint, setSessions, setNoteDrafts, t]);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import React from "react";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { useApp } from "../../context/AppContext";
|
||||
import {
|
||||
Connection,
|
||||
Session,
|
||||
createConnection,
|
||||
createSession,
|
||||
deleteConnection,
|
||||
deleteSession,
|
||||
disconnectSession,
|
||||
listConnections,
|
||||
listSessions,
|
||||
prepareSession,
|
||||
retrySession,
|
||||
updateConnection,
|
||||
updateSessionNote,
|
||||
} from "../../lib/api";
|
||||
import { localizeText, normalizeTargetProfile, pickWorseProfile } from "./sessionsUtils";
|
||||
import { useSessionsOrdering } from "./useSessionsOrdering";
|
||||
import { useSessionsPolling } from "./useSessionsPolling";
|
||||
import { usePasswordDialog } from "./usePasswordDialog";
|
||||
|
||||
export function useSessionsState() {
|
||||
const [connections, setConnections] = React.useState<Connection[]>([]);
|
||||
const [sessions, setSessions] = React.useState<Session[]>([]);
|
||||
const [filter, setFilter] = React.useState("all");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [form, setForm] = React.useState({
|
||||
name: "",
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
auth_type: "password" as "password" | "private_key",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
});
|
||||
const [noteDrafts, setNoteDrafts] = React.useState<Record<string, string>>({});
|
||||
const [showCreateForm, setShowCreateForm] = React.useState(false);
|
||||
const [editingConnection, setEditingConnection] = React.useState<Connection | null>(null);
|
||||
const [editForm, setEditForm] = React.useState({
|
||||
name: "",
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
auth_type: "password" as "password" | "private_key",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
});
|
||||
const [deleteConfirm, setDeleteConfirm] = React.useState<{
|
||||
type: "session" | "connection";
|
||||
id: string | number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||
const [connectingId, setConnectingId] = React.useState<number | null>(null);
|
||||
const [retryingSessionIds, setRetryingSessionIds] = React.useState<Record<string, boolean>>({});
|
||||
const [enhancePrompt, setEnhancePrompt] = React.useState<{
|
||||
open: boolean;
|
||||
connectionId: number;
|
||||
remoteArch: string;
|
||||
remoteOs: string;
|
||||
checked: boolean;
|
||||
} | null>(null);
|
||||
const { push } = useToast();
|
||||
const { isDark, toggleTheme, toggleLanguage, language, networkProfile, reportNetworkHint } = useApp();
|
||||
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
|
||||
const passwordDialog = usePasswordDialog({ push, t });
|
||||
const sessionsRef = React.useRef<Session[]>([]);
|
||||
const draggingRef = React.useRef(false);
|
||||
|
||||
const preserveOrderIfDragging = React.useCallback((sessionList: Session[]) => {
|
||||
if (!draggingRef.current) {
|
||||
return sessionList;
|
||||
}
|
||||
const orderMap = new Map(sessionsRef.current.map((session) => [session.id, session.session_order]));
|
||||
return sessionList.map((session) => (
|
||||
orderMap.has(session.id)
|
||||
? { ...session, session_order: orderMap.get(session.id) }
|
||||
: session
|
||||
));
|
||||
}, []);
|
||||
|
||||
const loadData = React.useCallback(async () => {
|
||||
try {
|
||||
const [connectionList, sessionList] = await Promise.all([listConnections(), listSessions()]);
|
||||
setConnections(connectionList);
|
||||
setSessions(preserveOrderIfDragging(sessionList));
|
||||
setNoteDrafts((prev) => {
|
||||
const next = { ...prev };
|
||||
sessionList.forEach((session) => {
|
||||
if (!(session.id in next)) {
|
||||
next[session.id] = session.note ?? "";
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("加载失败", "Failed to load data"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [preserveOrderIfDragging, push, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData, language]);
|
||||
|
||||
React.useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
|
||||
const orderedSessions = React.useMemo(() => {
|
||||
const normalizeOrder = (value?: number) => (value && value > 0 ? value : Number.MAX_SAFE_INTEGER);
|
||||
return [...sessions].sort((a, b) => {
|
||||
const orderA = normalizeOrder(a.session_order);
|
||||
const orderB = normalizeOrder(b.session_order);
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
return new Date(a.started_at).getTime() - new Date(b.started_at).getTime();
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
const filteredSessions = orderedSessions.filter((session) => {
|
||||
const matchStatus = filter === "all" || session.status === filter;
|
||||
const matchSearch = (session.name || "").toLowerCase().includes(search.toLowerCase());
|
||||
return matchStatus && matchSearch;
|
||||
});
|
||||
|
||||
const ordering = useSessionsOrdering({
|
||||
orderedSessions,
|
||||
filteredSessions,
|
||||
setSessions,
|
||||
loadData,
|
||||
push,
|
||||
t,
|
||||
draggingRef,
|
||||
});
|
||||
|
||||
const targetOverviewProfile = React.useMemo<"good" | "degraded" | "poor" | "unknown">(() => {
|
||||
let current: "good" | "degraded" | "poor" | "unknown" = "unknown";
|
||||
for (const session of sessions) {
|
||||
const profile = normalizeTargetProfile(session.target_profile);
|
||||
if (profile === "unknown") {
|
||||
continue;
|
||||
}
|
||||
current = current === "unknown" ? profile : pickWorseProfile(current, profile);
|
||||
}
|
||||
return current;
|
||||
}, [sessions]);
|
||||
|
||||
const effectiveNetworkProfile = React.useMemo<"good" | "degraded" | "poor">(() => {
|
||||
if (targetOverviewProfile === "unknown") {
|
||||
return networkProfile;
|
||||
}
|
||||
return pickWorseProfile(networkProfile, targetOverviewProfile);
|
||||
}, [networkProfile, targetOverviewProfile]);
|
||||
|
||||
const sessionsPollIntervalMs =
|
||||
effectiveNetworkProfile === "poor" ? 25000 : effectiveNetworkProfile === "degraded" ? 15000 : 10000;
|
||||
|
||||
useSessionsPolling({
|
||||
sessionsPollIntervalMs,
|
||||
preserveOrderIfDragging,
|
||||
sessionsRef,
|
||||
setSessions,
|
||||
setNoteDrafts,
|
||||
push,
|
||||
t,
|
||||
reportNetworkHint,
|
||||
});
|
||||
|
||||
const networkProfileLabel =
|
||||
effectiveNetworkProfile === "good"
|
||||
? t("网络良好", "Network Good")
|
||||
: effectiveNetworkProfile === "degraded"
|
||||
? t("网络波动", "Network Fluctuating")
|
||||
: t("弱网模式", "Poor Network Mode");
|
||||
const networkProfileTone =
|
||||
effectiveNetworkProfile === "good" ? "text-emerald-400" : effectiveNetworkProfile === "degraded" ? "text-amber-400" : "text-rose-400";
|
||||
|
||||
const mapSessionStatus = React.useCallback(
|
||||
(status: string) => {
|
||||
if (status === "active") {
|
||||
return t("在线", "Active");
|
||||
}
|
||||
if (status === "disconnected") {
|
||||
return t("离线", "Disconnected");
|
||||
}
|
||||
return status;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const isSystemRetrying = React.useCallback((session: Session) => {
|
||||
return (
|
||||
session.enhanced_enabled === true
|
||||
&& session.status !== "active"
|
||||
&& session.allow_auto_retry !== false
|
||||
&& (session.retry_cycle_count ?? 0) < 5
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleCreateConnection = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.name || !form.host || !form.username) {
|
||||
push(t("请填写完整连接信息", "Please fill in all required connection fields"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
host: form.host,
|
||||
port: Number(form.port),
|
||||
username: form.username,
|
||||
auth_type: form.auth_type,
|
||||
password: form.auth_type === "password" ? form.password : undefined,
|
||||
private_key: form.auth_type === "private_key" ? form.private_key : undefined,
|
||||
key_passphrase: form.auth_type === "private_key" ? form.key_passphrase : undefined,
|
||||
};
|
||||
await createConnection(payload);
|
||||
push(t("连接已保存", "Connection saved"));
|
||||
setForm({ name: "", host: "", port: 22, username: "", auth_type: "password", password: "", private_key: "", key_passphrase: "" });
|
||||
setShowCreateForm(false);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("创建失败", "Create failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const createSessionWithOption = async (
|
||||
connectionId: number,
|
||||
signal: AbortSignal,
|
||||
enableEnhancedPersistence: boolean
|
||||
) => {
|
||||
const session = await createSession(
|
||||
{
|
||||
connection_id: connectionId,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
enable_enhanced_persistence: enableEnhancedPersistence,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
push(t("会话已启动", "Session started"));
|
||||
window.open(`/terminal/${session.id}`, "_blank");
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleCreateSession = async (connectionId: number) => {
|
||||
setConnectingId(connectionId);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const prepared = await prepareSession(connectionId, { signal: controller.signal });
|
||||
if (prepared.should_prompt_enhance) {
|
||||
setEnhancePrompt({
|
||||
open: true,
|
||||
connectionId,
|
||||
remoteArch: prepared.remote_arch,
|
||||
remoteOs: prepared.remote_os,
|
||||
checked: false,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
setConnectingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
await createSessionWithOption(connectionId, controller.signal, false);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
push(t("连接超时,请检查与目标机器网络是否通畅", "Connection timeout. Please verify network reachability to the target host."));
|
||||
} else {
|
||||
push(error instanceof Error ? error.message : t("启动失败", "Start failed"));
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setConnectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmEnhance = async () => {
|
||||
if (!enhancePrompt) {
|
||||
return;
|
||||
}
|
||||
const { connectionId, checked } = enhancePrompt;
|
||||
setEnhancePrompt(null);
|
||||
setConnectingId(connectionId);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
await createSessionWithOption(connectionId, controller.signal, checked);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
push(t("连接超时,请检查与目标机器网络是否通畅", "Connection timeout. Please verify network reachability to the target host."));
|
||||
} else {
|
||||
push(error instanceof Error ? error.message : t("启动失败", "Start failed"));
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setConnectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryEnhancedSession = async (sessionId: string) => {
|
||||
if (retryingSessionIds[sessionId]) {
|
||||
return;
|
||||
}
|
||||
setRetryingSessionIds((prev) => ({ ...prev, [sessionId]: true }));
|
||||
try {
|
||||
await retrySession(sessionId);
|
||||
push(t("重试连接已发起", "Retry request started"));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("重试失败", "Retry failed"));
|
||||
} finally {
|
||||
setRetryingSessionIds((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[sessionId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectOrDelete = async (session: Session) => {
|
||||
if (session.status === "active") {
|
||||
if (!confirm(t("断开后此会话后台正在执行的程序会停止运行,确定要断开吗?", "Disconnecting will stop running processes in this session. Continue?"))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await disconnectSession(session.id);
|
||||
push(t("会话已断开", "Session disconnected"));
|
||||
loadData();
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("断开失败", "Disconnect failed"));
|
||||
}
|
||||
} else {
|
||||
setDeleteConfirm({ type: "session", id: session.id, name: session.name });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (sessionId: string) => {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteSession(sessionId);
|
||||
push(t("会话已删除", "Session deleted"));
|
||||
loadData();
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("删除失败", "Delete failed"));
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConnection = async (connectionId: number) => {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteConnection(connectionId);
|
||||
push(t("连接已删除", "Connection deleted"));
|
||||
loadData();
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("删除失败", "Delete failed"));
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm) return;
|
||||
if (deleteConfirm.type === "session") {
|
||||
handleDeleteSession(deleteConfirm.id as string);
|
||||
} else {
|
||||
handleDeleteConnection(deleteConfirm.id as number);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditConnection = (conn: Connection) => {
|
||||
setEditingConnection(conn);
|
||||
setEditForm({
|
||||
name: conn.name,
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: conn.username,
|
||||
auth_type: conn.auth_type as "password" | "private_key",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateConnection = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!editingConnection) return;
|
||||
try {
|
||||
const payload: Parameters<typeof updateConnection>[1] = {
|
||||
name: editForm.name,
|
||||
host: editForm.host,
|
||||
port: Number(editForm.port),
|
||||
username: editForm.username,
|
||||
};
|
||||
if (editForm.password || editForm.private_key || editForm.key_passphrase) {
|
||||
payload.auth_type = editForm.auth_type;
|
||||
payload.password = editForm.auth_type === "password" ? editForm.password : undefined;
|
||||
payload.private_key = editForm.auth_type === "private_key" ? editForm.private_key : undefined;
|
||||
payload.key_passphrase = editForm.auth_type === "private_key" ? editForm.key_passphrase : undefined;
|
||||
}
|
||||
await updateConnection(editingConnection.id, payload);
|
||||
push(t("连接已更新", "Connection updated"));
|
||||
setEditingConnection(null);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("更新失败", "Update failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNoteChange = (sessionId: string, value: string) => {
|
||||
setNoteDrafts((prev) => ({ ...prev, [sessionId]: value }));
|
||||
};
|
||||
|
||||
const handleSaveNote = async (session: Session) => {
|
||||
try {
|
||||
const note = (noteDrafts[session.id] ?? "").trim();
|
||||
const response = await updateSessionNote(session.id, note.length > 0 ? note : null);
|
||||
setSessions((prev) => prev.map((item) => (item.id === response.id ? { ...item, ...response } : item)));
|
||||
setNoteDrafts((prev) => ({ ...prev, [session.id]: response.note ?? "" }));
|
||||
push(t("备注已保存", "Note saved"));
|
||||
} catch (error) {
|
||||
push(error instanceof Error ? error.message : t("保存失败", "Save failed"));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDark,
|
||||
toggleTheme,
|
||||
toggleLanguage,
|
||||
language,
|
||||
t,
|
||||
connections,
|
||||
sessions,
|
||||
orderedSessions,
|
||||
filteredSessions,
|
||||
filter,
|
||||
setFilter,
|
||||
search,
|
||||
setSearch,
|
||||
loading,
|
||||
form,
|
||||
setForm,
|
||||
showCreateForm,
|
||||
setShowCreateForm,
|
||||
editingConnection,
|
||||
setEditingConnection,
|
||||
editForm,
|
||||
setEditForm,
|
||||
noteDrafts,
|
||||
handleNoteChange,
|
||||
handleSaveNote,
|
||||
deleteConfirm,
|
||||
setDeleteConfirm,
|
||||
deleteLoading,
|
||||
confirmDelete,
|
||||
...passwordDialog,
|
||||
handleCreateConnection,
|
||||
handleUpdateConnection,
|
||||
handleEditConnection,
|
||||
handleCreateSession,
|
||||
connectingId,
|
||||
handleRetryEnhancedSession,
|
||||
retryingSessionIds,
|
||||
handleDisconnectOrDelete,
|
||||
enhancePrompt,
|
||||
setEnhancePrompt,
|
||||
handleConfirmEnhance,
|
||||
mapSessionStatus,
|
||||
isSystemRetrying,
|
||||
networkProfileLabel,
|
||||
networkProfileTone,
|
||||
ordering,
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionsState = ReturnType<typeof useSessionsState>;
|
||||
@@ -0,0 +1,193 @@
|
||||
import React from "react";
|
||||
import { Button } from "../../components/Button";
|
||||
import { FileBrowser } from "../../components/FileBrowser";
|
||||
import { SystemMonitor } from "../../components/SystemMonitor";
|
||||
import type { TerminalSessionState } from "./useTerminalSession";
|
||||
|
||||
type TerminalDesktopProps = {
|
||||
state: TerminalSessionState;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function TerminalDesktop({ state, onBack }: TerminalDesktopProps) {
|
||||
const { syncTerminalSize, terminalInstance } = state;
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState(() => {
|
||||
const saved = localStorage.getItem("terminal-sidebar-width");
|
||||
return saved ? Number(saved) : 256;
|
||||
});
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleMouseDown = React.useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newWidth = containerRect.right - event.clientX;
|
||||
const maxWidth = containerRect.width * 0.7;
|
||||
const clampedWidth = Math.max(50, Math.min(maxWidth, newWidth));
|
||||
setSidebarWidth(clampedWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem("terminal-sidebar-width", String(sidebarWidth));
|
||||
const timer = window.setTimeout(() => {
|
||||
const term = terminalInstance.current;
|
||||
if (term) {
|
||||
syncTerminalSize(term);
|
||||
}
|
||||
}, 10);
|
||||
return () => clearTimeout(timer);
|
||||
}, [sidebarWidth, syncTerminalSize, terminalInstance]);
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-screen flex-col transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
|
||||
<div className={`border-b px-4 py-3 ${state.isDark ? "border-slate-800" : "border-slate-200 bg-white"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-sm font-medium ${state.isDark ? "text-slate-200" : "text-slate-700"}`}>
|
||||
{state.sessionInfo?.name || state.t("会话", "Session")} | {state.sessionInfo ? new Date(state.sessionInfo.started_at).toLocaleString() : ""}
|
||||
</span>
|
||||
<span className={`text-xs ${state.connectionTone}`}>
|
||||
{state.reconnectCountdown !== null ? state.t(`重连中 (${state.reconnectCountdown}s)`, `Reconnecting (${state.reconnectCountdown}s)`) : state.connectionLabel}
|
||||
</span>
|
||||
<span className={`text-xs ${state.networkProfileTone}`}>{state.networkProfileLabel}</span>
|
||||
{state.targetLatencyMs !== null ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.targetLatencyMs}ms</span>
|
||||
<div
|
||||
className={`flex h-4 w-[100px] items-end gap-px rounded px-1 ${state.isDark ? "bg-slate-900/60" : "bg-slate-200/70"}`}
|
||||
title={state.t("近30次网络延迟波动", "Latency trend (last 30 samples)")}
|
||||
aria-label={state.t("近30次网络延迟波动", "Latency trend (last 30 samples)")}
|
||||
>
|
||||
{state.latencyBarHeights.map((height, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`flex-1 rounded-sm ${height > 0 ? (state.isDark ? "bg-cyan-400/80" : "bg-cyan-600/80") : "bg-transparent"}`}
|
||||
style={height > 0 ? { height: `${height}px` } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{state.latencyHistoryMaxMs !== null ? (
|
||||
<span
|
||||
className={`text-[10px] whitespace-nowrap ${state.isDark ? "text-slate-500" : "text-slate-400"}`}
|
||||
title={state.t("近30次最大延迟", "Max latency in last 30 samples")}
|
||||
>
|
||||
{state.t("最大", "Max")} {state.latencyHistoryMaxMs}ms
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{state.sessionInfo?.note ? (
|
||||
<span className={`text-xs mt-1 ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>{state.sessionInfo.note}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.toggleLanguage}>
|
||||
{state.language === "en-US" ? "中文" : "EN"}
|
||||
</Button>
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={onBack}>
|
||||
{state.t("返回会话管理", "Back to Sessions")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.toggleTheme}>
|
||||
{state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleClear}>
|
||||
{state.t("清屏", "Clear")}
|
||||
</Button>
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleSelectAll}>
|
||||
{state.t("全选", "Select all")}
|
||||
</Button>
|
||||
{state.connectionState === "closed" && state.reconnectCountdown !== null ? (
|
||||
<Button variant="ghost" lightMode={!state.isDark} onClick={state.handleCancelReconnect}>
|
||||
{state.t("取消重连", "Cancel reconnect")}
|
||||
</Button>
|
||||
) : null}
|
||||
{state.connectionState === "closed" && state.reconnectCountdown === null ? (
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={state.handleReconnect}>
|
||||
{state.t("重连", "Reconnect")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={state.handleCopySelection}>
|
||||
{state.t("复制", "Copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 p-4 pb-2 min-h-0">
|
||||
<div
|
||||
ref={state.terminalRef}
|
||||
className={`h-full w-full rounded-lg border ${state.isDark ? "border-slate-800" : "border-slate-300 xterm-light"} ${state.sessionInfo?.enhanced_enabled ? "xterm-tmux-enhanced" : ""}`}
|
||||
tabIndex={-1}
|
||||
onClick={() => state.terminalInstance.current?.focus()}
|
||||
/>
|
||||
</div>
|
||||
<div className={`p-4 pt-2 ${state.isDark ? "border-t border-slate-800" : "border-t border-slate-200"}`} style={{ height: "280px" }}>
|
||||
{state.sessionId ? (
|
||||
<FileBrowser
|
||||
sessionId={state.sessionId}
|
||||
isDark={state.isDark}
|
||||
currentDir={state.currentDir}
|
||||
onFileSelect={state.setSelectedFilePath}
|
||||
networkProfile={state.sessionNetworkProfile}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={`w-1 cursor-col-resize transition-colors hidden lg:block ${
|
||||
isDragging
|
||||
? (state.isDark ? "bg-indigo-500" : "bg-indigo-400")
|
||||
: (state.isDark ? "bg-slate-800 hover:bg-slate-700" : "bg-slate-200 hover:bg-slate-300")
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
style={{ width: sidebarWidth }}
|
||||
className={`p-4 hidden lg:block flex-shrink-0 overflow-y-auto ${state.isDark ? "bg-slate-900/50 dark-scrollbar" : "bg-white border-l border-slate-200 light-scrollbar"}`}
|
||||
>
|
||||
{state.sessionId ? (
|
||||
<SystemMonitor
|
||||
sessionId={state.sessionId}
|
||||
isDark={state.isDark}
|
||||
selectedFilePath={state.selectedFilePath || undefined}
|
||||
networkProfile={state.sessionNetworkProfile}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{isDragging ? <div className="fixed inset-0 z-50 cursor-col-resize" /> : null}
|
||||
{state.sessionDisconnected?.disconnected && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-4 text-rose-400">{state.t("会话已断开", "Session disconnected")}</div>
|
||||
<div className="text-sm text-slate-400">{state.t("断开时间", "Disconnected at")}: {state.sessionDisconnected.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from "react";
|
||||
import { Button } from "../../components/Button";
|
||||
import { FileBrowser } from "../../components/FileBrowser";
|
||||
import { SystemMonitor } from "../../components/SystemMonitor";
|
||||
import type { TerminalSessionState } from "./useTerminalSession";
|
||||
|
||||
type TerminalMobileProps = {
|
||||
state: TerminalSessionState;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type MobileTab = "terminal" | "files" | "system";
|
||||
|
||||
export function TerminalMobile({ state, onBack }: TerminalMobileProps) {
|
||||
const [activeTab, setActiveTab] = React.useState<MobileTab>("terminal");
|
||||
const { syncTerminalSize, terminalInstance, scrollTerminal } = state;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab !== "terminal") {
|
||||
return;
|
||||
}
|
||||
const term = terminalInstance.current;
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
syncTerminalSize(term, { force: true });
|
||||
term.focus();
|
||||
}, [activeTab, syncTerminalSize, terminalInstance]);
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: "language",
|
||||
label: state.language === "en-US" ? "中文" : "EN",
|
||||
onClick: state.toggleLanguage,
|
||||
variant: "ghost" as const,
|
||||
},
|
||||
{
|
||||
key: "theme",
|
||||
label: state.isDark ? state.t("浅色", "Light") : state.t("深色", "Dark"),
|
||||
onClick: state.toggleTheme,
|
||||
variant: "ghost" as const,
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
label: state.t("复制", "Copy"),
|
||||
onClick: state.handleCopySelection,
|
||||
variant: "secondary" as const,
|
||||
},
|
||||
{
|
||||
key: "clear",
|
||||
label: state.t("清屏", "Clear"),
|
||||
onClick: state.handleClear,
|
||||
variant: "ghost" as const,
|
||||
},
|
||||
{
|
||||
key: "select",
|
||||
label: state.t("全选", "Select all"),
|
||||
onClick: state.handleSelectAll,
|
||||
variant: "ghost" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const shouldShowReconnect = state.connectionState === "closed" && state.reconnectCountdown === null;
|
||||
const shouldShowCancelReconnect = state.connectionState === "closed" && state.reconnectCountdown !== null;
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-screen flex-col transition-colors duration-300 ${state.isDark ? "bg-slate-950 text-slate-100 dark-scrollbar" : "bg-gray-100 text-slate-900 light-scrollbar"}`}>
|
||||
<div className={`border-b px-4 py-3 ${state.isDark ? "border-slate-800" : "border-slate-200 bg-white"}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className={`text-base font-semibold truncate ${state.isDark ? "text-slate-100" : "text-slate-800"}`}>
|
||||
{state.sessionInfo?.name || state.t("会话", "Session")}
|
||||
</div>
|
||||
<div className={`text-xs truncate ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.sessionInfo ? new Date(state.sessionInfo.started_at).toLocaleString() : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" lightMode={!state.isDark} onClick={onBack} className="px-3 py-2 text-xs whitespace-nowrap">
|
||||
{state.t("返回", "Back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`${state.connectionTone}`}>
|
||||
{state.reconnectCountdown !== null ? state.t(`重连中 (${state.reconnectCountdown}s)`, `Reconnecting (${state.reconnectCountdown}s)`) : state.connectionLabel}
|
||||
</span>
|
||||
<span className={`${state.networkProfileTone}`}>{state.networkProfileLabel}</span>
|
||||
{state.targetLatencyMs !== null ? (
|
||||
<span className={state.isDark ? "text-slate-500" : "text-slate-400"}>
|
||||
{state.targetLatencyMs}ms
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant={action.variant}
|
||||
lightMode={!state.isDark}
|
||||
onClick={action.onClick}
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
{shouldShowCancelReconnect ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.handleCancelReconnect}
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
{state.t("取消重连", "Cancel reconnect")}
|
||||
</Button>
|
||||
) : null}
|
||||
{shouldShowReconnect ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
lightMode={!state.isDark}
|
||||
onClick={state.handleReconnect}
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
>
|
||||
{state.t("重连", "Reconnect")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{state.sessionInfo?.note ? (
|
||||
<div className={`mt-2 text-xs ${state.isDark ? "text-slate-500" : "text-slate-400"}`}>
|
||||
{state.sessionInfo.note}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 pb-16">
|
||||
<div className={`${activeTab === "terminal" ? "flex" : "hidden"} h-full flex-col`}>
|
||||
<div className="flex-1 p-3 min-h-0 relative">
|
||||
<div
|
||||
ref={state.terminalRef}
|
||||
className={`h-full w-full rounded-lg border ${state.isDark ? "border-slate-800" : "border-slate-300 xterm-light"} ${state.sessionInfo?.enhanced_enabled ? "xterm-tmux-enhanced" : ""}`}
|
||||
tabIndex={-1}
|
||||
onClick={() => state.terminalInstance.current?.focus()}
|
||||
/>
|
||||
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollTerminal("up", "remote")}
|
||||
className={`h-10 w-10 rounded-full border text-base ${
|
||||
state.isDark
|
||||
? "border-slate-700 bg-slate-900/90 text-slate-200"
|
||||
: "border-slate-200 bg-white text-slate-700"
|
||||
}`}
|
||||
aria-label={state.t("向上滚动终端", "Scroll terminal up")}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollTerminal("down", "remote")}
|
||||
className={`h-10 w-10 rounded-full border text-base ${
|
||||
state.isDark
|
||||
? "border-slate-700 bg-slate-900/90 text-slate-200"
|
||||
: "border-slate-200 bg-white text-slate-700"
|
||||
}`}
|
||||
aria-label={state.t("向下滚动终端", "Scroll terminal down")}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${activeTab === "files" ? "flex" : "hidden"} h-full flex-col`}>
|
||||
<div className="flex-1 p-3 min-h-0">
|
||||
{state.sessionId ? (
|
||||
<FileBrowser
|
||||
sessionId={state.sessionId}
|
||||
isDark={state.isDark}
|
||||
currentDir={state.currentDir}
|
||||
onFileSelect={state.setSelectedFilePath}
|
||||
networkProfile={state.sessionNetworkProfile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${activeTab === "system" ? "flex" : "hidden"} h-full flex-col`}>
|
||||
<div className="flex-1 p-3 min-h-0 overflow-y-auto">
|
||||
{state.sessionId ? (
|
||||
<SystemMonitor
|
||||
sessionId={state.sessionId}
|
||||
isDark={state.isDark}
|
||||
selectedFilePath={state.selectedFilePath || undefined}
|
||||
networkProfile={state.sessionNetworkProfile}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`fixed bottom-0 left-0 right-0 z-50 border-t ${state.isDark ? "border-slate-800 bg-slate-950" : "border-slate-200 bg-white"}`}>
|
||||
<div className="flex">
|
||||
{[
|
||||
{ key: "terminal", label: state.t("终端", "Terminal") },
|
||||
{ key: "files", label: state.t("文件", "Files") },
|
||||
{ key: "system", label: state.t("系统", "System") },
|
||||
].map((tab) => {
|
||||
const active = activeTab === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key as MobileTab)}
|
||||
className={`flex-1 py-3 text-sm font-medium transition ${
|
||||
active
|
||||
? (state.isDark ? "text-indigo-300" : "text-indigo-600")
|
||||
: (state.isDark ? "text-slate-400" : "text-slate-500")
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.sessionDisconnected?.disconnected && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-4 text-rose-400">{state.t("会话已断开", "Session disconnected")}</div>
|
||||
<div className="text-sm text-slate-400">{state.t("断开时间", "Disconnected at")}: {state.sessionDisconnected.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { NetworkProfile } from "../../context/AppContext";
|
||||
|
||||
export type TargetNetworkProfile = "good" | "degraded" | "poor" | "unknown";
|
||||
|
||||
export const NETWORK_PROFILE_RANK: Record<NetworkProfile, number> = {
|
||||
good: 0,
|
||||
degraded: 1,
|
||||
poor: 2,
|
||||
};
|
||||
|
||||
export function pickWorseProfile(a: NetworkProfile, b: NetworkProfile): NetworkProfile {
|
||||
return NETWORK_PROFILE_RANK[a] >= NETWORK_PROFILE_RANK[b] ? a : b;
|
||||
}
|
||||
|
||||
export function normalizeTargetNetworkProfile(raw: string | undefined | null): TargetNetworkProfile {
|
||||
if (raw === "good" || raw === "degraded" || raw === "poor") {
|
||||
return raw;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export const darkTerminalTheme = {
|
||||
background: "#020617",
|
||||
foreground: "#e2e8f0",
|
||||
cursor: "#e2e8f0",
|
||||
cursorAccent: "#020617",
|
||||
selectionBackground: "#334155",
|
||||
black: "#1e293b",
|
||||
red: "#f87171",
|
||||
green: "#4ade80",
|
||||
yellow: "#facc15",
|
||||
blue: "#60a5fa",
|
||||
magenta: "#c084fc",
|
||||
cyan: "#22d3ee",
|
||||
white: "#f1f5f9",
|
||||
brightBlack: "#475569",
|
||||
brightRed: "#fca5a5",
|
||||
brightGreen: "#86efac",
|
||||
brightYellow: "#fde047",
|
||||
brightBlue: "#93c5fd",
|
||||
brightMagenta: "#d8b4fe",
|
||||
brightCyan: "#67e8f9",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
export const lightTerminalTheme = {
|
||||
background: "#ffffff",
|
||||
foreground: "#1e293b",
|
||||
cursor: "#1e293b",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#bfdbfe",
|
||||
black: "#1e293b",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
blue: "#2563eb",
|
||||
magenta: "#9333ea",
|
||||
cyan: "#0891b2",
|
||||
white: "#f1f5f9",
|
||||
brightBlack: "#64748b",
|
||||
brightRed: "#ef4444",
|
||||
brightGreen: "#22c55e",
|
||||
brightYellow: "#eab308",
|
||||
brightBlue: "#3b82f6",
|
||||
brightMagenta: "#a855f7",
|
||||
brightCyan: "#06b6d4",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
export const WHEEL_PIXEL_PER_LINE = 40;
|
||||
export const LATENCY_HISTORY_SIZE = 30;
|
||||
const ALT_SCREEN_SEQUENCE = /\x1b\[\?(?:1049|1047|47)[hl]/g;
|
||||
const HARD_RESET_SEQUENCE = /\x1bc/g;
|
||||
const CLEAR_SCROLLBACK_SEQUENCE = /\x1b\[(?:3|2;3)J/g;
|
||||
const SANITIZE_TARGET_SEQUENCES = [
|
||||
"\x1b[?1049h",
|
||||
"\x1b[?1049l",
|
||||
"\x1b[?1047h",
|
||||
"\x1b[?1047l",
|
||||
"\x1b[?47h",
|
||||
"\x1b[?47l",
|
||||
"\x1bc",
|
||||
"\x1b[3J",
|
||||
"\x1b[2;3J",
|
||||
];
|
||||
const SANITIZE_TARGET_MAX_LENGTH = SANITIZE_TARGET_SEQUENCES.reduce(
|
||||
(max, sequence) => Math.max(max, sequence.length),
|
||||
0
|
||||
);
|
||||
|
||||
export function normalizeWheelDeltaToLines(event: WheelEvent, terminalRows: number): number {
|
||||
let lineDelta = event.deltaY;
|
||||
if (event.deltaMode === 0) {
|
||||
lineDelta = lineDelta / WHEEL_PIXEL_PER_LINE;
|
||||
} else if (event.deltaMode === 2) {
|
||||
lineDelta = lineDelta * Math.max(terminalRows, 1);
|
||||
}
|
||||
if (!Number.isFinite(lineDelta)) {
|
||||
return 0;
|
||||
}
|
||||
return lineDelta;
|
||||
}
|
||||
|
||||
export function normalizeWheelDeltaToPixels(event: WheelEvent, viewportHeight: number): number {
|
||||
let pixelDelta = event.deltaY;
|
||||
if (event.deltaMode === 1) {
|
||||
pixelDelta = pixelDelta * WHEEL_PIXEL_PER_LINE;
|
||||
} else if (event.deltaMode === 2) {
|
||||
pixelDelta = pixelDelta * Math.max(viewportHeight, 1);
|
||||
}
|
||||
if (!Number.isFinite(pixelDelta)) {
|
||||
return 0;
|
||||
}
|
||||
return pixelDelta;
|
||||
}
|
||||
|
||||
function stripTerminalControlSequences(data: string): string {
|
||||
// 在增强 tmux 会话中屏蔽会重置本地滚动缓冲的控制序列,保证 xterm scrollback 可持续积累。
|
||||
return data
|
||||
.replace(ALT_SCREEN_SEQUENCE, "")
|
||||
.replace(HARD_RESET_SEQUENCE, "")
|
||||
.replace(CLEAR_SCROLLBACK_SEQUENCE, "");
|
||||
}
|
||||
|
||||
function splitSanitizePendingSuffix(data: string): { safeOutput: string; pendingSuffix: string } {
|
||||
const maxSuffixLength = Math.min(data.length, SANITIZE_TARGET_MAX_LENGTH - 1);
|
||||
let pendingSuffix = "";
|
||||
for (let length = 1; length <= maxSuffixLength; length += 1) {
|
||||
const suffix = data.slice(-length);
|
||||
const isSequencePrefix = SANITIZE_TARGET_SEQUENCES.some(
|
||||
(sequence) => sequence.length > suffix.length && sequence.startsWith(suffix)
|
||||
);
|
||||
if (isSequencePrefix) {
|
||||
pendingSuffix = suffix;
|
||||
}
|
||||
}
|
||||
if (!pendingSuffix) {
|
||||
return { safeOutput: data, pendingSuffix: "" };
|
||||
}
|
||||
return {
|
||||
safeOutput: data.slice(0, -pendingSuffix.length),
|
||||
pendingSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeTerminalOutputChunk(
|
||||
data: string,
|
||||
forceNormalBuffer: boolean,
|
||||
pendingSuffix: string
|
||||
): { output: string; pendingSuffix: string } {
|
||||
if (!forceNormalBuffer || data.length === 0) {
|
||||
return { output: data, pendingSuffix: "" };
|
||||
}
|
||||
const merged = pendingSuffix + data;
|
||||
const split = splitSanitizePendingSuffix(merged);
|
||||
return {
|
||||
output: stripTerminalControlSequences(split.safeOutput),
|
||||
pendingSuffix: split.pendingSuffix,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React from "react";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { useApp } from "../../context/AppContext";
|
||||
import { darkTerminalTheme, lightTerminalTheme } from "./terminalUtils";
|
||||
import { useTerminalSocket } from "./useTerminalSocket";
|
||||
import { useTerminalSessionInfo } from "./useTerminalSessionInfo";
|
||||
|
||||
function localizeText(language: string, zh: string, en: string): string {
|
||||
return language === "en-US" ? en : zh;
|
||||
}
|
||||
|
||||
export function useTerminalSession(sessionId?: string) {
|
||||
const { push } = useToast();
|
||||
const {
|
||||
isDark,
|
||||
toggleTheme,
|
||||
toggleLanguage,
|
||||
language,
|
||||
networkProfile: globalNetworkProfile,
|
||||
reportNetworkHint,
|
||||
clearNetworkHint,
|
||||
} = useApp();
|
||||
const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]);
|
||||
const [currentDir, setCurrentDir] = React.useState<string>("/");
|
||||
const [selectedFilePath, setSelectedFilePath] = React.useState<string | null>(null);
|
||||
const enhancedSessionRef = React.useRef(false);
|
||||
|
||||
const socketState = useTerminalSocket({
|
||||
sessionId,
|
||||
t,
|
||||
push,
|
||||
reportNetworkHint,
|
||||
clearNetworkHint,
|
||||
enhancedSessionRef,
|
||||
setCurrentDir,
|
||||
});
|
||||
|
||||
const sessionInfoState = useTerminalSessionInfo({
|
||||
sessionId,
|
||||
globalNetworkProfile,
|
||||
connectionState: socketState.connectionState,
|
||||
reconnectCountdown: socketState.reconnectCountdown,
|
||||
enhancedSessionRef,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedFilePath(null);
|
||||
setCurrentDir("/");
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const term = socketState.terminalInstance.current;
|
||||
if (!term) return;
|
||||
term.options.theme = isDark ? darkTerminalTheme : lightTerminalTheme;
|
||||
term.refresh(0, term.rows - 1);
|
||||
}, [isDark, socketState.terminalInstance]);
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
const term = socketState.terminalInstance.current;
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
term.clear();
|
||||
push(t("已清屏", "Screen cleared"));
|
||||
}, [push, t, socketState.terminalInstance]);
|
||||
|
||||
const handleSelectAll = React.useCallback(() => {
|
||||
const term = socketState.terminalInstance.current;
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
term.selectAll();
|
||||
push(t("已全选", "Selected all"));
|
||||
}, [push, t, socketState.terminalInstance]);
|
||||
|
||||
const handleCopySelection = React.useCallback(async () => {
|
||||
const termSelection = socketState.terminalInstance.current?.getSelection() ?? "";
|
||||
const domSelection = window.getSelection()?.toString() ?? "";
|
||||
const selection = termSelection || domSelection;
|
||||
if (!selection) {
|
||||
push(t("没有可复制内容", "No selection to copy"));
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackCopy = () => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = selection;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return copied;
|
||||
};
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
push(t("已复制选中内容", "Copied selection"));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 使用降级方案处理复制失败。
|
||||
}
|
||||
|
||||
if (fallbackCopy()) {
|
||||
push(t("已复制选中内容", "Copied selection"));
|
||||
return;
|
||||
}
|
||||
push(t("复制失败,请使用右键菜单", "Copy failed, use context menu"));
|
||||
}, [push, t, socketState.terminalInstance]);
|
||||
|
||||
const connectionLabel =
|
||||
socketState.connectionState === "open"
|
||||
? t("已连接", "Connected")
|
||||
: socketState.connectionState === "connecting"
|
||||
? t("连接中", "Connecting")
|
||||
: t("已断开", "Disconnected");
|
||||
const connectionTone =
|
||||
socketState.connectionState === "open"
|
||||
? "text-emerald-400"
|
||||
: socketState.connectionState === "connecting"
|
||||
? "text-amber-400"
|
||||
: "text-rose-400";
|
||||
const networkProfileLabel =
|
||||
sessionInfoState.sessionNetworkProfile === "good"
|
||||
? t("网络良好", "Network Good")
|
||||
: sessionInfoState.sessionNetworkProfile === "degraded"
|
||||
? t("网络波动", "Network Fluctuating")
|
||||
: t("弱网模式", "Poor Network Mode");
|
||||
const networkProfileTone =
|
||||
sessionInfoState.sessionNetworkProfile === "good"
|
||||
? "text-emerald-400"
|
||||
: sessionInfoState.sessionNetworkProfile === "degraded"
|
||||
? "text-amber-400"
|
||||
: "text-rose-400";
|
||||
|
||||
return {
|
||||
t,
|
||||
isDark,
|
||||
toggleTheme,
|
||||
toggleLanguage,
|
||||
language,
|
||||
sessionId,
|
||||
currentDir,
|
||||
selectedFilePath,
|
||||
setSelectedFilePath,
|
||||
terminalRef: socketState.terminalRef,
|
||||
terminalInstance: socketState.terminalInstance,
|
||||
fitAddon: socketState.fitAddon,
|
||||
connectionState: socketState.connectionState,
|
||||
reconnectCountdown: socketState.reconnectCountdown,
|
||||
autoReconnect: socketState.autoReconnect,
|
||||
setAutoReconnect: socketState.setAutoReconnect,
|
||||
syncTerminalSize: socketState.syncTerminalSize,
|
||||
scrollTerminal: socketState.scrollTerminal,
|
||||
handleReconnect: socketState.handleReconnect,
|
||||
handleCancelReconnect: socketState.handleCancelReconnect,
|
||||
sessionInfo: sessionInfoState.sessionInfo,
|
||||
targetLatencyMs: sessionInfoState.targetLatencyMs,
|
||||
latencyBarHeights: sessionInfoState.latencyBarHeights,
|
||||
latencyHistoryMaxMs: sessionInfoState.latencyHistoryMaxMs,
|
||||
sessionNetworkProfile: sessionInfoState.sessionNetworkProfile,
|
||||
sessionDisconnected: sessionInfoState.sessionDisconnected,
|
||||
connectionLabel,
|
||||
connectionTone,
|
||||
networkProfileLabel,
|
||||
networkProfileTone,
|
||||
handleClear,
|
||||
handleSelectAll,
|
||||
handleCopySelection,
|
||||
};
|
||||
}
|
||||
|
||||
export type TerminalSessionState = ReturnType<typeof useTerminalSession>;
|
||||
@@ -0,0 +1,162 @@
|
||||
import React from "react";
|
||||
import type { NetworkProfile } from "../../context/AppContext";
|
||||
import { getSession, Session } from "../../lib/api";
|
||||
import {
|
||||
LATENCY_HISTORY_SIZE,
|
||||
normalizeTargetNetworkProfile,
|
||||
pickWorseProfile,
|
||||
TargetNetworkProfile,
|
||||
} from "./terminalUtils";
|
||||
|
||||
type TerminalSessionInfoOptions = {
|
||||
sessionId?: string;
|
||||
globalNetworkProfile: NetworkProfile;
|
||||
connectionState: "connecting" | "open" | "closed";
|
||||
reconnectCountdown: number | null;
|
||||
enhancedSessionRef: React.MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
export function useTerminalSessionInfo({
|
||||
sessionId,
|
||||
globalNetworkProfile,
|
||||
connectionState,
|
||||
reconnectCountdown,
|
||||
enhancedSessionRef,
|
||||
}: TerminalSessionInfoOptions) {
|
||||
const [sessionInfo, setSessionInfo] = React.useState<Session | null>(null);
|
||||
const [targetLatencyHistory, setTargetLatencyHistory] = React.useState<number[]>([]);
|
||||
const [sessionDisconnected, setSessionDisconnected] = React.useState<{ disconnected: boolean; time: string } | null>(null);
|
||||
const sessionCheckInFlightRef = React.useRef(false);
|
||||
|
||||
const sessionTargetNetworkProfile = React.useMemo<TargetNetworkProfile>(
|
||||
() => normalizeTargetNetworkProfile(sessionInfo?.target_profile),
|
||||
[sessionInfo?.target_profile]
|
||||
);
|
||||
|
||||
const targetLatencyMs = React.useMemo(() => {
|
||||
if (!sessionInfo) return null;
|
||||
const avg = sessionInfo.target_avg_rtt_ms ?? null;
|
||||
const current = sessionInfo.target_rtt_ms ?? null;
|
||||
return avg ?? current;
|
||||
}, [sessionInfo]);
|
||||
|
||||
const mergedSessionNetworkProfile = React.useMemo<NetworkProfile>(() => {
|
||||
if (sessionTargetNetworkProfile === "unknown") {
|
||||
return globalNetworkProfile;
|
||||
}
|
||||
return pickWorseProfile(globalNetworkProfile, sessionTargetNetworkProfile);
|
||||
}, [globalNetworkProfile, sessionTargetNetworkProfile]);
|
||||
|
||||
const sessionNetworkProfile = React.useMemo<NetworkProfile>(() => {
|
||||
if (reconnectCountdown !== null || connectionState === "closed") {
|
||||
return "poor";
|
||||
}
|
||||
return mergedSessionNetworkProfile;
|
||||
}, [reconnectCountdown, connectionState, mergedSessionNetworkProfile]);
|
||||
|
||||
const sessionCheckIntervalMs =
|
||||
sessionNetworkProfile === "poor" ? 12000 : sessionNetworkProfile === "degraded" ? 6000 : 3000;
|
||||
|
||||
const latencyBarHeights = React.useMemo(() => {
|
||||
const history = targetLatencyHistory.slice(-LATENCY_HISTORY_SIZE);
|
||||
if (history.length === 0) {
|
||||
return new Array<number>(LATENCY_HISTORY_SIZE).fill(0);
|
||||
}
|
||||
const min = Math.min(...history);
|
||||
const max = Math.max(...history);
|
||||
const range = Math.max(max - min, 1);
|
||||
const bars = history.map((value) => {
|
||||
const ratio = (value - min) / range;
|
||||
return Math.round(3 + ratio * 13);
|
||||
});
|
||||
const padding = new Array<number>(Math.max(0, LATENCY_HISTORY_SIZE - bars.length)).fill(0);
|
||||
return [...padding, ...bars].slice(-LATENCY_HISTORY_SIZE);
|
||||
}, [targetLatencyHistory]);
|
||||
|
||||
const latencyHistoryMaxMs = React.useMemo(() => {
|
||||
const history = targetLatencyHistory.slice(-LATENCY_HISTORY_SIZE);
|
||||
if (history.length === 0) {
|
||||
return targetLatencyMs;
|
||||
}
|
||||
return Math.max(...history);
|
||||
}, [targetLatencyHistory, targetLatencyMs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTargetLatencyHistory([]);
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
enhancedSessionRef.current = sessionInfo?.enhanced_enabled === true;
|
||||
}, [sessionInfo?.enhanced_enabled, enhancedSessionRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (targetLatencyMs === null) {
|
||||
return;
|
||||
}
|
||||
setTargetLatencyHistory((prev) => [...prev.slice(-(LATENCY_HISTORY_SIZE - 1)), targetLatencyMs]);
|
||||
}, [targetLatencyMs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
getSession(sessionId)
|
||||
.then(setSessionInfo)
|
||||
.catch(() => {
|
||||
// 静默处理错误
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
|
||||
const scheduleNext = () => {
|
||||
if (cancelled) return;
|
||||
timer = window.setTimeout(checkSession, sessionCheckIntervalMs);
|
||||
};
|
||||
|
||||
const checkSession = async () => {
|
||||
if (sessionCheckInFlightRef.current) {
|
||||
scheduleNext();
|
||||
return;
|
||||
}
|
||||
sessionCheckInFlightRef.current = true;
|
||||
try {
|
||||
const session = await getSession(sessionId);
|
||||
setSessionInfo(session);
|
||||
if (session.status !== "active") {
|
||||
const disconnectedTime = session.disconnected_at ? new Date(session.disconnected_at) : new Date();
|
||||
const timeStr = `${disconnectedTime.getFullYear()}-${String(disconnectedTime.getMonth() + 1).padStart(2, "0")}-${String(disconnectedTime.getDate()).padStart(2, "0")} ${String(disconnectedTime.getHours()).padStart(2, "0")}:${String(disconnectedTime.getMinutes()).padStart(2, "0")}:${String(disconnectedTime.getSeconds()).padStart(2, "0")}`;
|
||||
setSessionDisconnected((prev) => {
|
||||
if (prev?.disconnected && prev.time === timeStr) {
|
||||
return prev;
|
||||
}
|
||||
return { disconnected: true, time: timeStr };
|
||||
});
|
||||
} else {
|
||||
setSessionDisconnected(null);
|
||||
}
|
||||
} catch {
|
||||
// 静默处理错误
|
||||
} finally {
|
||||
sessionCheckInFlightRef.current = false;
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
checkSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [sessionId, sessionCheckIntervalMs]);
|
||||
|
||||
return {
|
||||
sessionInfo,
|
||||
setSessionInfo,
|
||||
targetLatencyMs,
|
||||
latencyBarHeights,
|
||||
latencyHistoryMaxMs,
|
||||
sessionNetworkProfile,
|
||||
sessionDisconnected,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
import React from "react";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import type { NetworkProfile } from "../../context/AppContext";
|
||||
import { clearAuthStorage, getSession, getStoredToken, openTerminalSocket } from "../../lib/api";
|
||||
import {
|
||||
darkTerminalTheme,
|
||||
lightTerminalTheme,
|
||||
normalizeWheelDeltaToLines,
|
||||
normalizeWheelDeltaToPixels,
|
||||
sanitizeTerminalOutputChunk,
|
||||
} from "./terminalUtils";
|
||||
|
||||
type TerminalSocketOptions = {
|
||||
sessionId?: string;
|
||||
t: (zh: string, en: string) => string;
|
||||
push: (message: string) => void;
|
||||
reportNetworkHint: (profile: NetworkProfile, durationMs: number) => void;
|
||||
clearNetworkHint: () => void;
|
||||
enhancedSessionRef: React.MutableRefObject<boolean>;
|
||||
setCurrentDir: (dir: string) => void;
|
||||
};
|
||||
|
||||
type ResizeState = { socket: WebSocket; rows: number; cols: number };
|
||||
|
||||
export function useTerminalSocket({
|
||||
sessionId,
|
||||
t,
|
||||
push,
|
||||
reportNetworkHint,
|
||||
clearNetworkHint,
|
||||
enhancedSessionRef,
|
||||
setCurrentDir,
|
||||
}: TerminalSocketOptions) {
|
||||
const terminalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const terminalInstance = React.useRef<Terminal | null>(null);
|
||||
const fitAddon = React.useRef<FitAddon | null>(null);
|
||||
const socketRef = React.useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = React.useRef<number | null>(null);
|
||||
const reconnectCountdownIntervalRef = React.useRef<number | null>(null);
|
||||
const reconnectAttemptRef = React.useRef(0);
|
||||
const lastResizeStateRef = React.useRef<ResizeState | null>(null);
|
||||
const suppressMouseReportRef = React.useRef(false);
|
||||
const [connectionState, setConnectionState] = React.useState<"connecting" | "open" | "closed">("connecting");
|
||||
const [autoReconnect, setAutoReconnect] = React.useState(true);
|
||||
const [reconnectCountdown, setReconnectCountdown] = React.useState<number | null>(null);
|
||||
|
||||
const clearReconnectTimer = React.useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (reconnectCountdownIntervalRef.current) {
|
||||
clearInterval(reconnectCountdownIntervalRef.current);
|
||||
reconnectCountdownIntervalRef.current = null;
|
||||
}
|
||||
setReconnectCountdown(null);
|
||||
}, []);
|
||||
|
||||
const sendResize = React.useCallback((term: Terminal, options?: { force?: boolean }) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
const { rows, cols } = term;
|
||||
const lastResize = lastResizeStateRef.current;
|
||||
if (
|
||||
!options?.force &&
|
||||
lastResize &&
|
||||
lastResize.socket === socket &&
|
||||
lastResize.rows === rows &&
|
||||
lastResize.cols === cols
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "resize", rows, cols }));
|
||||
lastResizeStateRef.current = { socket, rows, cols };
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const syncTerminalSize = React.useCallback(
|
||||
(term: Terminal, options?: { force?: boolean }) => {
|
||||
fitAddon.current?.fit();
|
||||
sendResize(term, options);
|
||||
},
|
||||
[sendResize]
|
||||
);
|
||||
|
||||
const sendInput = React.useCallback(
|
||||
(data: string, notifyOnError: boolean = false) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
if (notifyOnError) {
|
||||
push(t("终端未连接", "Terminal is not connected"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
socket.send(JSON.stringify({ type: "input", data }));
|
||||
return true;
|
||||
},
|
||||
[push, t]
|
||||
);
|
||||
|
||||
const connectSocket = React.useCallback(
|
||||
(term: Terminal, isReconnect: boolean = false) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
const token = getStoredToken();
|
||||
if (!token) {
|
||||
clearAuthStorage();
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
clearReconnectTimer();
|
||||
setConnectionState("connecting");
|
||||
|
||||
if (isReconnect) {
|
||||
term.write(`\r\n\x1b[33m${t("正在重新连接...", "Reconnecting...")}\x1b[0m\r\n`);
|
||||
}
|
||||
|
||||
const socket = openTerminalSocket(sessionId);
|
||||
socketRef.current = socket;
|
||||
let sanitizePendingSuffix = "";
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (socketRef.current !== socket) {
|
||||
return;
|
||||
}
|
||||
const sanitized = sanitizeTerminalOutputChunk(
|
||||
String(event.data ?? ""),
|
||||
enhancedSessionRef.current,
|
||||
sanitizePendingSuffix
|
||||
);
|
||||
sanitizePendingSuffix = sanitized.pendingSuffix;
|
||||
const output = sanitized.output;
|
||||
term.write(output);
|
||||
|
||||
// 检测目录变化:解析提示符中的路径
|
||||
const text = output.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const promptMatch = text.match(/:([\/][^\s$#]+)[\s$#]/);
|
||||
if (promptMatch && promptMatch[1] && !promptMatch[1].startsWith("//")) {
|
||||
setCurrentDir(promptMatch[1]);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
if (socketRef.current !== socket) {
|
||||
return;
|
||||
}
|
||||
reconnectAttemptRef.current = 0;
|
||||
clearNetworkHint();
|
||||
syncTerminalSize(term, { force: true });
|
||||
[300, 1200].forEach((delay) => {
|
||||
window.setTimeout(() => {
|
||||
if (socketRef.current === socket && socket.readyState === WebSocket.OPEN) {
|
||||
syncTerminalSize(term);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
setConnectionState("open");
|
||||
term.focus();
|
||||
if (isReconnect) {
|
||||
push(t("重新连接成功", "Reconnected successfully"));
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (socketRef.current !== socket) {
|
||||
return;
|
||||
}
|
||||
setConnectionState("closed");
|
||||
reportNetworkHint("poor", 15000);
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
if (socketRef.current !== socket) {
|
||||
return;
|
||||
}
|
||||
setConnectionState("closed");
|
||||
if (!event.wasClean) {
|
||||
reportNetworkHint("poor", 25000);
|
||||
}
|
||||
|
||||
if (autoReconnect && !event.wasClean && reconnectAttemptRef.current < 5) {
|
||||
const schedule = [2, 4, 8, 16, 32];
|
||||
const attempt = reconnectAttemptRef.current + 1;
|
||||
const delaySeconds = schedule[attempt - 1] ?? schedule[schedule.length - 1];
|
||||
const delay = delaySeconds * 1000;
|
||||
reconnectAttemptRef.current = attempt;
|
||||
|
||||
const reconnectMessage = t(
|
||||
`连接已断开,${delaySeconds}秒后自动重连 (${reconnectAttemptRef.current}/5)...`,
|
||||
`Connection lost. Auto reconnect in ${delaySeconds}s (${reconnectAttemptRef.current}/5)...`
|
||||
);
|
||||
term.write(`\r\n\x1b[31m${reconnectMessage}\x1b[0m\r\n`);
|
||||
setReconnectCountdown(Math.round(delay / 1000));
|
||||
|
||||
let countdown = Math.round(delay / 1000);
|
||||
if (reconnectCountdownIntervalRef.current) {
|
||||
clearInterval(reconnectCountdownIntervalRef.current);
|
||||
reconnectCountdownIntervalRef.current = null;
|
||||
}
|
||||
reconnectCountdownIntervalRef.current = window.setInterval(() => {
|
||||
countdown -= 1;
|
||||
if (countdown > 0) {
|
||||
setReconnectCountdown(countdown);
|
||||
} else if (reconnectCountdownIntervalRef.current) {
|
||||
clearInterval(reconnectCountdownIntervalRef.current);
|
||||
reconnectCountdownIntervalRef.current = null;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
reconnectTimerRef.current = window.setTimeout(() => {
|
||||
if (reconnectCountdownIntervalRef.current) {
|
||||
clearInterval(reconnectCountdownIntervalRef.current);
|
||||
reconnectCountdownIntervalRef.current = null;
|
||||
}
|
||||
connectSocket(term, true);
|
||||
}, delay);
|
||||
} else if (!event.wasClean) {
|
||||
term.write(`\r\n\x1b[31m${t("连接已断开", "Connection lost")}\x1b[0m\r\n`);
|
||||
push(t("终端连接已断开", "Terminal connection closed"));
|
||||
}
|
||||
};
|
||||
},
|
||||
[sessionId, clearReconnectTimer, t, syncTerminalSize, push, reportNetworkHint, clearNetworkHint, autoReconnect, enhancedSessionRef, setCurrentDir]
|
||||
);
|
||||
|
||||
const handleReconnect = React.useCallback(() => {
|
||||
const term = terminalInstance.current;
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
reconnectAttemptRef.current = 0;
|
||||
socketRef.current?.close();
|
||||
connectSocket(term, true);
|
||||
}, [connectSocket]);
|
||||
|
||||
const handleCancelReconnect = React.useCallback(() => {
|
||||
clearReconnectTimer();
|
||||
setAutoReconnect(false);
|
||||
terminalInstance.current?.write(`\r\n\x1b[33m${t("已取消自动重连", "Auto reconnect canceled")}\x1b[0m\r\n`);
|
||||
}, [clearReconnectTimer, t]);
|
||||
|
||||
const connectSocketRef = React.useRef(connectSocket);
|
||||
const sendInputRef = React.useRef(sendInput);
|
||||
const syncTerminalSizeRef = React.useRef(syncTerminalSize);
|
||||
const scrollTerminal = React.useCallback((direction: "up" | "down", mode: "auto" | "remote" = "auto") => {
|
||||
const term = terminalInstance.current;
|
||||
const container = terminalRef.current;
|
||||
if (!term || !container) {
|
||||
return;
|
||||
}
|
||||
const lineDelta = direction === "up" ? -8 : 8;
|
||||
const sendTmuxWheelPage = (delta: number) => {
|
||||
if (delta === 0) {
|
||||
return;
|
||||
}
|
||||
const sequence = delta < 0 ? "\x1b[5~" : "\x1b[6~";
|
||||
sendInput(sequence);
|
||||
};
|
||||
const sendTmuxMouseWheel = (delta: number) => {
|
||||
if (delta === 0) {
|
||||
return;
|
||||
}
|
||||
const col = Math.max(1, Math.floor(term.cols / 2));
|
||||
const row = Math.max(1, Math.floor(term.rows / 2));
|
||||
const code = delta < 0 ? 64 : 65;
|
||||
sendInput(`\x1b[<${code};${col};${row}M`);
|
||||
};
|
||||
if (mode === "remote") {
|
||||
if (enhancedSessionRef.current && term.modes.mouseTrackingMode !== "none") {
|
||||
sendTmuxMouseWheel(lineDelta);
|
||||
return;
|
||||
}
|
||||
if (enhancedSessionRef.current) {
|
||||
term.scrollLines(lineDelta);
|
||||
return;
|
||||
}
|
||||
term.scrollLines(lineDelta);
|
||||
return;
|
||||
}
|
||||
const activeBuffer = term.buffer.active;
|
||||
const hasLocalScrollback = activeBuffer.type === "normal" && activeBuffer.baseY > 0;
|
||||
if (!hasLocalScrollback) {
|
||||
sendTmuxWheelPage(lineDelta);
|
||||
return;
|
||||
}
|
||||
const viewport = container.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (viewport) {
|
||||
const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
||||
const isAtTop = viewport.scrollTop <= 0;
|
||||
const isAtBottom = viewport.scrollTop >= maxScrollTop;
|
||||
const isEnhancedSession = enhancedSessionRef.current;
|
||||
if (isEnhancedSession && lineDelta < 0 && isAtTop) {
|
||||
sendTmuxWheelPage(lineDelta);
|
||||
return;
|
||||
}
|
||||
if (isEnhancedSession && lineDelta > 0 && isAtBottom) {
|
||||
sendTmuxWheelPage(lineDelta);
|
||||
return;
|
||||
}
|
||||
}
|
||||
term.scrollLines(lineDelta);
|
||||
}, [sendInput, enhancedSessionRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
connectSocketRef.current = connectSocket;
|
||||
sendInputRef.current = sendInput;
|
||||
syncTerminalSizeRef.current = syncTerminalSize;
|
||||
}, [connectSocket, sendInput, syncTerminalSize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const terminalContainer = terminalRef.current;
|
||||
if (!terminalContainer || !sessionId) {
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
let term: Terminal | null = null;
|
||||
let handleTerminalWheel: ((event: WheelEvent) => void) | null = null;
|
||||
let handleTerminalMouseDown: ((event: MouseEvent) => void) | null = null;
|
||||
let handleTerminalMouseUp: ((event: MouseEvent) => void) | null = null;
|
||||
let handleResize: (() => void) | null = null;
|
||||
|
||||
const initTerminal = async () => {
|
||||
try {
|
||||
const session = await getSession(sessionId);
|
||||
if (!disposed) {
|
||||
enhancedSessionRef.current = session.enhanced_enabled === true;
|
||||
}
|
||||
} catch {
|
||||
// 忽略初始化会话信息失败
|
||||
}
|
||||
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
const initialTheme = savedTheme === "light" ? "light" : "dark";
|
||||
|
||||
term = new Terminal({
|
||||
theme: initialTheme === "light" ? lightTerminalTheme : darkTerminalTheme,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
fontSize: 14,
|
||||
cursorBlink: true,
|
||||
scrollback: 100000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(terminalContainer);
|
||||
fit.fit();
|
||||
|
||||
terminalInstance.current = term;
|
||||
fitAddon.current = fit;
|
||||
|
||||
connectSocketRef.current(term);
|
||||
|
||||
const coreMouseService = (term as unknown as { _core?: { coreMouseService?: { triggerMouseEvent: (event: unknown) => boolean } } })._core
|
||||
?.coreMouseService as { triggerMouseEvent: (event: unknown) => boolean; _wsghTriggerMouseEvent?: (event: unknown) => boolean } | undefined;
|
||||
if (coreMouseService && !coreMouseService._wsghTriggerMouseEvent) {
|
||||
coreMouseService._wsghTriggerMouseEvent = coreMouseService.triggerMouseEvent.bind(coreMouseService);
|
||||
coreMouseService.triggerMouseEvent = (event) => {
|
||||
if (suppressMouseReportRef.current) {
|
||||
return false;
|
||||
}
|
||||
return coreMouseService._wsghTriggerMouseEvent?.(event) ?? false;
|
||||
};
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
sendInputRef.current(data);
|
||||
});
|
||||
|
||||
let wheelRemainder = 0;
|
||||
const sendTmuxWheelPage = (lineDelta: number) => {
|
||||
if (lineDelta === 0) {
|
||||
return;
|
||||
}
|
||||
const sequence = lineDelta < 0 ? "\x1b[5~" : "\x1b[6~";
|
||||
sendInputRef.current(sequence);
|
||||
};
|
||||
handleTerminalWheel = (event: WheelEvent) => {
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
if (term.modes.mouseTrackingMode !== "none") {
|
||||
return;
|
||||
}
|
||||
const isEnhancedSession = enhancedSessionRef.current;
|
||||
|
||||
const activeBuffer = term.buffer.active;
|
||||
const hasLocalScrollback = activeBuffer.type === "normal" && activeBuffer.baseY > 0;
|
||||
if (!hasLocalScrollback) {
|
||||
const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
|
||||
if (lineDelta === 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
sendTmuxWheelPage(lineDelta);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const viewport = terminalContainer.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (viewport) {
|
||||
const pixelDelta = normalizeWheelDeltaToPixels(event, viewport.clientHeight);
|
||||
const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
||||
const isAtTop = viewport.scrollTop <= 0;
|
||||
const isAtBottom = viewport.scrollTop >= maxScrollTop;
|
||||
const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
|
||||
|
||||
if (isEnhancedSession && pixelDelta < 0 && isAtTop) {
|
||||
sendTmuxWheelPage(lineDelta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnhancedSession && pixelDelta > 0 && isAtBottom) {
|
||||
sendTmuxWheelPage(lineDelta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pixelDelta !== 0) {
|
||||
const nextScrollTop = Math.min(maxScrollTop, Math.max(0, viewport.scrollTop + pixelDelta));
|
||||
viewport.scrollTop = nextScrollTop;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lineDelta = normalizeWheelDeltaToLines(event, term.rows);
|
||||
if (lineDelta === 0) {
|
||||
return;
|
||||
}
|
||||
wheelRemainder += lineDelta;
|
||||
const wholeLines = wheelRemainder > 0 ? Math.floor(wheelRemainder) : Math.ceil(wheelRemainder);
|
||||
if (wholeLines === 0) {
|
||||
return;
|
||||
}
|
||||
term.scrollLines(wholeLines);
|
||||
wheelRemainder -= wholeLines;
|
||||
};
|
||||
|
||||
terminalContainer.addEventListener("wheel", handleTerminalWheel, { capture: true, passive: false });
|
||||
handleTerminalMouseDown = (event: MouseEvent) => {
|
||||
if (!term || term.modes.mouseTrackingMode === "none") {
|
||||
return;
|
||||
}
|
||||
if (event.button === 0) {
|
||||
suppressMouseReportRef.current = true;
|
||||
const selectionService = (term as unknown as { _core?: { _selectionService?: { enable?: () => void; handleMouseDown?: (e: MouseEvent) => void } } })._core
|
||||
?._selectionService;
|
||||
if (selectionService?.enable && selectionService?.handleMouseDown) {
|
||||
selectionService.enable();
|
||||
selectionService.handleMouseDown(event);
|
||||
term.focus();
|
||||
event.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.button === 2) {
|
||||
suppressMouseReportRef.current = true;
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
handleTerminalMouseUp = () => {
|
||||
if (!suppressMouseReportRef.current) {
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
suppressMouseReportRef.current = false;
|
||||
}, 0);
|
||||
};
|
||||
terminalContainer.addEventListener("mousedown", handleTerminalMouseDown, { capture: true });
|
||||
document.addEventListener("mouseup", handleTerminalMouseUp, { capture: true });
|
||||
|
||||
handleResize = () => {
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
syncTerminalSizeRef.current(term);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
};
|
||||
|
||||
void initTerminal();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (handleTerminalWheel) {
|
||||
terminalContainer.removeEventListener("wheel", handleTerminalWheel, { capture: true });
|
||||
}
|
||||
if (handleTerminalMouseDown) {
|
||||
terminalContainer.removeEventListener("mousedown", handleTerminalMouseDown, { capture: true });
|
||||
}
|
||||
if (handleTerminalMouseUp) {
|
||||
document.removeEventListener("mouseup", handleTerminalMouseUp, { capture: true });
|
||||
}
|
||||
if (handleResize) {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
clearReconnectTimer();
|
||||
socketRef.current?.close();
|
||||
term?.dispose();
|
||||
};
|
||||
}, [sessionId, clearReconnectTimer, enhancedSessionRef]);
|
||||
|
||||
return {
|
||||
terminalRef,
|
||||
terminalInstance,
|
||||
fitAddon,
|
||||
connectionState,
|
||||
reconnectCountdown,
|
||||
autoReconnect,
|
||||
setAutoReconnect,
|
||||
syncTerminalSize,
|
||||
scrollTerminal,
|
||||
handleReconnect,
|
||||
handleCancelReconnect,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user