Please provide the specific file changes or a description of the modifications you have made so I can generate the commit message for you.
This commit is contained in:
@@ -1,103 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="SSH Manager">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<!-- 背景层:深色圆角矩形与发光效果 -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#07152f" />
|
||||
<stop offset="100%" stop-color="#0a1d3f" />
|
||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f172a" />
|
||||
<stop offset="100%" stop-color="#020617" />
|
||||
</linearGradient>
|
||||
<linearGradient id="neon" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3cc8ff" />
|
||||
<stop offset="55%" stop-color="#6ce7ff" />
|
||||
<stop offset="100%" stop-color="#7fffd4" />
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#17345f" />
|
||||
<stop offset="100%" stop-color="#112748" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="15" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
<filter id="softGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style>
|
||||
.trace { fill: none; stroke: #143b6f; stroke-linecap: round; stroke-width: 2.2; opacity: 0.75; }
|
||||
.trace2 { fill: none; stroke: #1a4f88; stroke-linecap: round; stroke-width: 1.6; opacity: 0.6; }
|
||||
.dot { fill: #1f78b4; opacity: 0.85; }
|
||||
.rack { fill: #17345f; stroke: url(#neon); stroke-width: 2.2; }
|
||||
.lightG { fill: #88ff96; }
|
||||
.lightY { fill: #ffd96b; }
|
||||
.lightR { fill: #ff7686; }
|
||||
</style>
|
||||
<linearGradient id="primaryGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.6" />
|
||||
<stop offset="100%" stop-color="#10b981" stop-opacity="0.3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="18" y="18" width="220" height="220" rx="34" fill="url(#bg)" />
|
||||
<rect x="18" y="18" width="220" height="220" rx="34" fill="none" stroke="#3cc8ff" stroke-width="4" filter="url(#glow)" />
|
||||
<!-- 外部圆角底框 -->
|
||||
<rect x="32" y="32" width="448" height="448" rx="100" ry="100" fill="url(#bgGradient)" stroke="#1e293b" stroke-width="8" />
|
||||
|
||||
<path class="trace" d="M36 62h28v-18h18v34h20" />
|
||||
<path class="trace" d="M36 96h42v18h22v-16h24" />
|
||||
<path class="trace" d="M36 160h24v22h34v-18h18" />
|
||||
<path class="trace" d="M36 196h54v-26h20" />
|
||||
<path class="trace" d="M220 58h-24v-14h-22v36h-14" />
|
||||
<path class="trace" d="M220 98h-38v14h-20v-18h-18" />
|
||||
<path class="trace" d="M220 156h-28v24h-36v-20h-16" />
|
||||
<path class="trace" d="M220 194h-42v-18h-24" />
|
||||
<circle class="dot" cx="64" cy="44" r="3" />
|
||||
<circle class="dot" cx="78" cy="114" r="3" />
|
||||
<circle class="dot" cx="60" cy="182" r="3" />
|
||||
<circle class="dot" cx="196" cy="44" r="3" />
|
||||
<circle class="dot" cx="182" cy="112" r="3" />
|
||||
<circle class="dot" cx="194" cy="180" r="3" />
|
||||
<!-- 内部光晕点缀 -->
|
||||
<circle cx="256" cy="256" r="180" fill="url(#primaryGlow)" filter="url(#glow)" />
|
||||
<rect x="64" y="64" width="384" height="384" rx="80" ry="80" fill="#0f172a" opacity="0.85" />
|
||||
|
||||
<g filter="url(#softGlow)">
|
||||
<rect x="72" y="74" width="114" height="96" rx="12" fill="url(#panel)" stroke="#8af3ff" stroke-width="3" />
|
||||
<rect x="82" y="86" width="94" height="72" rx="10" fill="#101c35" stroke="#53d9ff" stroke-opacity="0.7" />
|
||||
<circle cx="90" cy="80" r="3.4" class="lightR" />
|
||||
<circle cx="98" cy="80" r="3.4" class="lightY" />
|
||||
<circle cx="106" cy="80" r="3.4" class="lightG" />
|
||||
<text x="94" y="103" font-size="8.5" fill="#baf7ff" font-family="IBM Plex Mono, monospace">$ ssh -i keys/mgmt.pem</text>
|
||||
<text x="94" y="114" font-size="7.8" fill="#8bcfe2" font-family="IBM Plex Mono, monospace">admin@prod-1.net</text>
|
||||
<text x="92" y="132" font-size="34" fill="#b4ffff" font-family="IBM Plex Sans, sans-serif" font-weight="700">$</text>
|
||||
<rect x="108" y="125" width="24" height="6" rx="3" fill="url(#neon)" />
|
||||
<!-- 终端符号: >_ -->
|
||||
<g transform="translate(130, 160)" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- 箭头 > -->
|
||||
<path d="M 20 20 L 120 90 L 20 160" fill="none" stroke="#3b82f6" stroke-width="40" />
|
||||
|
||||
<!-- 下划线 _ -->
|
||||
<line x1="140" y1="180" x2="240" y2="180" stroke="#10b981" stroke-width="36" />
|
||||
</g>
|
||||
|
||||
<g transform="translate(127 148)" filter="url(#glow)">
|
||||
<circle r="47" fill="rgba(10,29,63,0.5)" stroke="#5ce8ff" stroke-width="1.8" />
|
||||
<circle r="36" fill="none" stroke="#2fc7ff" stroke-width="1.4" stroke-opacity="0.5" />
|
||||
<circle cx="-45" cy="0" r="3.4" fill="#55efff" />
|
||||
<circle cx="45" cy="0" r="3.4" fill="#55efff" />
|
||||
<circle cx="0" cy="-45" r="3.4" fill="#55efff" />
|
||||
<circle cx="0" cy="45" r="3.4" fill="#55efff" />
|
||||
|
||||
<rect class="rack" x="-27" y="-24" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="-27" y="-10" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="-27" y="4" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="-27" y="18" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="5" y="-24" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="5" y="-10" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="5" y="4" width="22" height="10" rx="3" />
|
||||
<rect class="rack" x="5" y="18" width="22" height="10" rx="3" />
|
||||
|
||||
<circle cx="-10" cy="-19" r="1.6" class="lightG" />
|
||||
<circle cx="-14" cy="-5" r="1.6" class="lightY" />
|
||||
<circle cx="-8" cy="9" r="1.6" class="lightG" />
|
||||
<circle cx="-12" cy="23" r="1.6" class="lightR" />
|
||||
<circle cx="22" cy="-19" r="1.6" class="lightY" />
|
||||
<circle cx="18" cy="-5" r="1.6" class="lightG" />
|
||||
<circle cx="24" cy="9" r="1.6" class="lightY" />
|
||||
<circle cx="20" cy="23" r="1.6" class="lightG" />
|
||||
|
||||
<path class="trace2" d="M-16 -14v-10c0-7 5-12 12-12h8c7 0 12 5 12 12v10" />
|
||||
<path class="trace2" d="M-16 18v10c0 7 5 12 12 12h8c7 0 12-5 12-12v-10" />
|
||||
<path class="trace2" d="M-5 -14v28" />
|
||||
<path class="trace2" d="M5 -14v28" />
|
||||
<rect x="-6" y="32" width="12" height="8" rx="2.2" fill="#17345f" stroke="#79f7ff" stroke-width="1.4" />
|
||||
</g>
|
||||
<!-- 顶部状态指示灯 (红黄绿) -->
|
||||
<circle cx="110" cy="110" r="12" fill="#ef4444" />
|
||||
<circle cx="150" cy="110" r="12" fill="#eab308" />
|
||||
<circle cx="190" cy="110" r="12" fill="#10b981" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,16 +1,33 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CheckCircle2, Clock, Command, Copy, Play, RefreshCw, Server, Terminal, XCircle } from 'lucide-react'
|
||||
import Modal from './Modal'
|
||||
import { executeBatchCommand } from '../services/connections'
|
||||
import type { BatchCommandResult, Connection } from '../types'
|
||||
import type { BatchCommandResult, Connection, ConnectionReachabilityStatus, ConnectionStatusItem } from '../types'
|
||||
|
||||
const reachabilityCopy: Record<ConnectionReachabilityStatus, { dot: string; label: string; text: string }> = {
|
||||
unknown: { dot: 'bg-slate-500', label: '未检测', text: 'text-slate-500' },
|
||||
checking: { dot: 'bg-amber-400', label: '检测中', text: 'text-amber-300' },
|
||||
online: { dot: 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.5)]', label: '在线', text: 'text-emerald-500/80' },
|
||||
offline: { dot: 'bg-slate-500', label: '离线', text: 'text-slate-500' },
|
||||
}
|
||||
|
||||
export default function BatchCommandModal({
|
||||
open,
|
||||
connections,
|
||||
connectionStatuses,
|
||||
connectionStatusDetails,
|
||||
onRefreshStatuses,
|
||||
statusError,
|
||||
statusLoading,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
connections: Connection[]
|
||||
connectionStatuses: Record<number, ConnectionReachabilityStatus>
|
||||
connectionStatusDetails: Record<number, ConnectionStatusItem>
|
||||
onRefreshStatuses: () => Promise<void>
|
||||
statusError: string | null
|
||||
statusLoading: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>(() => connections.slice(0, 2).map((item) => item.id))
|
||||
@@ -28,14 +45,35 @@ export default function BatchCommandModal({
|
||||
[results, selectedIds.length],
|
||||
)
|
||||
|
||||
const onlineIds = useMemo(
|
||||
() => connections.filter((item) => connectionStatuses[item.id] === 'online').map((item) => item.id),
|
||||
[connectionStatuses, connections],
|
||||
)
|
||||
|
||||
const offlineCount = useMemo(
|
||||
() => connections.filter((item) => connectionStatuses[item.id] === 'offline').length,
|
||||
[connectionStatuses, connections],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.filter(
|
||||
(id) =>
|
||||
connections.some((item) => item.id === id) &&
|
||||
connectionStatuses[id] === 'online',
|
||||
),
|
||||
)
|
||||
}, [connectionStatuses, connections])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
async function handleRun() {
|
||||
if (!selectedIds.length || !command.trim()) return
|
||||
const runnableIds = selectedIds.filter((id) => connectionStatuses[id] === 'online')
|
||||
if (!runnableIds.length || !command.trim()) return
|
||||
setRunning(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await executeBatchCommand(selectedIds, command)
|
||||
const response = await executeBatchCommand(runnableIds, command)
|
||||
setResults(response.data.results)
|
||||
} catch (err) {
|
||||
const message =
|
||||
@@ -53,21 +91,45 @@ export default function BatchCommandModal({
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3 text-sm text-slate-300">
|
||||
<span>目标主机 ({selectedIds.length})</span>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<button className="text-blue-400" onClick={() => setSelectedIds(connections.map((item) => item.id))}>
|
||||
<button className="text-blue-400 disabled:text-slate-600" disabled={statusLoading} onClick={() => setSelectedIds(onlineIds)}>
|
||||
全选
|
||||
</button>
|
||||
<button className="text-slate-400" onClick={() => setSelectedIds([])}>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 text-slate-400 transition hover:text-slate-200 disabled:text-slate-600"
|
||||
disabled={statusLoading || connections.length === 0}
|
||||
onClick={() => {
|
||||
void onRefreshStatuses().catch(() => undefined)
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={12} className={statusLoading ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-slate-800 px-4 py-2 text-xs text-slate-500">
|
||||
在线 {onlineIds.length} / 离线 {offlineCount} / 总数 {connections.length}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-auto p-2">
|
||||
{connections.map((connection) => {
|
||||
const checked = selectedIds.includes(connection.id)
|
||||
const reachability = connectionStatuses[connection.id] ?? 'unknown'
|
||||
const reachabilityMeta = reachabilityCopy[reachability]
|
||||
const statusDetail = connectionStatusDetails[connection.id]
|
||||
const disabled = reachability !== 'online'
|
||||
return (
|
||||
<label key={connection.id} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2 transition hover:bg-slate-800">
|
||||
<label
|
||||
key={connection.id}
|
||||
title={statusDetail?.message || reachabilityMeta.label}
|
||||
className={`flex items-center gap-2 rounded-xl px-3 py-2 transition ${
|
||||
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
onChange={() =>
|
||||
setSelectedIds((prev) =>
|
||||
@@ -75,8 +137,14 @@ export default function BatchCommandModal({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Server size={14} className={checked ? 'text-blue-400' : 'text-slate-500'} />
|
||||
<span className="truncate text-sm text-slate-300">{connection.name}</span>
|
||||
<div className="relative shrink-0">
|
||||
<Server size={14} className={checked && !disabled ? 'text-blue-400' : 'text-slate-500'} />
|
||||
<span className={`absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-slate-900 ${reachabilityMeta.dot}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-slate-300">{connection.name}</div>
|
||||
<div className={`text-[10px] ${reachabilityMeta.text}`}>{reachabilityMeta.label}</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
@@ -100,7 +168,7 @@ export default function BatchCommandModal({
|
||||
</div>
|
||||
<button
|
||||
className="flex h-[46px] items-center gap-2 rounded-xl bg-blue-600 px-6 text-white transition hover:bg-blue-500 disabled:opacity-60"
|
||||
disabled={running || selectedIds.length === 0}
|
||||
disabled={running || statusLoading || selectedIds.filter((id) => connectionStatuses[id] === 'online').length === 0}
|
||||
onClick={() => void handleRun()}
|
||||
>
|
||||
{running ? <RefreshCw size={16} className="animate-spin" /> : <Play size={16} fill="currentColor" />}
|
||||
@@ -116,11 +184,12 @@ export default function BatchCommandModal({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-auto p-4">
|
||||
{statusError ? <div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">{statusError}</div> : null}
|
||||
{error ? <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||
{!results.length && !error ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-slate-500">
|
||||
<Command size={48} className="mb-3 text-slate-700" />
|
||||
<p>请在上方输入命令并点击执行</p>
|
||||
<p>{statusLoading ? '正在检测主机状态...' : '请在上方输入命令并点击执行'}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{results.map((result) => (
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Connection, ConnectionCreateRequest } from '../types'
|
||||
import type {
|
||||
Connection,
|
||||
ConnectionCreateRequest,
|
||||
ConnectionModalSubmitPayload,
|
||||
SessionTreeFolderOption,
|
||||
} from '../types'
|
||||
import Modal from './Modal'
|
||||
|
||||
const emptyForm: ConnectionCreateRequest = {
|
||||
@@ -18,15 +23,20 @@ const emptyForm: ConnectionCreateRequest = {
|
||||
export default function ConnectionModal({
|
||||
open,
|
||||
connection,
|
||||
folderOptions,
|
||||
initialTargetFolderId,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
connection?: Connection | null
|
||||
folderOptions: SessionTreeFolderOption[]
|
||||
initialTargetFolderId: string | null
|
||||
onClose: () => void
|
||||
onSubmit: (payload: ConnectionCreateRequest) => Promise<void>
|
||||
onSubmit: (payload: ConnectionModalSubmitPayload) => Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<ConnectionCreateRequest>(emptyForm)
|
||||
const [targetFolderId, setTargetFolderId] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -48,8 +58,9 @@ export default function ConnectionModal({
|
||||
} else {
|
||||
setForm(emptyForm)
|
||||
}
|
||||
setTargetFolderId(initialTargetFolderId)
|
||||
setError(null)
|
||||
}, [connection, open])
|
||||
}, [connection, initialTargetFolderId, open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -63,8 +74,8 @@ export default function ConnectionModal({
|
||||
await onSubmit({
|
||||
...form,
|
||||
port: Number(form.port || 22),
|
||||
targetFolderId,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || '保存连接失败'
|
||||
@@ -97,38 +108,79 @@ export default function ConnectionModal({
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm text-slate-300">连接名称</span>
|
||||
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.name} onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} />
|
||||
<input
|
||||
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
placeholder="例如:prod-web-01"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm text-slate-300">主机 IP</span>
|
||||
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.host} onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))} />
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm text-slate-300">端口</span>
|
||||
<input type="number" className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.port ?? 22} onChange={(e) => setForm((prev) => ({ ...prev, port: Number(e.target.value) }))} />
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm text-slate-300">用户名</span>
|
||||
<input className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white" value={form.username} onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))} />
|
||||
<span className="text-sm text-slate-300">分组 / 父文件夹</span>
|
||||
<select
|
||||
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
value={targetFolderId ?? '__ROOT__'}
|
||||
onChange={(event) => setTargetFolderId(event.target.value === '__ROOT__' ? null : event.target.value)}
|
||||
>
|
||||
<option value="__ROOT__">根目录</option>
|
||||
{folderOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{`${'— '.repeat(option.depth)}${option.name}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4 border-t border-slate-800 pt-5">
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1.4fr)_120px_minmax(0,1fr)]">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm text-slate-300">主机 IP</span>
|
||||
<input
|
||||
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm text-slate-300">端口</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
value={form.port ?? 22}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, port: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm text-slate-300">用户名</span>
|
||||
<input
|
||||
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[28px] border border-slate-800 bg-slate-950/40 p-5">
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium text-slate-100">认证方式</div>
|
||||
<div className="mt-1 text-xs text-slate-500">保留现有密码、私钥和一键免密部署能力。</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className={`rounded-xl border px-4 py-2 text-sm ${isPassword ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||
className={`rounded-2xl border px-4 py-2 text-sm ${isPassword ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'PASSWORD_BOOTSTRAP' : 'NONE' }))}
|
||||
>
|
||||
密码认证
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-xl border px-4 py-2 text-sm ${form.authType === 'PRIVATE_KEY' ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||
className={`rounded-2xl border px-4 py-2 text-sm ${form.authType === 'PRIVATE_KEY' ? 'border-blue-500 bg-blue-500/10 text-blue-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, authType: 'PRIVATE_KEY', setupMode: 'NONE' }))}
|
||||
>
|
||||
私钥认证
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-xl border px-4 py-2 text-sm ${useBootstrap ? 'border-emerald-500 bg-emerald-500/10 text-emerald-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||
className={`rounded-2xl border px-4 py-2 text-sm ${useBootstrap ? 'border-emerald-500 bg-emerald-500/10 text-emerald-300' : 'border-slate-700 bg-slate-900 text-slate-300'}`}
|
||||
onClick={() => setForm((prev) => ({ ...prev, authType: 'PASSWORD', setupMode: prev.setupMode === 'PASSWORD_BOOTSTRAP' ? 'NONE' : 'PASSWORD_BOOTSTRAP' }))}
|
||||
>
|
||||
一键免密部署
|
||||
@@ -140,7 +192,7 @@ export default function ConnectionModal({
|
||||
<span className="text-sm text-slate-300">{useBootstrap ? '引导密码' : '密码'}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white"
|
||||
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
value={useBootstrap ? form.bootstrapPassword ?? '' : form.password ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((prev) =>
|
||||
@@ -155,7 +207,7 @@ export default function ConnectionModal({
|
||||
<span className="text-sm text-slate-300">私钥内容</span>
|
||||
<textarea
|
||||
rows={6}
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 font-mono text-sm text-white"
|
||||
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 font-mono text-sm text-white outline-none focus:border-blue-500"
|
||||
value={form.privateKey ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, privateKey: e.target.value }))}
|
||||
/>
|
||||
@@ -164,7 +216,7 @@ export default function ConnectionModal({
|
||||
<span className="text-sm text-slate-300">私钥口令</span>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white"
|
||||
className="w-full rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
value={form.passphrase ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, passphrase: e.target.value }))}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SessionTreeFolderOption } from '../types'
|
||||
import Modal from './Modal'
|
||||
|
||||
export default function FolderModal({
|
||||
open,
|
||||
folderOptions,
|
||||
initialParentId,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
folderOptions: SessionTreeFolderOption[]
|
||||
initialParentId: string | null
|
||||
onClose: () => void
|
||||
onSubmit: (payload: { name: string; parentId: string | null }) => Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [parentId, setParentId] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName('')
|
||||
setParentId(initialParentId)
|
||||
setSubmitting(false)
|
||||
setError(null)
|
||||
}, [initialParentId, open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
async function handleSave() {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
setError('请输入文件夹名称')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onSubmit({ name: trimmedName, parentId })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || '创建文件夹失败'
|
||||
setError(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新建文件夹"
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-lg"
|
||||
footer={
|
||||
<>
|
||||
<button className="rounded-xl bg-slate-700 px-4 py-2 text-sm text-slate-200 transition hover:bg-slate-600" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl bg-blue-600 px-4 py-2 text-sm text-white transition hover:bg-blue-500 disabled:opacity-60"
|
||||
disabled={submitting}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{submitting ? '创建中...' : '创建文件夹'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">文件夹名称</span>
|
||||
<input
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
placeholder="例如:生产环境"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">父级目录(可选)</span>
|
||||
<select
|
||||
className="w-full rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-white outline-none focus:border-blue-500"
|
||||
value={parentId ?? '__ROOT__'}
|
||||
onChange={(event) => setParentId(event.target.value === '__ROOT__' ? null : event.target.value)}
|
||||
>
|
||||
<option value="__ROOT__">根目录</option>
|
||||
{folderOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{`${'— '.repeat(option.depth)}${option.name}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{error ? <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</div> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import { ChevronDown, ChevronRight, Folder, Server } from 'lucide-react'
|
||||
import type { Connection } from '../types'
|
||||
import type { Connection, ConnectionReachabilityStatus } from '../types'
|
||||
import type { BuiltTreeNode } from '../lib/utils'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface SessionTreeContextMenuPayload {
|
||||
nodeId: string
|
||||
nodeType: 'folder' | 'connection'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface SessionTreeProps {
|
||||
nodes: BuiltTreeNode[]
|
||||
activeConnectionId: number | null
|
||||
connectionStatuses?: Record<number, ConnectionReachabilityStatus>
|
||||
openConnectionIds: number[]
|
||||
selectedNodeId: string | null
|
||||
search: string
|
||||
onSelectNode: (nodeId: string) => void
|
||||
onToggleFolder: (nodeId: string) => void
|
||||
onOpenConnection: (connection: Connection) => void
|
||||
onOpenConnection: (connection: Connection, nodeId: string) => void
|
||||
onContextMenu: (payload: SessionTreeContextMenuPayload) => void
|
||||
}
|
||||
|
||||
const statusCopy: Record<ConnectionReachabilityStatus, { dot: string; label: string; text: string }> = {
|
||||
unknown: { dot: 'bg-slate-500', label: '未检测', text: 'text-slate-500' },
|
||||
checking: { dot: 'bg-amber-400', label: '检测中', text: 'text-amber-300' },
|
||||
online: { dot: 'bg-emerald-500', label: '在线', text: 'text-emerald-400' },
|
||||
offline: { dot: 'bg-red-500', label: '离线', text: 'text-red-400' },
|
||||
}
|
||||
|
||||
function matches(node: BuiltTreeNode, term: string): boolean {
|
||||
@@ -22,26 +42,55 @@ function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
activeConnectionId,
|
||||
connectionStatuses,
|
||||
openConnectionIds,
|
||||
selectedNodeId,
|
||||
search,
|
||||
onSelectNode,
|
||||
onToggleFolder,
|
||||
onOpenConnection,
|
||||
onContextMenu,
|
||||
}: {
|
||||
node: BuiltTreeNode
|
||||
depth: number
|
||||
activeConnectionId: number | null
|
||||
connectionStatuses?: Record<number, ConnectionReachabilityStatus>
|
||||
openConnectionIds: number[]
|
||||
selectedNodeId: string | null
|
||||
search: string
|
||||
onSelectNode: (nodeId: string) => void
|
||||
onToggleFolder: (nodeId: string) => void
|
||||
onOpenConnection: (connection: Connection) => void
|
||||
onOpenConnection: (connection: Connection, nodeId: string) => void
|
||||
onContextMenu: (payload: SessionTreeContextMenuPayload) => void
|
||||
}) {
|
||||
function handleContextMenu(event: MouseEvent<HTMLButtonElement>, nodeType: 'folder' | 'connection') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onContextMenu({
|
||||
nodeId: node.id,
|
||||
nodeType,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}
|
||||
|
||||
if (!matches(node, search)) return null
|
||||
|
||||
if (node.type === 'folder') {
|
||||
const expanded = node.expanded ?? true
|
||||
const selected = selectedNodeId === node.id
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm text-slate-300 transition hover:bg-slate-800"
|
||||
onClick={() => onToggleFolder(node.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm transition',
|
||||
selected ? 'bg-slate-800 text-slate-100 ring-1 ring-inset ring-slate-700' : 'text-slate-300 hover:bg-slate-800',
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelectNode(node.id)
|
||||
onToggleFolder(node.id)
|
||||
}}
|
||||
onContextMenu={(event) => handleContextMenu(event, 'folder')}
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} className="text-slate-500" /> : <ChevronRight size={14} className="text-slate-500" />}
|
||||
@@ -56,9 +105,14 @@ function TreeNode({
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeConnectionId={activeConnectionId}
|
||||
connectionStatuses={connectionStatuses}
|
||||
openConnectionIds={openConnectionIds}
|
||||
selectedNodeId={selectedNodeId}
|
||||
search={search}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleFolder={onToggleFolder}
|
||||
onOpenConnection={onOpenConnection}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -69,20 +123,48 @@ function TreeNode({
|
||||
|
||||
if (!node.connection) return null
|
||||
|
||||
const selected = selectedNodeId === node.id
|
||||
const active = activeConnectionId === node.connection.id
|
||||
const opened = openConnectionIds.includes(node.connection.id)
|
||||
const reachability = connectionStatuses?.[node.connection.id] ?? 'unknown'
|
||||
const reachabilityMeta = statusCopy[reachability]
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-2 rounded-xl px-2 py-2 text-left text-sm transition',
|
||||
active ? 'bg-blue-600/20 text-blue-300' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-100',
|
||||
active
|
||||
? 'bg-blue-600/20 text-blue-200 ring-1 ring-inset ring-blue-500/40'
|
||||
: selected
|
||||
? 'bg-slate-800 text-slate-100 ring-1 ring-inset ring-slate-700'
|
||||
: opened
|
||||
? 'text-emerald-200 hover:bg-slate-800'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-100',
|
||||
)}
|
||||
onDoubleClick={() => onOpenConnection(node.connection!)}
|
||||
onClick={() => onOpenConnection(node.connection!)}
|
||||
onDoubleClick={() => onOpenConnection(node.connection!, node.id)}
|
||||
onClick={() => {
|
||||
onSelectNode(node.id)
|
||||
if (opened && !active) {
|
||||
onOpenConnection(node.connection!, node.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => handleContextMenu(event, 'connection')}
|
||||
style={{ paddingLeft: 24 + depth * 16 }}
|
||||
>
|
||||
<Server size={14} className={active ? 'text-blue-300' : 'text-emerald-400'} />
|
||||
<span className="flex-1 truncate">{node.connection.name}</span>
|
||||
<span className="hidden rounded bg-slate-950 px-1.5 py-0.5 text-[10px] text-slate-500 group-hover:inline-block">双击连接</span>
|
||||
<div className="relative shrink-0">
|
||||
<Server size={14} className={active ? 'text-blue-300' : opened ? 'text-emerald-400' : 'text-slate-500'} />
|
||||
<span className={cn('absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-slate-900', reachabilityMeta.dot)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{node.connection.name}</div>
|
||||
<div className={cn('text-[10px]', reachabilityMeta.text)}>{reachabilityMeta.label}</div>
|
||||
</div>
|
||||
{active ? (
|
||||
<span className="rounded-full border border-blue-500/30 bg-blue-500/10 px-2 py-0.5 text-[10px] text-blue-200">当前</span>
|
||||
) : opened ? (
|
||||
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-200">已打开</span>
|
||||
) : (
|
||||
<span className="hidden rounded bg-slate-950 px-1.5 py-0.5 text-[10px] text-slate-500 group-hover:inline-block">双击连接</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Terminal } from 'xterm'
|
||||
import { Activity, Folder, HardDrive, RefreshCw, Search, TerminalSquare } from 'lucide-react'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { getMetrics } from '../services/monitor'
|
||||
import { formatBytes } from '../lib/utils'
|
||||
import type { Connection, MonitorMetrics, WorkspaceLayout } from '../types'
|
||||
import type { Connection, TerminalConnectionStatus } from '../types'
|
||||
|
||||
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
||||
|
||||
export default function TerminalPane({
|
||||
connection,
|
||||
active,
|
||||
layout,
|
||||
visible,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
onLayoutChange,
|
||||
onStatusChange,
|
||||
}: {
|
||||
connection: Connection
|
||||
active: boolean
|
||||
layout: WorkspaceLayout
|
||||
visible: boolean
|
||||
fontSize: number
|
||||
fontFamily: string
|
||||
onLayoutChange: (layout: WorkspaceLayout) => void
|
||||
onStatusChange?: (status: TerminalConnectionStatus) => void
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
@@ -30,9 +25,28 @@ export default function TerminalPane({
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null)
|
||||
const reconnectTimerRef = useRef<number | null>(null)
|
||||
const monitorTimerRef = useRef<number | null>(null)
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'reconnecting' | 'error'>('connecting')
|
||||
const [metrics, setMetrics] = useState<MonitorMetrics>({})
|
||||
const visibleRef = useRef(visible)
|
||||
const syncViewportRef = useRef<() => void>(() => {})
|
||||
const [status, setStatus] = useState<TerminalConnectionStatus>('connecting')
|
||||
|
||||
visibleRef.current = visible
|
||||
|
||||
syncViewportRef.current = () => {
|
||||
if (!visibleRef.current) return
|
||||
|
||||
const term = termRef.current
|
||||
if (!term) return
|
||||
|
||||
fitAddonRef.current?.fit()
|
||||
|
||||
const ws = wsRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.(status)
|
||||
}, [onStatusChange, status])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -52,8 +66,25 @@ export default function TerminalPane({
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fit
|
||||
term.open(containerRef.current!)
|
||||
fit.fit()
|
||||
term.focus()
|
||||
if (visible) {
|
||||
syncViewportRef.current()
|
||||
term.focus()
|
||||
}
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
const resizeDisposable = term.onResize(() => {
|
||||
syncViewportRef.current()
|
||||
})
|
||||
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
syncViewportRef.current()
|
||||
})
|
||||
resizeObserverRef.current.observe(containerRef.current!)
|
||||
|
||||
const connect = () => {
|
||||
const token = localStorage.getItem('token')
|
||||
@@ -69,8 +100,7 @@ export default function TerminalPane({
|
||||
wsRef.current = ws
|
||||
ws.onopen = () => {
|
||||
setStatus('connected')
|
||||
fit.fit()
|
||||
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
syncViewportRef.current()
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
term.write(typeof event.data === 'string' ? event.data : '')
|
||||
@@ -83,95 +113,38 @@ export default function TerminalPane({
|
||||
ws.onerror = () => {
|
||||
setStatus('error')
|
||||
}
|
||||
const disposable = term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
const resizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
})
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
fit.fit()
|
||||
})
|
||||
resizeObserverRef.current.observe(containerRef.current!)
|
||||
return () => {
|
||||
disposable.dispose()
|
||||
resizeDisposable.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const disposeTerminalEvents = connect()
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (monitorTimerRef.current) window.clearInterval(monitorTimerRef.current)
|
||||
if (reconnectTimerRef.current) window.clearTimeout(reconnectTimerRef.current)
|
||||
resizeObserverRef.current?.disconnect()
|
||||
wsRef.current?.close()
|
||||
disposeTerminalEvents?.()
|
||||
dataDisposable.dispose()
|
||||
resizeDisposable.dispose()
|
||||
term.dispose()
|
||||
}
|
||||
}, [connection.id, fontFamily, fontSize])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const response = await getMetrics(connection.id)
|
||||
setMetrics(response.data)
|
||||
} catch {
|
||||
// keep terminal usable even when monitor fails
|
||||
}
|
||||
}
|
||||
void fetchMetrics()
|
||||
monitorTimerRef.current = window.setInterval(fetchMetrics, 5000)
|
||||
if (!visible) return
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
syncViewportRef.current()
|
||||
termRef.current?.focus()
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (monitorTimerRef.current) window.clearInterval(monitorTimerRef.current)
|
||||
window.cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [active, connection.id])
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-slate-900">
|
||||
<div className="flex h-10 items-center justify-between border-b border-slate-800 bg-slate-800/80 px-4 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<Activity size={14} className="text-emerald-400" />
|
||||
CPU: {metrics.cpuUsage ?? '-'}%
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive size={14} className="text-blue-400" />
|
||||
MEM: {formatBytes(metrics.memUsed ?? null)} / {formatBytes(metrics.memTotal ?? null)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-emerald-400">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
{status === 'connected' ? 'WebSocket 已连接' : status === 'reconnecting' ? '连接中断,重试中' : '连接建立中'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={`rounded p-1.5 ${layout === 'terminal' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('terminal')}>
|
||||
<TerminalSquare size={16} />
|
||||
</button>
|
||||
<button className={`rounded p-1.5 ${layout === 'sftp' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('sftp')}>
|
||||
<Folder size={16} />
|
||||
</button>
|
||||
<button className={`rounded p-1.5 ${layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400'}`} onClick={() => onLayoutChange('split')}>
|
||||
<Activity size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group relative flex-1 bg-black p-2 font-mono">
|
||||
<div className="flex-1 bg-black p-2 font-mono">
|
||||
<div ref={containerRef} className="h-full w-full overflow-hidden rounded-2xl border border-slate-900 bg-black" />
|
||||
<div className="absolute right-6 top-4 flex rounded-xl border border-slate-700 bg-slate-900/80 opacity-0 transition group-hover:opacity-100">
|
||||
<button className="p-2 text-slate-400 transition hover:text-white" onClick={() => termRef.current?.clear()}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button className="p-2 text-slate-400 transition hover:text-white" onClick={() => termRef.current?.focus()}>
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+324
-58
@@ -1,4 +1,9 @@
|
||||
import type { Connection, SessionTreeLayoutPayload, SessionTreeNodePayload } from '../types'
|
||||
import type {
|
||||
Connection,
|
||||
SessionTreeFolderOption,
|
||||
SessionTreeLayoutPayload,
|
||||
SessionTreeNodePayload,
|
||||
} from '../types'
|
||||
|
||||
export interface BuiltTreeNode {
|
||||
id: string
|
||||
@@ -48,14 +53,63 @@ export function formatSftpPermissions(entry: { directory: boolean }) {
|
||||
return entry.directory ? 'drwxr-xr-x' : '-rw-r--r--'
|
||||
}
|
||||
|
||||
function sortBuiltNodes(items: BuiltTreeNode[]) {
|
||||
items.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
||||
items.forEach((item) => sortBuiltNodes(item.children))
|
||||
}
|
||||
|
||||
function sortConnections(connections: Connection[]) {
|
||||
return connections.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
function generateNodeId(prefix: 'folder' | 'connection', suffix: string) {
|
||||
return `${prefix}-${suffix}`
|
||||
}
|
||||
|
||||
function createConnectionNode(
|
||||
connection: Connection,
|
||||
parentId: string | null,
|
||||
order: number,
|
||||
now: number,
|
||||
): SessionTreeNodePayload {
|
||||
return {
|
||||
id: generateNodeId('connection', String(connection.id)),
|
||||
type: 'connection',
|
||||
name: connection.name,
|
||||
parentId,
|
||||
order,
|
||||
connectionId: connection.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
function isFolderId(layout: SessionTreeLayoutPayload | null, nodeId: string | null) {
|
||||
if (!nodeId || !layout) return false
|
||||
return layout.nodes.some((node) => node.id === nodeId && node.type === 'folder')
|
||||
}
|
||||
|
||||
function normalizeParentId(layout: SessionTreeLayoutPayload | null, parentId: string | null) {
|
||||
return isFolderId(layout, parentId) ? parentId : null
|
||||
}
|
||||
|
||||
function nextSiblingOrder(nodes: SessionTreeNodePayload[], parentId: string | null, excludeNodeId?: string) {
|
||||
const siblingOrders = nodes
|
||||
.filter((node) => node.id !== excludeNodeId && (node.parentId ?? null) === parentId)
|
||||
.map((node) => node.order)
|
||||
|
||||
return (siblingOrders.length ? Math.max(...siblingOrders) : -1) + 1
|
||||
}
|
||||
|
||||
function findConnectionNode(layout: SessionTreeLayoutPayload | null, connectionId: number) {
|
||||
return layout?.nodes.find((node) => node.type === 'connection' && node.connectionId === connectionId) ?? null
|
||||
}
|
||||
|
||||
export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connections: Connection[]): BuiltTreeNode[] {
|
||||
const connectionMap = new Map(connections.map((item) => [item.id, item]))
|
||||
const nodes = layout?.nodes ?? []
|
||||
if (nodes.length === 0) {
|
||||
return connections
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((connection, index) => ({
|
||||
return sortConnections(connections).map((connection, index) => ({
|
||||
id: `connection-${connection.id}`,
|
||||
type: 'connection',
|
||||
name: connection.name,
|
||||
@@ -71,7 +125,7 @@ export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connec
|
||||
built.set(node.id, {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
name: node.name,
|
||||
name: node.type === 'connection' && node.connectionId ? connectionMap.get(node.connectionId)?.name ?? node.name : node.name,
|
||||
order: node.order,
|
||||
parentId: node.parentId,
|
||||
expanded: node.expanded,
|
||||
@@ -92,24 +146,75 @@ export function buildSessionTree(layout: SessionTreeLayoutPayload | null, connec
|
||||
}
|
||||
}
|
||||
|
||||
const sortNodes = (items: BuiltTreeNode[]) => {
|
||||
items.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
||||
items.forEach((item) => sortNodes(item.children))
|
||||
}
|
||||
sortNodes(roots)
|
||||
sortBuiltNodes(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
export function collectConnectionIds(nodes: BuiltTreeNode[]) {
|
||||
const ids: number[] = []
|
||||
const walk = (list: BuiltTreeNode[]) => {
|
||||
list.forEach((node) => {
|
||||
if (node.type === 'connection' && node.connection) ids.push(node.connection.id)
|
||||
if (node.children.length > 0) walk(node.children)
|
||||
})
|
||||
export function createInitialSessionTreeLayout(connections: Connection[]): SessionTreeLayoutPayload {
|
||||
const now = Date.now()
|
||||
return {
|
||||
nodes: sortConnections(connections).map((connection, index) => createConnectionNode(connection, null, index, now)),
|
||||
sortMode: 'manual',
|
||||
}
|
||||
}
|
||||
|
||||
export function syncSessionTreeLayout(
|
||||
layout: SessionTreeLayoutPayload | null,
|
||||
connections: Connection[],
|
||||
): SessionTreeLayoutPayload {
|
||||
if (!layout) {
|
||||
return createInitialSessionTreeLayout(connections)
|
||||
}
|
||||
|
||||
const connectionMap = new Map(connections.map((connection) => [connection.id, connection]))
|
||||
const seenConnectionIds = new Set<number>()
|
||||
const retainedNodes: SessionTreeNodePayload[] = []
|
||||
|
||||
for (const node of layout.nodes) {
|
||||
if (node.type === 'folder') {
|
||||
retainedNodes.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!node.connectionId || seenConnectionIds.has(node.connectionId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const connection = connectionMap.get(node.connectionId)
|
||||
if (!connection) {
|
||||
continue
|
||||
}
|
||||
|
||||
retainedNodes.push({
|
||||
...node,
|
||||
name: connection.name,
|
||||
})
|
||||
seenConnectionIds.add(node.connectionId)
|
||||
}
|
||||
|
||||
const normalizedLayout: SessionTreeLayoutPayload = {
|
||||
nodes: retainedNodes.map((node) => ({
|
||||
...node,
|
||||
parentId: node.parentId && isFolderId({ nodes: retainedNodes }, node.parentId) ? node.parentId : null,
|
||||
})),
|
||||
sortMode: layout.sortMode ?? 'manual',
|
||||
}
|
||||
|
||||
const missingConnections = sortConnections(connections).filter((connection) => !seenConnectionIds.has(connection.id))
|
||||
if (missingConnections.length === 0) {
|
||||
return normalizedLayout
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const nextNodes = normalizedLayout.nodes.slice()
|
||||
missingConnections.forEach((connection) => {
|
||||
nextNodes.push(createConnectionNode(connection, null, nextSiblingOrder(nextNodes, null), now))
|
||||
})
|
||||
|
||||
return {
|
||||
...normalizedLayout,
|
||||
nodes: nextNodes,
|
||||
}
|
||||
walk(nodes)
|
||||
return ids
|
||||
}
|
||||
|
||||
export function updateExpandedState(layout: SessionTreeLayoutPayload | null, nodeId: string): SessionTreeLayoutPayload | null {
|
||||
@@ -124,45 +229,206 @@ export function updateExpandedState(layout: SessionTreeLayoutPayload | null, nod
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefaultTreeLayout(connections: Connection[]): SessionTreeLayoutPayload {
|
||||
export function updateAllFoldersExpandedState(
|
||||
layout: SessionTreeLayoutPayload | null,
|
||||
expanded: boolean,
|
||||
): SessionTreeLayoutPayload | null {
|
||||
if (!layout) return layout
|
||||
|
||||
const now = Date.now()
|
||||
const groups = new Map<string, Connection[]>()
|
||||
connections.forEach((connection) => {
|
||||
const key = connection.name.includes('-') ? connection.name.split('-')[0] : '默认分组'
|
||||
const group = groups.get(key) ?? []
|
||||
group.push(connection)
|
||||
groups.set(key, group)
|
||||
return {
|
||||
...layout,
|
||||
nodes: layout.nodes.map((node) =>
|
||||
node.type === 'folder'
|
||||
? {
|
||||
...node,
|
||||
expanded,
|
||||
updatedAt: now,
|
||||
}
|
||||
: node,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function listSessionFolderOptions(
|
||||
layout: SessionTreeLayoutPayload | null,
|
||||
connections: Connection[],
|
||||
): SessionTreeFolderOption[] {
|
||||
const options: SessionTreeFolderOption[] = []
|
||||
const walk = (nodes: BuiltTreeNode[], depth: number) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.type !== 'folder') return
|
||||
options.push({ id: node.id, name: node.name, depth })
|
||||
walk(node.children, depth + 1)
|
||||
})
|
||||
}
|
||||
|
||||
walk(buildSessionTree(layout, connections), 0)
|
||||
return options
|
||||
}
|
||||
|
||||
export function resolveSuggestedFolderId(layout: SessionTreeLayoutPayload | null, selectedNodeId: string | null) {
|
||||
if (!layout || !selectedNodeId) return null
|
||||
const node = layout.nodes.find((item) => item.id === selectedNodeId)
|
||||
if (!node) return null
|
||||
if (node.type === 'folder') return node.id
|
||||
return normalizeParentId(layout, node.parentId ?? null)
|
||||
}
|
||||
|
||||
export function findConnectionNodeId(layout: SessionTreeLayoutPayload | null, connectionId: number) {
|
||||
return findConnectionNode(layout, connectionId)?.id ?? null
|
||||
}
|
||||
|
||||
export function findConnectionFolderId(layout: SessionTreeLayoutPayload | null, connectionId: number) {
|
||||
return normalizeParentId(layout, findConnectionNode(layout, connectionId)?.parentId ?? null)
|
||||
}
|
||||
|
||||
export function insertFolderNode(
|
||||
layout: SessionTreeLayoutPayload,
|
||||
folderName: string,
|
||||
parentId: string | null,
|
||||
): { layout: SessionTreeLayoutPayload; nodeId: string } {
|
||||
const now = Date.now()
|
||||
const nextParentId = normalizeParentId(layout, parentId)
|
||||
const nodeId = generateNodeId('folder', globalThis.crypto?.randomUUID?.() ?? String(now))
|
||||
const nextNode: SessionTreeNodePayload = {
|
||||
id: nodeId,
|
||||
type: 'folder',
|
||||
name: folderName,
|
||||
parentId: nextParentId,
|
||||
order: nextSiblingOrder(layout.nodes, nextParentId),
|
||||
expanded: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
return {
|
||||
layout: {
|
||||
...layout,
|
||||
nodes: [...layout.nodes, nextNode],
|
||||
sortMode: layout.sortMode ?? 'manual',
|
||||
},
|
||||
nodeId,
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertConnectionNode(
|
||||
layout: SessionTreeLayoutPayload,
|
||||
connection: Connection,
|
||||
parentId: string | null,
|
||||
): SessionTreeLayoutPayload {
|
||||
const now = Date.now()
|
||||
const nextParentId = normalizeParentId(layout, parentId)
|
||||
const existingNode = findConnectionNode(layout, connection.id)
|
||||
|
||||
if (!existingNode) {
|
||||
return {
|
||||
...layout,
|
||||
nodes: [
|
||||
...layout.nodes,
|
||||
createConnectionNode(connection, nextParentId, nextSiblingOrder(layout.nodes, nextParentId), now),
|
||||
],
|
||||
sortMode: layout.sortMode ?? 'manual',
|
||||
}
|
||||
}
|
||||
|
||||
const nextOrder =
|
||||
existingNode.parentId === nextParentId
|
||||
? existingNode.order
|
||||
: nextSiblingOrder(layout.nodes, nextParentId, existingNode.id)
|
||||
|
||||
return {
|
||||
...layout,
|
||||
nodes: layout.nodes.map((node) =>
|
||||
node.id === existingNode.id
|
||||
? {
|
||||
...node,
|
||||
name: connection.name,
|
||||
parentId: nextParentId,
|
||||
order: nextOrder,
|
||||
updatedAt: now,
|
||||
}
|
||||
: node,
|
||||
),
|
||||
sortMode: layout.sortMode ?? 'manual',
|
||||
}
|
||||
}
|
||||
|
||||
export function renameSessionTreeNode(
|
||||
layout: SessionTreeLayoutPayload | null,
|
||||
nodeId: string,
|
||||
name: string,
|
||||
): SessionTreeLayoutPayload | null {
|
||||
if (!layout) return layout
|
||||
|
||||
const nextName = name.trim()
|
||||
if (!nextName) return layout
|
||||
|
||||
const now = Date.now()
|
||||
return {
|
||||
...layout,
|
||||
nodes: layout.nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
name: nextName,
|
||||
updatedAt: now,
|
||||
}
|
||||
: node,
|
||||
),
|
||||
sortMode: layout.sortMode ?? 'manual',
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSessionTreeNodeSubtree(
|
||||
layout: SessionTreeLayoutPayload | null,
|
||||
nodeId: string,
|
||||
): {
|
||||
layout: SessionTreeLayoutPayload
|
||||
deletedConnectionIds: number[]
|
||||
deletedNodeIds: string[]
|
||||
} | null {
|
||||
if (!layout) return null
|
||||
|
||||
const nodeExists = layout.nodes.some((node) => node.id === nodeId)
|
||||
if (!nodeExists) {
|
||||
return {
|
||||
layout,
|
||||
deletedConnectionIds: [],
|
||||
deletedNodeIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
const childIdsByParent = new Map<string, string[]>()
|
||||
layout.nodes.forEach((node) => {
|
||||
if (!node.parentId) return
|
||||
const childIds = childIdsByParent.get(node.parentId) ?? []
|
||||
childIds.push(node.id)
|
||||
childIdsByParent.set(node.parentId, childIds)
|
||||
})
|
||||
|
||||
const nodes: SessionTreeNodePayload[] = []
|
||||
let order = 0
|
||||
Array.from(groups.entries()).forEach(([groupName, list]) => {
|
||||
const folderId = `folder-${groupName}-${order}`
|
||||
nodes.push({
|
||||
id: folderId,
|
||||
type: 'folder',
|
||||
name: groupName,
|
||||
parentId: null,
|
||||
order,
|
||||
expanded: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
list
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((connection, index) => {
|
||||
nodes.push({
|
||||
id: `connection-${connection.id}`,
|
||||
type: 'connection',
|
||||
name: connection.name,
|
||||
parentId: folderId,
|
||||
order: index,
|
||||
connectionId: connection.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
})
|
||||
order += 1
|
||||
})
|
||||
return { nodes, sortMode: 'manual' }
|
||||
const deletedNodeIdSet = new Set<string>()
|
||||
const stack = [nodeId]
|
||||
while (stack.length > 0) {
|
||||
const currentId = stack.pop()
|
||||
if (!currentId || deletedNodeIdSet.has(currentId)) continue
|
||||
deletedNodeIdSet.add(currentId)
|
||||
|
||||
const childIds = childIdsByParent.get(currentId) ?? []
|
||||
childIds.forEach((childId) => stack.push(childId))
|
||||
}
|
||||
|
||||
const deletedConnectionIds = layout.nodes.flatMap((node) =>
|
||||
deletedNodeIdSet.has(node.id) && node.type === 'connection' && node.connectionId ? [node.connectionId] : [],
|
||||
)
|
||||
|
||||
return {
|
||||
layout: {
|
||||
...layout,
|
||||
nodes: layout.nodes.filter((node) => !deletedNodeIdSet.has(node.id)),
|
||||
sortMode: layout.sortMode ?? 'manual',
|
||||
},
|
||||
deletedConnectionIds,
|
||||
deletedNodeIds: Array.from(deletedNodeIdSet),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,106 @@
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Activity,
|
||||
Command,
|
||||
FileUp,
|
||||
Folder,
|
||||
HardDrive,
|
||||
ListTree,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
SplitSquareHorizontal,
|
||||
Terminal,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||
import { buildDefaultTreeLayout, buildSessionTree, collectConnectionIds, updateExpandedState } from '../lib/utils'
|
||||
import { createConnection, listConnections, updateConnection } from '../services/connections'
|
||||
import {
|
||||
buildSessionTree,
|
||||
cn,
|
||||
createInitialSessionTreeLayout,
|
||||
deleteSessionTreeNodeSubtree,
|
||||
findConnectionFolderId,
|
||||
findConnectionNodeId,
|
||||
formatBytes,
|
||||
insertFolderNode,
|
||||
listSessionFolderOptions,
|
||||
renameSessionTreeNode,
|
||||
resolveSuggestedFolderId,
|
||||
syncSessionTreeLayout,
|
||||
updateAllFoldersExpandedState,
|
||||
updateExpandedState,
|
||||
upsertConnectionNode,
|
||||
} from '../lib/utils'
|
||||
import { checkConnectionStatuses, createConnection, deleteConnection, listConnections, updateConnection } from '../services/connections'
|
||||
import { getMetrics } from '../services/monitor'
|
||||
import { getSessionTree, saveSessionTree } from '../services/sessionTree'
|
||||
import type { Connection, ConnectionCreateRequest, SessionTreeLayoutPayload, TransferTaskGroup, WorkspaceLayout, WorkspaceTab } from '../types'
|
||||
import type {
|
||||
Connection,
|
||||
ConnectionModalSubmitPayload,
|
||||
ConnectionReachabilityStatus,
|
||||
ConnectionStatusItem,
|
||||
MonitorMetrics,
|
||||
SessionTreeLayoutPayload,
|
||||
TerminalConnectionStatus,
|
||||
TransferTaskGroup,
|
||||
WorkspaceLayout,
|
||||
WorkspaceTab,
|
||||
} from '../types'
|
||||
import BatchCommandModal from '../components/BatchCommandModal'
|
||||
import ChangePasswordModal from '../components/ChangePasswordModal'
|
||||
import ConnectionModal from '../components/ConnectionModal'
|
||||
import FolderModal from '../components/FolderModal'
|
||||
import SessionTree from '../components/SessionTree'
|
||||
import SettingsModal from '../components/SettingsModal'
|
||||
import SftpPane from '../components/SftpPane'
|
||||
import TerminalPane from '../components/TerminalPane'
|
||||
import TransferCenterModal from '../components/TransferCenterModal'
|
||||
|
||||
const terminalStatusCopy: Record<TerminalConnectionStatus, { label: string; tone: string; dot: string }> = {
|
||||
idle: { label: '终端未打开', tone: 'text-slate-400', dot: 'bg-slate-500' },
|
||||
connecting: { label: 'WebSocket 连接中', tone: 'text-amber-300', dot: 'bg-amber-400' },
|
||||
connected: { label: 'WebSocket 已连接', tone: 'text-emerald-300', dot: 'bg-emerald-400' },
|
||||
reconnecting: { label: '连接中断,重试中', tone: 'text-amber-300', dot: 'bg-amber-400' },
|
||||
error: { label: 'WebSocket 连接异常', tone: 'text-red-300', dot: 'bg-red-400' },
|
||||
}
|
||||
|
||||
type TreeContextMenuTargetType = 'folder' | 'connection'
|
||||
|
||||
type TreeContextMenuState = {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
targetId: string | null
|
||||
targetType: TreeContextMenuTargetType | null
|
||||
}
|
||||
|
||||
const closedTreeContextMenu: TreeContextMenuState = {
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
targetId: null,
|
||||
targetType: null,
|
||||
}
|
||||
|
||||
function getClampedContextMenuPosition(x: number, y: number, itemCount: number) {
|
||||
const menuWidth = 176
|
||||
const menuHeight = itemCount * 36 + 8
|
||||
const viewportPadding = 8
|
||||
|
||||
return {
|
||||
x: Math.max(viewportPadding, Math.min(x, window.innerWidth - menuWidth - viewportPadding)),
|
||||
y: Math.max(viewportPadding, Math.min(y, window.innerHeight - menuHeight - viewportPadding)),
|
||||
}
|
||||
}
|
||||
|
||||
function omitConnectionIdsFromRecord<T>(record: Record<number, T>, deletedConnectionIds: Set<number>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).filter(([key]) => !deletedConnectionIds.has(Number(key))),
|
||||
) as Record<number, T>
|
||||
}
|
||||
|
||||
export default function WorkspacePage({
|
||||
initialTool,
|
||||
onLogout,
|
||||
@@ -38,14 +115,24 @@ export default function WorkspacePage({
|
||||
const [search, setSearch] = useState('')
|
||||
const [tabs, setTabs] = useState<WorkspaceTab[]>([])
|
||||
const [currentTabId, setCurrentTabId] = useState<number | null>(null)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||
const [showConnectionModal, setShowConnectionModal] = useState(false)
|
||||
const [showFolderModal, setShowFolderModal] = useState(false)
|
||||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null)
|
||||
const [showBatchModal, setShowBatchModal] = useState(false)
|
||||
const [showTransferModal, setShowTransferModal] = useState(initialTool === 'transfers')
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [showChangePassword, setShowChangePassword] = useState<boolean>(!!user?.passwordChangeRequired)
|
||||
const [transferTasks, setTransferTasks] = useState<TransferTaskGroup[]>([])
|
||||
const [workspaceMetrics, setWorkspaceMetrics] = useState<MonitorMetrics>({})
|
||||
const [connectionStatuses, setConnectionStatuses] = useState<Record<number, ConnectionReachabilityStatus>>({})
|
||||
const [connectionStatusDetails, setConnectionStatusDetails] = useState<Record<number, ConnectionStatusItem>>({})
|
||||
const [connectionStatusError, setConnectionStatusError] = useState<string | null>(null)
|
||||
const [connectionStatusLoading, setConnectionStatusLoading] = useState(false)
|
||||
const [terminalStatuses, setTerminalStatuses] = useState<Record<number, TerminalConnectionStatus>>({})
|
||||
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState>(closedTreeContextMenu)
|
||||
const [tabContextMenu, setTabContextMenu] = useState({ visible: false, x: 0, y: 0 })
|
||||
const [terminalFontSize, setTerminalFontSize] = useLocalStorage('ssh-manager.terminal-font-size', 14)
|
||||
const [terminalFontFamily, setTerminalFontFamily] = useLocalStorage(
|
||||
'ssh-manager.terminal-font-family',
|
||||
@@ -57,72 +144,415 @@ export default function WorkspacePage({
|
||||
}, [user?.passwordChangeRequired])
|
||||
|
||||
useEffect(() => {
|
||||
const connectionIds = new Set(connections.map((connection) => connection.id))
|
||||
setConnectionStatuses((prev) =>
|
||||
Object.fromEntries(Object.entries(prev).filter(([key]) => connectionIds.has(Number(key)))) as Record<
|
||||
number,
|
||||
ConnectionReachabilityStatus
|
||||
>,
|
||||
)
|
||||
setConnectionStatusDetails((prev) =>
|
||||
Object.fromEntries(Object.entries(prev).filter(([key]) => connectionIds.has(Number(key)))) as Record<
|
||||
number,
|
||||
ConnectionStatusItem
|
||||
>,
|
||||
)
|
||||
}, [connections])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
const connectionsResponse = await listConnections()
|
||||
setConnections(connectionsResponse.data)
|
||||
if (cancelled) return
|
||||
|
||||
const nextConnections = connectionsResponse.data
|
||||
setConnections(nextConnections)
|
||||
|
||||
try {
|
||||
const treeResponse = await getSessionTree()
|
||||
if (treeResponse.data.nodes.length) {
|
||||
setTreeLayout(treeResponse.data)
|
||||
} else {
|
||||
const layout = buildDefaultTreeLayout(connectionsResponse.data)
|
||||
setTreeLayout(layout)
|
||||
await saveSessionTree(layout)
|
||||
if (cancelled) return
|
||||
|
||||
const baseLayout = treeResponse.data.nodes.length
|
||||
? treeResponse.data
|
||||
: createInitialSessionTreeLayout(nextConnections)
|
||||
const nextLayout = syncSessionTreeLayout(baseLayout, nextConnections)
|
||||
setTreeLayout(nextLayout)
|
||||
|
||||
if (JSON.stringify(treeResponse.data) !== JSON.stringify(nextLayout)) {
|
||||
await saveSessionTree(nextLayout)
|
||||
}
|
||||
} catch {
|
||||
const layout = buildDefaultTreeLayout(connectionsResponse.data)
|
||||
setTreeLayout(layout)
|
||||
if (cancelled) return
|
||||
setTreeLayout(createInitialSessionTreeLayout(nextConnections))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const treeNodes = useMemo(() => buildSessionTree(treeLayout, connections), [treeLayout, connections])
|
||||
const folderOptions = useMemo(() => listSessionFolderOptions(treeLayout, connections), [treeLayout, connections])
|
||||
const openConnectionIds = useMemo(() => tabs.map((tab) => tab.id), [tabs])
|
||||
const activeConnection = tabs.find((tab) => tab.id === currentTabId)?.connection ?? null
|
||||
const activeTerminalStatus = activeConnection ? terminalStatuses[activeConnection.id] ?? 'connecting' : 'idle'
|
||||
const hasFolders = treeLayout?.nodes.some((node) => node.type === 'folder') ?? false
|
||||
const hasCollapsedFolders = treeLayout?.nodes.some((node) => node.type === 'folder' && node.expanded === false) ?? false
|
||||
const connectionModalFolderId = useMemo(
|
||||
() =>
|
||||
editingConnection
|
||||
? findConnectionFolderId(treeLayout, editingConnection.id)
|
||||
: resolveSuggestedFolderId(treeLayout, selectedNodeId),
|
||||
[editingConnection, selectedNodeId, treeLayout],
|
||||
)
|
||||
|
||||
function openConnection(connection: Connection) {
|
||||
useEffect(() => {
|
||||
if (!activeConnection) {
|
||||
setWorkspaceMetrics({})
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const response = await getMetrics(activeConnection.id)
|
||||
if (!cancelled) {
|
||||
setWorkspaceMetrics(response.data)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setWorkspaceMetrics({})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void fetchMetrics()
|
||||
const timer = window.setInterval(fetchMetrics, 5000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [activeConnection?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (connections.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasCachedReachability = connections.some((connection) => {
|
||||
const status = connectionStatuses[connection.id]
|
||||
return status === 'online' || status === 'offline' || status === 'checking'
|
||||
})
|
||||
if (hasCachedReachability) {
|
||||
return
|
||||
}
|
||||
|
||||
void refreshConnectionStatuses({ connectionList: connections })
|
||||
}, [connections])
|
||||
|
||||
function getCurrentTreeLayout(nextConnections = connections) {
|
||||
return syncSessionTreeLayout(treeLayout ?? createInitialSessionTreeLayout(nextConnections), nextConnections)
|
||||
}
|
||||
|
||||
async function refreshConnectionStatuses(options?: {
|
||||
connectionList?: Connection[]
|
||||
throwOnError?: boolean
|
||||
}) {
|
||||
const connectionList = options?.connectionList ?? connections
|
||||
if (connectionList.length === 0) {
|
||||
setConnectionStatuses({})
|
||||
setConnectionStatusDetails({})
|
||||
setConnectionStatusError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setConnectionStatusLoading(true)
|
||||
setConnectionStatusError(null)
|
||||
const connectionIds = connectionList.map((connection) => connection.id)
|
||||
setConnectionStatuses((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(connectionIds.map((id) => [id, 'checking' as ConnectionReachabilityStatus])),
|
||||
}))
|
||||
|
||||
try {
|
||||
const response = await checkConnectionStatuses(connectionIds)
|
||||
const nextDetails = Object.fromEntries(
|
||||
response.data.results.map((item) => [item.connectionId, item]),
|
||||
) as Record<number, ConnectionStatusItem>
|
||||
const nextStatuses = Object.fromEntries(
|
||||
response.data.results.map((item) => [item.connectionId, item.status as ConnectionReachabilityStatus]),
|
||||
) as Record<number, ConnectionReachabilityStatus>
|
||||
|
||||
setConnectionStatusDetails((prev) => ({ ...prev, ...nextDetails }))
|
||||
setConnectionStatuses((prev) => ({ ...prev, ...nextStatuses }))
|
||||
} catch (error) {
|
||||
setConnectionStatusError(
|
||||
(error as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message ||
|
||||
'主机状态检测失败',
|
||||
)
|
||||
setConnectionStatuses((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(connectionIds.map((id) => [id, prev[id] === 'online' || prev[id] === 'offline' ? prev[id] : 'unknown'])),
|
||||
}))
|
||||
if (options?.throwOnError) {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
setConnectionStatusLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshConnectionStatuses() {
|
||||
await refreshConnectionStatuses({ throwOnError: true })
|
||||
}
|
||||
|
||||
async function persistTreeLayout(nextLayout: SessionTreeLayoutPayload) {
|
||||
setTreeLayout(nextLayout)
|
||||
await saveSessionTree(nextLayout)
|
||||
}
|
||||
|
||||
function findTreeNode(nodeId: string) {
|
||||
return treeLayout?.nodes.find((node) => node.id === nodeId) ?? null
|
||||
}
|
||||
|
||||
function closeTreeContextMenu() {
|
||||
setTreeContextMenu(closedTreeContextMenu)
|
||||
}
|
||||
|
||||
function openConnection(connection: Connection, nodeId?: string | null) {
|
||||
startTransition(() => {
|
||||
setTerminalStatuses((prev) => (prev[connection.id] ? prev : { ...prev, [connection.id]: 'connecting' }))
|
||||
setTabs((prev) => {
|
||||
if (prev.some((tab) => tab.id === connection.id)) return prev
|
||||
const existing = prev.find((tab) => tab.id === connection.id)
|
||||
if (existing) {
|
||||
return prev.map((tab) =>
|
||||
tab.id === connection.id ? { ...tab, name: connection.name, connection } : tab,
|
||||
)
|
||||
}
|
||||
return [...prev, { id: connection.id, name: connection.name, connection }]
|
||||
})
|
||||
setCurrentTabId(connection.id)
|
||||
setSelectedFiles([])
|
||||
if (nodeId) {
|
||||
setSelectedNodeId(nodeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleToggleFolder(nodeId: string) {
|
||||
const nextLayout = updateExpandedState(treeLayout, nodeId)
|
||||
setTreeLayout(nextLayout)
|
||||
if (nextLayout) {
|
||||
await saveSessionTree(nextLayout)
|
||||
}
|
||||
if (!nextLayout) return
|
||||
await persistTreeLayout(nextLayout)
|
||||
}
|
||||
|
||||
async function handleSubmitConnection(payload: ConnectionCreateRequest) {
|
||||
if (editingConnection) {
|
||||
await updateConnection(editingConnection.id, payload)
|
||||
} else {
|
||||
await createConnection(payload)
|
||||
async function handleToggleAllFolders() {
|
||||
const baseLayout = getCurrentTreeLayout()
|
||||
const hasAnyFolder = baseLayout.nodes.some((node) => node.type === 'folder')
|
||||
if (!hasAnyFolder) return
|
||||
|
||||
const shouldExpand = baseLayout.nodes.some((node) => node.type === 'folder' && node.expanded === false)
|
||||
const nextLayout = updateAllFoldersExpandedState(baseLayout, shouldExpand)
|
||||
if (!nextLayout) return
|
||||
|
||||
await persistTreeLayout(nextLayout)
|
||||
}
|
||||
|
||||
async function handleCreateFolder(payload: { name: string; parentId: string | null }) {
|
||||
const baseLayout = getCurrentTreeLayout()
|
||||
const { layout: nextLayout, nodeId } = insertFolderNode(baseLayout, payload.name, payload.parentId)
|
||||
setSelectedNodeId(nodeId)
|
||||
await persistTreeLayout(nextLayout)
|
||||
}
|
||||
|
||||
function handleOpenTreeContextMenu(payload: { nodeId: string; nodeType: TreeContextMenuTargetType; x: number; y: number }) {
|
||||
const position = getClampedContextMenuPosition(payload.x, payload.y, payload.nodeType === 'folder' ? 4 : 2)
|
||||
setSelectedNodeId(payload.nodeId)
|
||||
closeTabContextMenu()
|
||||
setTreeContextMenu({
|
||||
visible: true,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
targetId: payload.nodeId,
|
||||
targetType: payload.nodeType,
|
||||
})
|
||||
}
|
||||
|
||||
function isAncestorNode(layout: SessionTreeLayoutPayload, ancestorNodeId: string, nodeId: string) {
|
||||
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]))
|
||||
let currentNode = nodesById.get(nodeId) ?? null
|
||||
|
||||
while (currentNode?.parentId) {
|
||||
if (currentNode.parentId === ancestorNodeId) {
|
||||
return true
|
||||
}
|
||||
currentNode = nodesById.get(currentNode.parentId) ?? null
|
||||
}
|
||||
const response = await listConnections()
|
||||
setConnections(response.data)
|
||||
if (!treeLayout || collectConnectionIds(buildSessionTree(treeLayout, response.data)).length !== response.data.length) {
|
||||
const nextLayout = buildDefaultTreeLayout(response.data)
|
||||
setTreeLayout(nextLayout)
|
||||
await saveSessionTree(nextLayout)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function clearSelectedNodeIfNeeded(layout: SessionTreeLayoutPayload, targetId: string, deletedNodeIds: string[]) {
|
||||
const deletedNodeIdSet = new Set(deletedNodeIds)
|
||||
setSelectedNodeId((current) => {
|
||||
if (!current) return current
|
||||
if (deletedNodeIdSet.has(current)) return null
|
||||
return isAncestorNode(layout, current, targetId) ? null : current
|
||||
})
|
||||
}
|
||||
|
||||
function removeConnectionsFromWorkspace(connectionIds: number[]) {
|
||||
if (connectionIds.length === 0) return
|
||||
|
||||
const deletedConnectionIds = new Set(connectionIds)
|
||||
setConnections((prev) => prev.filter((connection) => !deletedConnectionIds.has(connection.id)))
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((tab) => !deletedConnectionIds.has(tab.id))
|
||||
setCurrentTabId((current) =>
|
||||
current != null && deletedConnectionIds.has(current) ? next[next.length - 1]?.id ?? null : current,
|
||||
)
|
||||
return next
|
||||
})
|
||||
setSelectedFiles([])
|
||||
setConnectionStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||
setConnectionStatusDetails((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||
setTerminalStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||
}
|
||||
|
||||
async function handleEditTreeItem() {
|
||||
const { targetId, targetType } = treeContextMenu
|
||||
closeTreeContextMenu()
|
||||
if (!targetId || !targetType) return
|
||||
|
||||
if (targetType === 'folder') {
|
||||
const targetNode = findTreeNode(targetId)
|
||||
if (!targetNode || targetNode.type !== 'folder') return
|
||||
|
||||
const nextName = window.prompt('重命名文件夹', targetNode.name)
|
||||
const trimmedName = nextName?.trim()
|
||||
if (!trimmedName || trimmedName === targetNode.name) return
|
||||
|
||||
const nextLayout = renameSessionTreeNode(getCurrentTreeLayout(), targetId, trimmedName)
|
||||
if (!nextLayout) return
|
||||
await persistTreeLayout(nextLayout)
|
||||
return
|
||||
}
|
||||
|
||||
const targetNode = findTreeNode(targetId)
|
||||
if (targetNode?.type !== 'connection' || !targetNode.connectionId) return
|
||||
|
||||
const targetConnection = connections.find((connection) => connection.id === targetNode.connectionId) ?? null
|
||||
if (!targetConnection) return
|
||||
|
||||
setEditingConnection(targetConnection)
|
||||
setShowConnectionModal(true)
|
||||
}
|
||||
|
||||
async function handleDeleteTreeItem() {
|
||||
const { targetId, targetType } = treeContextMenu
|
||||
closeTreeContextMenu()
|
||||
if (!targetId || !targetType) return
|
||||
|
||||
const baseLayout = getCurrentTreeLayout()
|
||||
const targetNode = baseLayout.nodes.find((node) => node.id === targetId)
|
||||
if (!targetNode) return
|
||||
|
||||
const deletedSubtree = deleteSessionTreeNodeSubtree(baseLayout, targetId)
|
||||
if (!deletedSubtree) return
|
||||
|
||||
if (targetType === 'connection') {
|
||||
if (targetNode.type !== 'connection' || !targetNode.connectionId) return
|
||||
const confirmed = window.confirm('确认永久删除该 SSH 连接?')
|
||||
if (!confirmed) return
|
||||
|
||||
await deleteConnection(targetNode.connectionId)
|
||||
await persistTreeLayout(deletedSubtree.layout)
|
||||
removeConnectionsFromWorkspace([targetNode.connectionId])
|
||||
clearSelectedNodeIfNeeded(baseLayout, targetId, deletedSubtree.deletedNodeIds)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm('确认删除该文件夹?将同时删除其下所有子文件夹与连接。')
|
||||
if (!confirmed) return
|
||||
if (targetNode.type !== 'folder') return
|
||||
|
||||
if (deletedSubtree.deletedConnectionIds.length > 0) {
|
||||
await Promise.all(deletedSubtree.deletedConnectionIds.map((connectionId) => deleteConnection(connectionId)))
|
||||
}
|
||||
|
||||
await persistTreeLayout(deletedSubtree.layout)
|
||||
removeConnectionsFromWorkspace(deletedSubtree.deletedConnectionIds)
|
||||
clearSelectedNodeIfNeeded(baseLayout, targetId, deletedSubtree.deletedNodeIds)
|
||||
}
|
||||
|
||||
async function handleSubmitConnection(payload: ConnectionModalSubmitPayload) {
|
||||
const connectionToEdit = editingConnection
|
||||
const { targetFolderId, ...request } = payload
|
||||
const connectionResponse = connectionToEdit ? await updateConnection(connectionToEdit.id, request) : await createConnection(request)
|
||||
|
||||
const savedConnection = connectionResponse.data
|
||||
const connectionsResponse = await listConnections()
|
||||
const nextConnections = connectionsResponse.data
|
||||
const nextConnection = nextConnections.find((connection) => connection.id === savedConnection.id) ?? savedConnection
|
||||
setConnections(nextConnections)
|
||||
|
||||
const nextLayout = upsertConnectionNode(getCurrentTreeLayout(nextConnections), nextConnection, targetFolderId)
|
||||
const nodeId = findConnectionNodeId(nextLayout, nextConnection.id)
|
||||
|
||||
await persistTreeLayout(nextLayout)
|
||||
setShowConnectionModal(false)
|
||||
setEditingConnection(null)
|
||||
|
||||
if (connectionToEdit) {
|
||||
setSelectedNodeId(nodeId)
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => (tab.id === nextConnection.id ? { ...tab, name: nextConnection.name, connection: nextConnection } : tab)),
|
||||
)
|
||||
setConnectionStatusDetails((prev) =>
|
||||
prev[nextConnection.id]
|
||||
? {
|
||||
...prev,
|
||||
[nextConnection.id]: {
|
||||
...prev[nextConnection.id],
|
||||
connectionName: nextConnection.name,
|
||||
},
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
openConnection(nextConnection, nodeId)
|
||||
}
|
||||
|
||||
function handleCloseTab(id: number) {
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((tab) => tab.id !== id)
|
||||
if (currentTabId === id) {
|
||||
setCurrentTabId(next[next.length - 1]?.id ?? null)
|
||||
}
|
||||
setCurrentTabId((current) => (current === id ? next[next.length - 1]?.id ?? null : current))
|
||||
return next
|
||||
})
|
||||
setTerminalStatuses((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function closeTabContextMenu() {
|
||||
setTabContextMenu({ visible: false, x: 0, y: 0 })
|
||||
}
|
||||
|
||||
function closeAllTabs() {
|
||||
setTabs([])
|
||||
setCurrentTabId(null)
|
||||
setTerminalStatuses({})
|
||||
setSelectedFiles([])
|
||||
closeTabContextMenu()
|
||||
}
|
||||
|
||||
const statusMeta = terminalStatusCopy[activeTerminalStatus]
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-slate-950 text-slate-100">
|
||||
<header className="flex h-14 items-center justify-between border-b border-slate-800 bg-slate-900 px-4 shadow-sm">
|
||||
@@ -135,15 +565,17 @@ export default function WorkspacePage({
|
||||
</div>
|
||||
<div className="h-6 w-px bg-slate-800" />
|
||||
<nav className="flex items-center gap-1">
|
||||
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowConnectionModal(true)}>
|
||||
<Plus size={16} className="text-emerald-500" />
|
||||
新建连接
|
||||
</button>
|
||||
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowBatchModal(true)}>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => setShowBatchModal(true)}
|
||||
>
|
||||
<Command size={16} className="text-purple-400" />
|
||||
批量执行
|
||||
</button>
|
||||
<button className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowTransferModal(true)}>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => setShowTransferModal(true)}
|
||||
>
|
||||
<FileUp size={16} className="text-blue-400" />
|
||||
传输中心
|
||||
</button>
|
||||
@@ -159,10 +591,15 @@ export default function WorkspacePage({
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="rounded-full p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white" onClick={() => setShowSettingsModal(true)}>
|
||||
<button
|
||||
className="rounded-full p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
<div className="rounded-full border border-slate-800 bg-slate-950 px-3 py-1.5 text-sm text-slate-400">{user?.displayName || user?.username}</div>
|
||||
<div className="rounded-full border border-slate-800 bg-slate-950 px-3 py-1.5 text-sm text-slate-400">
|
||||
{user?.displayName || user?.username}
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-slate-400 transition hover:text-red-400"
|
||||
onClick={() => {
|
||||
@@ -178,63 +615,209 @@ export default function WorkspacePage({
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-4 py-3 text-sm">
|
||||
<span className="font-medium text-slate-300">会话管理</span>
|
||||
<button className="rounded-lg p-1 text-slate-400 transition hover:bg-slate-800" onClick={() => setShowConnectionModal(true)}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<div className="border-b border-slate-800 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-slate-300">会话管理</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white',
|
||||
hasFolders ? '' : 'cursor-not-allowed text-slate-600 hover:bg-transparent hover:text-slate-600',
|
||||
)}
|
||||
disabled={!hasFolders}
|
||||
onClick={() => void handleToggleAllFolders()}
|
||||
title={hasFolders ? (hasCollapsedFolders ? '全部展开' : '全部折叠') : '暂无文件夹'}
|
||||
aria-label={hasFolders ? (hasCollapsedFolders ? '全部展开' : '全部折叠') : '暂无文件夹'}
|
||||
>
|
||||
<ListTree size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => {
|
||||
closeTreeContextMenu()
|
||||
setShowFolderModal(true)
|
||||
}}
|
||||
title="新建文件夹"
|
||||
aria-label="新建文件夹"
|
||||
>
|
||||
<Folder size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1.5 text-slate-400 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => {
|
||||
closeTreeContextMenu()
|
||||
setEditingConnection(null)
|
||||
setShowConnectionModal(true)
|
||||
}}
|
||||
title="新建连接"
|
||||
aria-label="新建连接"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<SessionTree nodes={treeNodes} activeConnectionId={currentTabId} search={search} onToggleFolder={(nodeId) => void handleToggleFolder(nodeId)} onOpenConnection={openConnection} />
|
||||
<div
|
||||
className="flex-1 overflow-auto p-2"
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
closeTreeContextMenu()
|
||||
setSelectedNodeId(null)
|
||||
}}
|
||||
>
|
||||
<SessionTree
|
||||
nodes={treeNodes}
|
||||
activeConnectionId={currentTabId}
|
||||
connectionStatuses={connectionStatuses}
|
||||
openConnectionIds={openConnectionIds}
|
||||
selectedNodeId={selectedNodeId}
|
||||
search={search}
|
||||
onSelectNode={setSelectedNodeId}
|
||||
onToggleFolder={(nodeId) => void handleToggleFolder(nodeId)}
|
||||
onOpenConnection={openConnection}
|
||||
onContextMenu={handleOpenTreeContextMenu}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex flex-1 flex-col overflow-hidden bg-[#0d1117]">
|
||||
<div className="flex border-b border-slate-800 bg-slate-900">
|
||||
{tabs.length === 0 ? <div className="h-10" /> : null}
|
||||
<div className="flex h-10 border-b border-slate-800 bg-slate-900">
|
||||
{tabs.length === 0 ? <div className="h-full flex-1" /> : null}
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`group flex h-10 min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm ${currentTabId === tab.id ? 'border-t-2 border-t-blue-500 bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800'}`}
|
||||
className={cn(
|
||||
'group flex h-full min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm transition',
|
||||
currentTabId === tab.id ? 'bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
|
||||
)}
|
||||
onClick={() => setCurrentTabId(tab.id)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
closeTreeContextMenu()
|
||||
setTabContextMenu({ visible: true, x: event.clientX, y: event.clientY })
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} className={currentTabId === tab.id ? 'text-emerald-400' : ''} />
|
||||
<Terminal size={14} className={currentTabId === tab.id ? 'text-emerald-400' : 'text-slate-500'} />
|
||||
<span className="flex-1 truncate">{tab.name}</span>
|
||||
<span className="opacity-0 transition group-hover:opacity-100" onClick={(event) => { event.stopPropagation(); handleCloseTab(tab.id) }}>
|
||||
<span
|
||||
className="opacity-0 transition group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleCloseTab(tab.id)
|
||||
}}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex h-11 items-center justify-between border-b border-slate-800 bg-slate-800/80 px-4">
|
||||
<div className="flex min-w-0 items-center gap-4 overflow-hidden text-xs">
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<Activity size={14} className="text-emerald-400" />
|
||||
CPU: {workspaceMetrics.cpuUsage ?? '-'}%
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<HardDrive size={14} className="text-blue-400" />
|
||||
MEM: {formatBytes(workspaceMetrics.memUsed ?? null)} / {formatBytes(workspaceMetrics.memTotal ?? null)}
|
||||
</div>
|
||||
<div className={cn('flex items-center gap-2 truncate', statusMeta.tone)}>
|
||||
<span className={cn('h-2 w-2 rounded-full', statusMeta.dot)} />
|
||||
{statusMeta.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex items-center rounded-lg border border-slate-700 bg-slate-900/90 p-1">
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-md p-2 transition',
|
||||
layout === 'terminal' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
|
||||
)}
|
||||
onClick={() => setLayout('terminal')}
|
||||
title="终端"
|
||||
>
|
||||
<Terminal size={15} />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-md p-2 transition',
|
||||
layout === 'sftp' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
|
||||
)}
|
||||
onClick={() => setLayout('sftp')}
|
||||
title="SFTP"
|
||||
>
|
||||
<Folder size={15} />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-md p-2 transition',
|
||||
layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
|
||||
)}
|
||||
onClick={() => setLayout('split')}
|
||||
title="分屏"
|
||||
>
|
||||
<SplitSquareHorizontal size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
{!activeConnection ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-500">
|
||||
<Monitor size={64} className="mb-4 text-slate-800" />
|
||||
<h2 className="mb-2 text-xl font-medium text-slate-300">欢迎使用 SSH Manager</h2>
|
||||
<p className="text-sm">请在左侧点击服务器建立连接,或创建新的会话。</p>
|
||||
<button className="mt-6 flex items-center gap-2 rounded-xl border border-blue-600/30 bg-blue-600/10 px-6 py-2 text-blue-300 transition hover:bg-blue-600/20" onClick={() => setShowConnectionModal(true)}>
|
||||
<p className="text-sm">请在左侧双击服务器节点建立连接,或先创建新的会话。</p>
|
||||
<button
|
||||
className="mt-6 flex items-center gap-2 rounded-xl border border-blue-600/30 bg-blue-600/10 px-6 py-2 text-blue-300 transition hover:bg-blue-600/20"
|
||||
onClick={() => {
|
||||
setEditingConnection(null)
|
||||
setShowConnectionModal(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
创建新连接
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex h-full ${layout === 'split' ? 'flex-row' : 'flex-col'}`}>
|
||||
{(layout === 'split' || layout === 'terminal') && (
|
||||
<div className={`${layout === 'split' ? 'w-1/2 border-r border-slate-800' : 'w-full'} overflow-hidden`}>
|
||||
<TerminalPane
|
||||
connection={activeConnection}
|
||||
active
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
fontSize={terminalFontSize}
|
||||
fontFamily={terminalFontFamily}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex h-full min-w-0 ${layout === 'split' ? 'flex-row' : 'flex-col'}`}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-w-0 flex-1 overflow-hidden',
|
||||
layout === 'split' && 'w-1/2 border-r border-slate-800',
|
||||
layout === 'terminal' && 'w-full',
|
||||
layout === 'sftp' && 'hidden',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const visible = tab.id === currentTabId && layout !== 'sftp'
|
||||
return (
|
||||
<div key={tab.id} className={cn('absolute inset-0', !visible && 'hidden')}>
|
||||
<TerminalPane
|
||||
connection={tab.connection}
|
||||
visible={visible}
|
||||
fontSize={terminalFontSize}
|
||||
fontFamily={terminalFontFamily}
|
||||
onStatusChange={(status) => {
|
||||
setTerminalStatuses((prev) =>
|
||||
prev[tab.id] === status ? prev : { ...prev, [tab.id]: status },
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(layout === 'split' || layout === 'sftp') && (
|
||||
<div className={`${layout === 'split' ? 'w-1/2' : 'w-full'} overflow-hidden`}>
|
||||
<SftpPane connection={activeConnection} selectedFiles={selectedFiles} onSelectedFilesChange={setSelectedFiles} />
|
||||
<div className={`${layout === 'split' ? 'min-w-0 w-1/2' : 'w-full'} overflow-hidden`}>
|
||||
<SftpPane
|
||||
connection={activeConnection}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectedFilesChange={setSelectedFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,13 +829,31 @@ export default function WorkspacePage({
|
||||
<ConnectionModal
|
||||
open={showConnectionModal}
|
||||
connection={editingConnection}
|
||||
folderOptions={folderOptions}
|
||||
initialTargetFolderId={connectionModalFolderId}
|
||||
onClose={() => {
|
||||
setShowConnectionModal(false)
|
||||
setEditingConnection(null)
|
||||
}}
|
||||
onSubmit={handleSubmitConnection}
|
||||
/>
|
||||
<BatchCommandModal open={showBatchModal} connections={connections} onClose={() => setShowBatchModal(false)} />
|
||||
<FolderModal
|
||||
open={showFolderModal}
|
||||
folderOptions={folderOptions}
|
||||
initialParentId={resolveSuggestedFolderId(treeLayout, selectedNodeId)}
|
||||
onClose={() => setShowFolderModal(false)}
|
||||
onSubmit={handleCreateFolder}
|
||||
/>
|
||||
<BatchCommandModal
|
||||
open={showBatchModal}
|
||||
connections={connections}
|
||||
connectionStatuses={connectionStatuses}
|
||||
connectionStatusDetails={connectionStatusDetails}
|
||||
statusError={connectionStatusError}
|
||||
statusLoading={connectionStatusLoading}
|
||||
onRefreshStatuses={handleRefreshConnectionStatuses}
|
||||
onClose={() => setShowBatchModal(false)}
|
||||
/>
|
||||
<TransferCenterModal
|
||||
open={showTransferModal}
|
||||
connections={connections}
|
||||
@@ -269,6 +870,67 @@ export default function WorkspacePage({
|
||||
onFontFamilyChange={setTerminalFontFamily}
|
||||
/>
|
||||
{showChangePassword ? <ChangePasswordModal force={!!user?.passwordChangeRequired} onClose={() => setShowChangePassword(false)} /> : null}
|
||||
{treeContextMenu.visible ? (
|
||||
<div className="fixed inset-0 z-50" onClick={closeTreeContextMenu}>
|
||||
<div
|
||||
className="absolute min-w-44 rounded-xl border border-slate-700 bg-slate-900 p-1 shadow-2xl shadow-black/40"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{ left: treeContextMenu.x, top: treeContextMenu.y }}
|
||||
>
|
||||
{treeContextMenu.targetType === 'folder' ? (
|
||||
<>
|
||||
<button
|
||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => {
|
||||
closeTreeContextMenu()
|
||||
setEditingConnection(null)
|
||||
setShowConnectionModal(true)
|
||||
}}
|
||||
>
|
||||
新建连接
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => {
|
||||
closeTreeContextMenu()
|
||||
setShowFolderModal(true)
|
||||
}}
|
||||
>
|
||||
新建文件夹
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => void handleEditTreeItem()}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-red-300 transition hover:bg-red-500/10 hover:text-red-200"
|
||||
onClick={() => void handleDeleteTreeItem()}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{tabContextMenu.visible ? (
|
||||
<div className="fixed inset-0 z-50" onClick={closeTabContextMenu}>
|
||||
<div
|
||||
className="absolute min-w-40 rounded-xl border border-slate-700 bg-slate-900 p-1 shadow-2xl shadow-black/40"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{ left: tabContextMenu.x, top: tabContextMenu.y }}
|
||||
>
|
||||
<button
|
||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||
onClick={closeAllTabs}
|
||||
>
|
||||
关闭所有标签
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import http from './http'
|
||||
import type { BatchCommandResponse, Connection, ConnectionCreateRequest } from '../types'
|
||||
import type { BatchCommandResponse, Connection, ConnectionCreateRequest, ConnectionStatusResponse } from '../types'
|
||||
|
||||
export function listConnections() {
|
||||
return http.get<Connection[]>('/connections')
|
||||
@@ -20,3 +20,7 @@ export function deleteConnection(id: number) {
|
||||
export function executeBatchCommand(connectionIds: number[], command: string) {
|
||||
return http.post<BatchCommandResponse>('/connections/batch-command', { connectionIds, command })
|
||||
}
|
||||
|
||||
export function checkConnectionStatuses(connectionIds: number[]) {
|
||||
return http.post<ConnectionStatusResponse>('/connections/status', { connectionIds })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export type ConnectionSetupMode = 'NONE' | 'PASSWORD_BOOTSTRAP'
|
||||
export type SessionTreeNodeType = 'folder' | 'connection'
|
||||
export type SessionTreeSortMode = 'manual' | 'nameAsc'
|
||||
export type WorkspaceLayout = 'split' | 'terminal' | 'sftp'
|
||||
export type TerminalConnectionStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error'
|
||||
export type ConnectionReachabilityStatus = 'unknown' | 'checking' | 'online' | 'offline'
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
@@ -41,6 +43,10 @@ export interface ConnectionCreateRequest {
|
||||
bootstrapPassword?: string
|
||||
}
|
||||
|
||||
export interface ConnectionModalSubmitPayload extends ConnectionCreateRequest {
|
||||
targetFolderId: string | null
|
||||
}
|
||||
|
||||
export interface SessionTreeNodePayload {
|
||||
id: string
|
||||
type: SessionTreeNodeType
|
||||
@@ -58,6 +64,12 @@ export interface SessionTreeLayoutPayload {
|
||||
sortMode?: SessionTreeSortMode
|
||||
}
|
||||
|
||||
export interface SessionTreeFolderOption {
|
||||
id: string
|
||||
name: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface SftpFileInfo {
|
||||
name: string
|
||||
directory: boolean
|
||||
@@ -115,6 +127,21 @@ export interface BatchCommandResponse {
|
||||
results: BatchCommandResult[]
|
||||
}
|
||||
|
||||
export interface ConnectionStatusItem {
|
||||
connectionId: number
|
||||
connectionName: string
|
||||
status: 'online' | 'offline'
|
||||
message: string
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface ConnectionStatusResponse {
|
||||
total: number
|
||||
onlineCount: number
|
||||
offlineCount: number
|
||||
results: ConnectionStatusItem[]
|
||||
}
|
||||
|
||||
export interface MonitorMetrics {
|
||||
cpuUsage?: number | null
|
||||
memTotal?: number | null
|
||||
|
||||
Reference in New Issue
Block a user