diff --git a/.gitignore b/.gitignore index 48b94f6..d8ed544 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ frontend/data/ .coverage coverage/ htmlcov/ + +# Tasks (local only) +tasks/ diff --git a/README.en.md b/README.en.md index 1b35cb7..7286d3f 100644 --- a/README.en.md +++ b/README.en.md @@ -11,6 +11,15 @@ One of its core capabilities is the persistent session design for long-running w ![WebSSH Gateway Overview](./docs/images/preview-overview.png) ![WebSSH Gateway Terminal and File Manager](./docs/images/preview-terminal.png) +## Mobile Experience + +A dedicated mobile layout provides touch-friendly interactions for terminal work, file management, and system monitoring in phone browsers. + +Mobile Overview +Mobile Terminal +Mobile File Management +Mobile System Status + ## 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 diff --git a/README.md b/README.md index 9501de4..cfe6563 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ WebSSH Gateway 是一个面向运维和开发场景的浏览器 SSH 网关。你 ![WebSSH Gateway 项目总览](./docs/images/preview-overview.png) ![WebSSH Gateway 终端与文件管理](./docs/images/preview-terminal.png) +## 移动端适配 + +移动端提供专门的触摸友好布局与交互,支持在手机浏览器中完成终端操作、文件管理与系统监控。 + +移动端概览 +移动端终端 +移动端文件管理 +移动端系统状态 + ## 社区版声明 - 本仓库为 **WebSSH Gateway 社区版(Community Edition)**。 @@ -24,6 +33,7 @@ WebSSH Gateway 是一个面向运维和开发场景的浏览器 SSH 网关。你 - 连接资产管理:支持保存主机连接信息,密码/私钥凭据加密存储。 - 会话生命周期管理:会话状态跟踪、断开重连、会话备注。 - 增强会话持久化:支持基于 `tmux` 的持久会话设计、增强保活与自动重试机制。 +- 移动端适配:面向手机浏览器的触摸友好操作与布局。 - 系统监控面板:CPU、内存、网络、进程、磁盘等实时查看。 - 文件管理能力:浏览、上传、下载、重命名、删除、权限修改、批量上传。 - 基础安全能力:JWT 鉴权、密码复杂度校验、登录失败锁定、请求追踪 ID 日志。 diff --git a/backend/app/api/sessions.py b/backend/app/api/sessions.py index 6a5fc61..f3872a3 100644 --- a/backend/app/api/sessions.py +++ b/backend/app/api/sessions.py @@ -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 - diff --git a/backend/app/api/system.py b/backend/app/api/system.py index 73c6f19..0ad4b5a 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -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) diff --git a/backend/app/core/state.py b/backend/app/core/state.py index 8765dec..67967b1 100644 --- a/backend/app/core/state.py +++ b/backend/app/core/state.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 063eb28..aa1e30a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 6eb1d5d..34ee6cd 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -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) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 0d8af9d..6b7a414 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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 diff --git a/backend/app/services/bootstrap.py b/backend/app/services/bootstrap.py index 232be3d..7f83b11 100644 --- a/backend/app/services/bootstrap.py +++ b/backend/app/services/bootstrap.py @@ -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) diff --git a/docs/images/mobile-fileManagement.jpg b/docs/images/mobile-fileManagement.jpg new file mode 100644 index 0000000..9bf9106 Binary files /dev/null and b/docs/images/mobile-fileManagement.jpg differ diff --git a/docs/images/mobile-overview.jpg b/docs/images/mobile-overview.jpg new file mode 100644 index 0000000..6883ee3 Binary files /dev/null and b/docs/images/mobile-overview.jpg differ diff --git a/docs/images/mobile-systemStatus.jpg b/docs/images/mobile-systemStatus.jpg new file mode 100644 index 0000000..3416a94 Binary files /dev/null and b/docs/images/mobile-systemStatus.jpg differ diff --git a/docs/images/mobile-terminal.jpg b/docs/images/mobile-terminal.jpg new file mode 100644 index 0000000..9d8071b Binary files /dev/null and b/docs/images/mobile-terminal.jpg differ diff --git a/frontend/src/components/FileBrowser.tsx b/frontend/src/components/FileBrowser.tsx index 41304a6..6f9149b 100644 --- a/frontend/src/components/FileBrowser.tsx +++ b/frontend/src/components/FileBrowser.tsx @@ -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(null); const directoryCacheRef = React.useRef>(new Map()); const treeRef = React.useRef(tree); const selectedPathRef = React.useRef(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) => { + 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 (
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 ( -
+

{t("文件浏览", "File Browser")} @@ -1082,48 +1124,127 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo

{/* 路径输入框 */} -
+
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`} /> - + +
+ {isCompact ? ( +
+ + {t("已选", "Selected")} {selectedCount} + + + + + + + + + + +
+ ) : null} +
)} {/* 左侧树形目录 */} -
+
{tree.map(node => renderTreeNode(node))}
@@ -1276,23 +1397,29 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo {error}
) : ( - +
- - + - - - + {!isCompact ? ( + <> + + + + + ) : null} @@ -1303,14 +1430,18 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo onClick={handleParentItemClick} onDoubleClick={handleNavigateToParent} > - - - - - + + {!isCompact ? ( + <> + + + + + ) : null} )} {files.map((file) => ( @@ -1327,27 +1458,31 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo handleContextMenu(e, file); }} > - - - - - + {!isCompact ? ( + <> + + + + + ) : null} ))} {files.length === 0 && !filesLoading && ( - @@ -1475,4 +1610,3 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo ); } - diff --git a/frontend/src/components/SystemMonitor.tsx b/frontend/src/components/SystemMonitor.tsx index 57f8d5a..6b61328 100644 --- a/frontend/src/components/SystemMonitor.tsx +++ b/frontend/src/components/SystemMonitor.tsx @@ -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(null); const [network, setNetwork] = React.useState(null); const [processes, setProcesses] = React.useState([]); const [disks, setDisks] = React.useState([]); const [fileContent, setFileContent] = React.useState(""); const [originalContent, setOriginalContent] = React.useState(""); + const [imageUrl, setImageUrl] = React.useState(null); + const [imageError, setImageError] = React.useState(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 ( +

+ {title} +

+ ); + } + return ( + + ); + }; const SortIcon = ({ field }: { field: SortField }) => { if (sortField !== field) return ↕; @@ -235,191 +365,240 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf if (!stats) return null; return ( -
+
-

- {t("系统状态", "System Status")} -

+ {renderSectionHeader("status", t("系统状态", "System Status"))} - {/* CPU */} -
-
- - {t(`CPU (${stats.cpu.count} 核)`, `CPU (${stats.cpu.count} Cores)`)} - - - {stats.cpu.percent.toFixed(1)}% - -
- -
- - {/* 内存 */} -
-
- - {t("内存", "Memory")} - - - {stats.memory.percent.toFixed(1)}% ({formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)}) - -
- -
- - {/* 交换区 */} -
-
- - {t("交换区", "Swap")} - - - {stats.swap.total > 0 - ? `${stats.swap.percent.toFixed(1)}% (${formatBytes(stats.swap.used)} / ${formatBytes(stats.swap.total)})` - : t("未启用", "Disabled") - } - -
- {stats.swap.total > 0 && ( - - )} -
- - {/* 网络速度 */} -
-
- - {t("网络", "Network")} - -
- - ↑ {network ? formatSpeed(network.upload_speed) : "-"} - - - ↓ {network ? formatSpeed(network.download_speed) : "-"} - + {!isCompact || !collapsedSections.status ? ( + <> + {/* CPU */} +
+
+ + {t(`CPU (${stats.cpu.count} 核)`, `CPU (${stats.cpu.count} Cores)`)} + + + {stats.cpu.percent.toFixed(1)}% + +
+
-
-
+ + {/* 内存 */} +
+
+ + {t("内存", "Memory")} + + + {stats.memory.percent.toFixed(1)}% ({formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)}) + +
+ +
+ + {/* 交换区 */} +
+
+ + {t("交换区", "Swap")} + + + {stats.swap.total > 0 + ? `${stats.swap.percent.toFixed(1)}% (${formatBytes(stats.swap.used)} / ${formatBytes(stats.swap.total)})` + : t("未启用", "Disabled") + } + +
+ {stats.swap.total > 0 && ( + + )} +
+ + {/* 网络速度 */} +
+
+ + {t("网络", "Network")} + +
+ + ↑ {network ? formatSpeed(network.upload_speed) : "-"} + + + ↓ {network ? formatSpeed(network.download_speed) : "-"} + +
+
+
+ + ) : null}
{/* 进程列表 */}
-

- {t("进程 TOP 5", "Top 5 Processes")} -

+ {renderSectionHeader("processes", t("进程 TOP 5", "Top 5 Processes"))} - {/* 表头 */} -
-
{t("进程名", "Process")}
-
handleSort("cpu")} - > - CPU -
-
handleSort("memory")} - > - {t("内存", "Memory")} -
-
- - {/* 进程表 */} -
- {sortedProcesses.map((proc) => ( -
-
- {proc.name} + {!isCompact || !collapsedSections.processes ? ( + <> + {/* 表头 */} +
+
{t("进程名", "Process")}
+
handleSort("cpu")} + > + CPU
-
- {proc.cpu_percent.toFixed(1)}% -
-
- {formatBytes(proc.memory_bytes)} +
handleSort("memory")} + > + {t("内存", "Memory")}
- ))} - {sortedProcesses.length === 0 && ( -
- {t("暂无数据", "No data")} + + {/* 进程表 */} +
+ {sortedProcesses.map((proc) => ( +
+
+ {proc.name} +
+
+ {proc.cpu_percent.toFixed(1)}% +
+
+ {formatBytes(proc.memory_bytes)} +
+
+ ))} + {sortedProcesses.length === 0 && ( +
+ {t("暂无数据", "No data")} +
+ )}
- )} -
+ + ) : null}
{/* 磁盘信息 */}
-

- {t("磁盘挂载", "Disk Mounts")} -

-
- {disks.map((disk) => ( -
-
- - {disk.mount} - - - {disk.percent.toFixed(1)}% ({formatBytes(disk.used)} / {formatBytes(disk.total)}) - + {renderSectionHeader("disks", t("磁盘挂载", "Disk Mounts"))} + {!isCompact || !collapsedSections.disks ? ( +
+ {disks.map((disk) => ( +
+
+ + {disk.mount} + + + {disk.percent.toFixed(1)}% ({formatBytes(disk.used)} / {formatBytes(disk.total)}) + +
+
- -
- ))} -
+ ))} +
+ ) : null}
{/* 文件预览 */} {selectedFilePath && (
-
-

- {t("文件预览", "File Preview")} -

- -
- {fileLoading ? ( -
- ⟳ {t("加载中...", "Loading...")} -
- ) : fileTooLarge ? ( -
-

{t(`文件过大 (${(fileSize / 1024 / 1024).toFixed(2)} MB)`, `File too large (${(fileSize / 1024 / 1024).toFixed(2)} MB)`)}

- -
+ {isCompact ? ( + renderSectionHeader("file", isImageFile ? t("图片预览", "Image Preview") : t("文件预览", "File Preview")) ) : ( -
{t("名称", "Name")} + {t("名称", "Name")} {t("大小", "Size")} -
setResizingCol("size")} /> + {!isCompact ? ( +
setResizingCol("size")} /> + ) : null}
- {t("权限", "Permissions")} -
setResizingCol("permissions")} /> -
- {t("用户", "Owner")} -
setResizingCol("owner")} /> -
{t("修改时间", "Modified Time")} + {t("权限", "Permissions")} +
setResizingCol("permissions")} /> +
+ {t("用户", "Owner")} +
setResizingCol("owner")} /> +
{t("修改时间", "Modified Time")}
+ 📁 ... ---{t("返回上一级", "Go to parent")}---{t("返回上一级", "Go to parent")}
+ {file.is_dir ? "📁" : "📄"} {file.name} + {file.is_dir ? "-" : formatFileSize(file.size)} - {file.permissions} - - {file.owner} - - {file.modified} - + {file.permissions} + + {file.owner} + + {file.modified} +
+ {t("空目录", "Empty directory")}