From efc94ab3c25a4daebc1e288a2e318e500c99bf6a Mon Sep 17 00:00:00 2001 From: beibeizi Date: Sun, 15 Mar 2026 18:06:26 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F=E5=B9=B6=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + backend/app/api/sessions.py | 59 ++++++++- backend/app/main.py | 2 + backend/app/models/session.py | 1 + backend/app/schemas/api.py | 5 + backend/app/services/bootstrap.py | 32 +++++ frontend/src/lib/api.ts | 14 +++ frontend/src/pages/Sessions.tsx | 196 ++++++++++++++++++++++++++---- 8 files changed, 282 insertions(+), 30 deletions(-) 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/backend/app/api/sessions.py b/backend/app/api/sessions.py index 6a5fc61..e156fa4 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, @@ -495,6 +507,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 +676,3 @@ async def terminal_socket( await websocket.close(code=1008) except Exception: pass - diff --git a/backend/app/main.py b/backend/app/main.py index 063eb28..0baa794 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) 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/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ea96147..35f12a3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -22,6 +22,7 @@ const CLIENT_TEXT_MAP: Record = { "重试连接失败": { 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 { + 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 { const response = await fetch(`${HTTP_BASE}/sessions/${sessionId}`, { method: "DELETE", diff --git a/frontend/src/pages/Sessions.tsx b/frontend/src/pages/Sessions.tsx index 9b418b2..cfc0167 100644 --- a/frontend/src/pages/Sessions.tsx +++ b/frontend/src/pages/Sessions.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { GripVertical } from "lucide-react"; import { Button } from "../components/Button"; import { Card } from "../components/Card"; import { ConfirmDialog } from "../components/ConfirmDialog"; @@ -22,6 +23,7 @@ import { retrySession, updateConnection, updateSessionNote, + updateSessionOrder, getStoredToken } from "../lib/api"; @@ -46,6 +48,30 @@ function normalizeTargetProfile(raw: string | undefined | null): "good" | "degra return "unknown"; } +function moveItem(list: T[], fromIndex: number, toIndex: number): T[] { + const next = [...list]; + const [item] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, item); + return next; +} + +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; + }); +} + export function Sessions() { const [connections, setConnections] = React.useState([]); const [sessions, setSessions] = React.useState([]); @@ -90,6 +116,8 @@ export function Sessions() { newPassword: "", confirmPassword: "", }); + const [draggingSessionId, setDraggingSessionId] = React.useState(null); + const [savingOrder, setSavingOrder] = React.useState(false); const [enhancePrompt, setEnhancePrompt] = React.useState<{ open: boolean; connectionId: number; @@ -102,6 +130,9 @@ export function Sessions() { const t = React.useCallback((zh: string, en: string) => localizeText(language, zh, en), [language]); const sessionPollInFlightRef = React.useRef(false); const sessionsRef = React.useRef([]); + const draggingRef = React.useRef(false); + const orderDirtyRef = React.useRef(false); + const orderedIdsRef = React.useRef([]); const targetOverviewProfile = React.useMemo<"good" | "degraded" | "poor" | "unknown">(() => { let current: "good" | "degraded" | "poor" | "unknown" = "unknown"; for (const session of sessions) { @@ -150,11 +181,23 @@ export function Sessions() { ); }, []); + 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 = async () => { try { const [connectionList, sessionList] = await Promise.all([listConnections(), listSessions()]); setConnections(connectionList); - setSessions(sessionList); + setSessions(preserveOrderIfDragging(sessionList)); setNoteDrafts((prev) => { const next = { ...prev }; sessionList.forEach((session) => { @@ -197,7 +240,7 @@ export function Sessions() { sessionPollInFlightRef.current = true; try { const sessionList = await listSessions(); - setSessions(sessionList); + setSessions(preserveOrderIfDragging(sessionList)); // 同步更新 noteDrafts 中已存在且未被本地修改的备注 setNoteDrafts((prev) => { const previousSessions = sessionsRef.current; @@ -282,7 +325,23 @@ export function Sessions() { return () => socket.close(); }, [push, reportNetworkHint, t]); - const filteredSessions = sessions.filter((session) => { + 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]); + + React.useEffect(() => { + orderedIdsRef.current = orderedSessions.map((session) => session.id); + }, [orderedSessions]); + + const filteredSessions = orderedSessions.filter((session) => { const matchStatus = filter === "all" || session.status === filter; const matchSearch = (session.name || "").toLowerCase().includes(search.toLowerCase()); return matchStatus && matchSearch; @@ -520,6 +579,67 @@ export function Sessions() { } }; + 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 + ))); + }, []); + + const handleDragStart = (sessionId: string, event: React.DragEvent) => { + if (savingOrder) { + event.preventDefault(); + return; + } + draggingRef.current = true; + orderDirtyRef.current = false; + setDraggingSessionId(sessionId); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", sessionId); + }; + + const handleDragOver = (sessionId: string, event: React.DragEvent) => { + if (!draggingSessionId || draggingSessionId === sessionId) { + return; + } + event.preventDefault(); + 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; + }; + + const handleDragEnd = async () => { + draggingRef.current = false; + setDraggingSessionId(null); + if (!orderDirtyRef.current) { + return; + } + orderDirtyRef.current = false; + setSavingOrder(true); + try { + await updateSessionOrder(orderedIdsRef.current); + push(t("排序已保存", "Order saved")); + } catch (error) { + push(error instanceof Error ? error.message : t("保存失败", "Save failed")); + await loadData(); + } finally { + setSavingOrder(false); + } + }; + const resetPasswordForm = React.useCallback(() => { setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" }); }, []); @@ -652,31 +772,54 @@ export function Sessions() { {filteredSessions.map((session) => { const noteValue = noteDrafts[session.id] ?? ""; return ( -
+
handleDragOver(session.id, event)} + onDrop={(event) => event.preventDefault()} + className={`rounded-lg border p-4 ${isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"}`} + >
-
-

{session.name}

-

- {session.username}@{session.host} -

-

{t("状态", "Status")}: {mapSessionStatus(session.status)}

- {session.enhanced_enabled ? ( -

- {t("增强持久化连接", "Enhanced persistent connection")} +

+ +
+

{session.name}

+

+ {session.username}@{session.host}

- ) : null} -

{t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}

-

{t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}

- {session.disconnected_at ? ( -

- {t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()} -

- ) : null} - {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? ( -

- {t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/3 -

- ) : null} +

{t("状态", "Status")}: {mapSessionStatus(session.status)}

+ {session.enhanced_enabled ? ( +

+ {t("增强持久化连接", "Enhanced persistent connection")} +

+ ) : null} +

{t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}

+

{t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}

+ {session.disconnected_at ? ( +

+ {t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()} +

+ ) : null} + {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? ( +

+ {t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/3 +

+ ) : null} +
{session.status === "active" ? ( @@ -1064,4 +1207,3 @@ export function Sessions() {
); } - From 805a2c105091bd5f84730dbc789ca5833b9783a5 Mon Sep 17 00:00:00 2001 From: beibeizi Date: Sun, 15 Mar 2026 18:24:26 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=89=8B=E6=9F=84=E4=B8=8E=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Sessions.tsx | 125 +++++++++++++++++++++----------- 1 file changed, 83 insertions(+), 42 deletions(-) diff --git a/frontend/src/pages/Sessions.tsx b/frontend/src/pages/Sessions.tsx index cfc0167..5903ffc 100644 --- a/frontend/src/pages/Sessions.tsx +++ b/frontend/src/pages/Sessions.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { GripVertical } from "lucide-react"; +import { ArrowUpDown } from "lucide-react"; import { Button } from "../components/Button"; import { Card } from "../components/Card"; import { ConfirmDialog } from "../components/ConfirmDialog"; @@ -133,6 +133,8 @@ export function Sessions() { const draggingRef = React.useRef(false); const orderDirtyRef = React.useRef(false); const orderedIdsRef = React.useRef([]); + const cardRefs = React.useRef(new Map()); + const previousPositionsRef = React.useRef(new Map()); const targetOverviewProfile = React.useMemo<"good" | "degraded" | "poor" | "unknown">(() => { let current: "good" | "degraded" | "poor" | "unknown" = "unknown"; for (const session of sessions) { @@ -347,6 +349,37 @@ export function Sessions() { return matchStatus && matchSearch; }); + React.useLayoutEffect(() => { + const nextPositions = new Map(); + 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 handleCreateConnection = async (event: React.FormEvent) => { event.preventDefault(); if (!form.name || !form.host || !form.username) { @@ -606,6 +639,7 @@ export function Sessions() { return; } event.preventDefault(); + event.dataTransfer.dropEffect = "move"; const visibleOrderIds = filteredSessions.map((session) => session.id); if (!visibleOrderIds.includes(draggingSessionId) || !visibleOrderIds.includes(sessionId)) { return; @@ -774,52 +808,59 @@ export function Sessions() { return (
{ + if (element) { + cardRefs.current.set(session.id, element); + } else { + cardRefs.current.delete(session.id); + } + }} onDragOver={(event) => handleDragOver(session.id, event)} onDrop={(event) => event.preventDefault()} - className={`rounded-lg border p-4 ${isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"}`} + className={`relative rounded-lg border p-4 pl-12 transition-transform duration-200 ease-out ${ + draggingSessionId === session.id ? "ring-2 ring-indigo-400/60" : "" + } ${isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"}`} > +
-
- -
-

{session.name}

-

- {session.username}@{session.host} +

+

{session.name}

+

+ {session.username}@{session.host} +

+

{t("状态", "Status")}: {mapSessionStatus(session.status)}

+ {session.enhanced_enabled ? ( +

+ {t("增强持久化连接", "Enhanced persistent connection")}

-

{t("状态", "Status")}: {mapSessionStatus(session.status)}

- {session.enhanced_enabled ? ( -

- {t("增强持久化连接", "Enhanced persistent connection")} -

- ) : null} -

{t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}

-

{t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}

- {session.disconnected_at ? ( -

- {t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()} -

- ) : null} - {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? ( -

- {t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/3 -

- ) : null} -
+ ) : null} +

{t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}

+

{t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}

+ {session.disconnected_at ? ( +

+ {t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()} +

+ ) : null} + {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? ( +

+ {t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/3 +

+ ) : null}
{session.status === "active" ? ( From df97f71cc51637fa601ac71eef3be662b286827f Mon Sep 17 00:00:00 2001 From: beibeizi Date: Sun, 15 Mar 2026 23:24:26 +0800 Subject: [PATCH 03/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=BC=9A=E8=AF=9D=E4=B8=A2=E5=A4=B1=E4=B8=8E?= =?UTF-8?q?SPA=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/system.py | 667 +++++++++++++++++++------------------- backend/app/main.py | 2 +- 2 files changed, 335 insertions(+), 334 deletions(-) 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/main.py b/backend/app/main.py index 0baa794..aa1e30a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -160,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 From a5b4f4bb5dcf55491c7c112b60926e435e0ec2d2 Mon Sep 17 00:00:00 2001 From: beibeizi Date: Mon, 16 Mar 2026 01:16:43 +0800 Subject: [PATCH 04/16] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=80=82?= =?UTF-8?q?=E9=85=8D=EF=BC=9A=E4=BC=9A=E8=AF=9D=E4=B8=8E=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/FileBrowser.tsx | 141 +- frontend/src/components/SystemMonitor.tsx | 8 +- frontend/src/lib/hooks/useIsMobile.ts | 27 + frontend/src/pages/Sessions.tsx | 1255 +---------------- frontend/src/pages/Terminal.tsx | 1032 +------------- .../sessions/SessionsConnectionsPanel.tsx | 236 ++++ .../src/pages/sessions/SessionsDesktop.tsx | 230 +++ .../src/pages/sessions/SessionsDialogs.tsx | 122 ++ .../src/pages/sessions/SessionsMobile.tsx | 223 +++ frontend/src/pages/sessions/sessionsUtils.ts | 47 + .../src/pages/sessions/usePasswordDialog.ts | 61 + .../src/pages/sessions/useSessionsOrdering.ts | 165 +++ .../src/pages/sessions/useSessionsPolling.ts | 128 ++ .../src/pages/sessions/useSessionsState.ts | 490 +++++++ .../src/pages/terminal/TerminalDesktop.tsx | 193 +++ .../src/pages/terminal/TerminalMobile.tsx | 211 +++ frontend/src/pages/terminal/terminalUtils.ts | 160 +++ .../src/pages/terminal/useTerminalSession.ts | 177 +++ .../pages/terminal/useTerminalSessionInfo.ts | 162 +++ .../src/pages/terminal/useTerminalSocket.ts | 465 ++++++ 20 files changed, 3202 insertions(+), 2331 deletions(-) create mode 100644 frontend/src/lib/hooks/useIsMobile.ts create mode 100644 frontend/src/pages/sessions/SessionsConnectionsPanel.tsx create mode 100644 frontend/src/pages/sessions/SessionsDesktop.tsx create mode 100644 frontend/src/pages/sessions/SessionsDialogs.tsx create mode 100644 frontend/src/pages/sessions/SessionsMobile.tsx create mode 100644 frontend/src/pages/sessions/sessionsUtils.ts create mode 100644 frontend/src/pages/sessions/usePasswordDialog.ts create mode 100644 frontend/src/pages/sessions/useSessionsOrdering.ts create mode 100644 frontend/src/pages/sessions/useSessionsPolling.ts create mode 100644 frontend/src/pages/sessions/useSessionsState.ts create mode 100644 frontend/src/pages/terminal/TerminalDesktop.tsx create mode 100644 frontend/src/pages/terminal/TerminalMobile.tsx create mode 100644 frontend/src/pages/terminal/terminalUtils.ts create mode 100644 frontend/src/pages/terminal/useTerminalSession.ts create mode 100644 frontend/src/pages/terminal/useTerminalSessionInfo.ts create mode 100644 frontend/src/pages/terminal/useTerminalSocket.ts diff --git a/frontend/src/components/FileBrowser.tsx b/frontend/src/components/FileBrowser.tsx index 41304a6..1c9c6c2 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 = @@ -1018,7 +1020,7 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo return (
+

{t("文件浏览", "File Browser")} @@ -1082,48 +1088,50 @@ 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`} /> - + +
)} {/* 左侧树形目录 */} -
+
{tree.map(node => renderTreeNode(node))}
@@ -1276,23 +1284,29 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo {error}
) : ( - +
- - + - - - + {!isCompact ? ( + <> + + + + + ) : null} @@ -1303,14 +1317,18 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo onClick={handleParentItemClick} onDoubleClick={handleNavigateToParent} > - - - - - + + {!isCompact ? ( + <> + + + + + ) : null} )} {files.map((file) => ( @@ -1327,27 +1345,31 @@ export function FileBrowser({ sessionId, isDark, currentDir, onFileSelect, netwo handleContextMenu(e, file); }} > - - - - - + {!isCompact ? ( + <> + + + + + ) : null} ))} {files.length === 0 && !filesLoading && ( - @@ -1475,4 +1497,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..5b3c930 100644 --- a/frontend/src/components/SystemMonitor.tsx +++ b/frontend/src/components/SystemMonitor.tsx @@ -65,12 +65,14 @@ 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 [stats, setStats] = React.useState(null); const [network, setNetwork] = React.useState(null); const [processes, setProcesses] = React.useState([]); @@ -235,7 +237,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf if (!stats) return null; return ( -
+

{t("系统状态", "System Status")} @@ -385,7 +387,7 @@ export function SystemMonitor({ sessionId, isDark, selectedFilePath, networkProf - - - -

-
- -
-
-
- setSearch(event.target.value)} - className={`max-w-xs ${isDark ? "" : "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400"}`} - /> -
- {[ - { value: "all", label: t("全部", "All") }, - { value: "active", label: t("在线", "Active") }, - { value: "disconnected", label: t("离线", "Disconnected") } - ].map((item) => ( - - ))} -
-
- -
- {filteredSessions.map((session) => { - const noteValue = noteDrafts[session.id] ?? ""; - return ( -
{ - if (element) { - cardRefs.current.set(session.id, element); - } else { - cardRefs.current.delete(session.id); - } - }} - onDragOver={(event) => handleDragOver(session.id, event)} - onDrop={(event) => event.preventDefault()} - className={`relative rounded-lg border p-4 pl-12 transition-transform duration-200 ease-out ${ - draggingSessionId === session.id ? "ring-2 ring-indigo-400/60" : "" - } ${isDark ? "border-slate-700 bg-slate-900/60" : "border-slate-200 bg-white shadow-sm"}`} - > - -
-
-

{session.name}

-

- {session.username}@{session.host} -

-

{t("状态", "Status")}: {mapSessionStatus(session.status)}

- {session.enhanced_enabled ? ( -

- {t("增强持久化连接", "Enhanced persistent connection")} -

- ) : null} -

{t("创建时间", "Created at")}: {new Date(session.started_at).toLocaleString()}

-

{t("最近活动", "Last activity")}: {new Date(session.last_activity).toLocaleString()}

- {session.disconnected_at ? ( -

- {t("断开时间", "Disconnected at")}: {new Date(session.disconnected_at).toLocaleString()} -

- ) : null} - {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? ( -

- {t("本轮重试", "Retry cycle")}: {session.retry_cycle_count ?? 0}/3 -

- ) : null} -
-
- {session.status === "active" ? ( - - ) : null} - {session.enhanced_enabled && session.status !== "active" && session.allow_auto_retry !== false ? ( - - ) : null} - -
-
-
- handleNoteChange(session.id, event.target.value)} - className={!isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""} - /> -
- {t("最多 1000 字", "Up to 1000 characters")} - {noteValue.trim() !== (session.note ?? "") ? ( - - ) : null} -
-
-
- ); - })} - {filteredSessions.length === 0 ? ( -
{t("暂无会话", "No sessions")}
- ) : null} -
-
- - -
- - {showCreateForm ? ( -
-
- {t( - "请确保该项目所运行的机器可以访问到目标连接。", - "Make sure the machine running this project can reach the target connection." - )} -
- setForm({ ...form, name: event.target.value })} - className={!isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""} - /> - setForm({ ...form, host: event.target.value })} - className={!isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""} - /> - setForm({ ...form, port: Number(event.target.value) })} - className={!isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""} - /> - setForm({ ...form, username: event.target.value })} - className={!isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""} - /> -
- - -
- {form.auth_type === "password" ? ( - setForm({ ...form, password: event.target.value })} - className={!isDark ? "border-slate-300 bg-white text-slate-900 placeholder:text-slate-400" : ""} - /> - ) : ( - <> -
{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")}