Files
ssh-manager/frontend/src/components/TransferCenterModal.tsx
T

522 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import {
CheckCircle2,
FileUp,
FolderOpen,
ListTree,
Monitor,
RefreshCw,
Server,
Target,
Upload,
Zap,
} from 'lucide-react'
import Modal from './Modal'
import SftpFileSelectorModal from './SftpFileSelectorModal'
import {
cancelRemoteTransferTask,
createRemoteTransferTask,
subscribeRemoteTransferProgress,
subscribeUploadProgress,
uploadFile,
} from '../services/sftp'
import type { Connection, ConnectionReachabilityStatus, TransferTaskGroup, TransferTaskItem } from '../types'
function aggregate(items: TransferTaskItem[]) {
const total = items.length || 1
const progress = Math.round(items.reduce((sum, item) => sum + item.progress, 0) / total)
const allDone = items.every((item) => ['success', 'error', 'cancelled'].includes(item.status))
const hasError = items.some((item) => item.status === 'error')
const hasRunning = items.some((item) => item.status === 'running' || item.status === 'queued')
const status = hasRunning ? 'running' : hasError ? 'error' : allDone ? 'success' : 'queued'
return { progress, status: status as TransferTaskGroup['status'] }
}
function StatusDot({ status }: { status: ConnectionReachabilityStatus | undefined }) {
if (status === 'online')
return <span className="h-2 w-2 shrink-0 rounded-full bg-emerald-400" title="在线" />
if (status === 'offline')
return <span className="h-2 w-2 shrink-0 rounded-full bg-red-400" title="离线" />
if (status === 'checking')
return <span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-yellow-400" title="检测中" />
return <span className="h-2 w-2 shrink-0 rounded-full bg-slate-600" title="未知" />
}
export default function TransferCenterModal({
open,
connections,
connectionStatuses,
tasks,
onClose,
onTasksChange,
}: {
open: boolean
connections: Connection[]
connectionStatuses: Record<number, ConnectionReachabilityStatus>
tasks: TransferTaskGroup[]
onClose: () => void
onTasksChange: (updater: TransferTaskGroup[] | ((prev: TransferTaskGroup[]) => TransferTaskGroup[])) => void
}) {
const [tab, setTab] = useState<'local' | 'remote'>('local')
const [targetIds, setTargetIds] = useState<number[]>(connections.length ? [connections[0].id] : [])
const [localFiles, setLocalFiles] = useState<FileList | null>(null)
const [localTargetPath, setLocalTargetPath] = useState('/usr/local/nginx/html')
const [remoteSourceId, setRemoteSourceId] = useState<number>(connections[0]?.id ?? 0)
const [remoteSourcePath, setRemoteSourcePath] = useState('/opt/app/build.tar.gz')
const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/')
const [showBrowser, setShowBrowser] = useState(false)
if (!open) return null
function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) {
// Use functional update to always read latest state, avoiding stale-closure bugs in async callbacks
onTasksChange((prev) => prev.map((task) => (task.id === groupId ? updater(task) : task)))
}
async function handleStartLocal() {
if (!localFiles?.length || targetIds.length === 0) return
const file = localFiles[0]
const groupId = String(Date.now())
const group: TransferTaskGroup = {
id: groupId,
mode: 'LOCAL_TO_MANY',
title: `本地文件批量分发 · ${file.name}`,
status: 'running',
progress: 0,
createdAt: new Date().toLocaleTimeString(),
items: targetIds.map((id) => ({
id: `${groupId}-${id}`,
label: connections.find((item) => item.id === id)?.name || String(id),
status: 'queued',
progress: 0,
message: '等待上传',
})),
}
onTasksChange((prev) => [group, ...prev])
targetIds.forEach(async (targetId) => {
const response = await uploadFile(targetId, localTargetPath, file, { overwrite: true })
const taskId = response.data.taskId
updateTaskGroup(groupId, (current) => ({
...current,
items: current.items.map((item) =>
item.label === connections.find((conn) => conn.id === targetId)?.name
? { ...item, status: 'running', message: '正在传输...', taskId }
: item,
),
}))
const unsubscribe = subscribeUploadProgress(taskId, (task) => {
updateTaskGroup(groupId, (current) => {
const nextItems = current.items.map((item) =>
item.taskId === task.taskId
? {
...item,
progress: task.progress,
status: task.status,
message: task.error || (task.status === 'success' ? '上传完成' : task.status === 'error' ? '上传失败' : task.status === 'cancelled' ? '已取消' : '正在传输...'),
}
: item,
)
const next = aggregate(nextItems)
return { ...current, items: nextItems, progress: next.progress, status: next.status }
})
if (['success', 'error'].includes(task.status)) {
unsubscribe()
}
})
})
}
async function handleStartRemote() {
if (!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()) return
const groupId = String(Date.now())
const sourceName = remoteSourcePath.trim().split('/').filter(Boolean).pop() || remoteSourcePath.trim()
const group: TransferTaskGroup = {
id: groupId,
mode: 'REMOTE_TO_MANY',
title: `跨主机分发 · ${sourceName}`,
status: 'running',
progress: 0,
createdAt: new Date().toLocaleTimeString(),
items: targetIds.map((id) => ({
id: `${groupId}-${id}`,
label: connections.find((item) => item.id === id)?.name || String(id),
status: 'queued',
progress: 0,
message: '等待创建任务',
})),
}
onTasksChange((prev) => [group, ...prev])
targetIds.forEach(async (targetId) => {
const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath)
const taskId = response.data.taskId
updateTaskGroup(groupId, (current) => ({
...current,
items: current.items.map((item) =>
item.label === connections.find((conn) => conn.id === targetId)?.name
? { ...item, status: 'running', message: '正在跨服同步...', taskId }
: item,
),
}))
const unsubscribe = subscribeRemoteTransferProgress(taskId, (task) => {
updateTaskGroup(groupId, (current) => {
const nextItems = current.items.map((item) =>
item.taskId === task.taskId
? {
...item,
progress: task.progress,
status: task.status,
message: task.error || (task.status === 'success' ? '同步完成' : task.status === 'error' ? '同步失败' : task.status === 'cancelled' ? '已取消' : '正在同步...'),
}
: item,
)
const next = aggregate(nextItems)
return { ...current, items: nextItems, progress: next.progress, status: next.status }
})
if (['success', 'error', 'cancelled'].includes(task.status)) {
unsubscribe()
}
})
})
}
return (
<>
<Modal title="文件传输中心" onClose={onClose} maxWidth="max-w-7xl">
<div className="flex h-[74vh] flex-col overflow-hidden rounded-2xl border border-slate-800 bg-[#0d1117]">
{/* Tabs */}
<div className="flex gap-8 border-b border-slate-800 bg-slate-900 px-6 pt-4">
<button
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'local' ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-400'}`}
onClick={() => setTab('local')}
>
<span className="flex items-center gap-2">
<Monitor size={16} />
</span>
</button>
<button
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'remote' ? 'border-purple-500 text-purple-400' : 'border-transparent text-slate-400'}`}
onClick={() => { setTab('remote'); setShowBrowser(false) }}
>
<span className="flex items-center gap-2">
<Server size={16} />
</span>
</button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Left panel */}
<div className="flex flex-1 overflow-hidden border-r border-slate-800 bg-slate-900/70 p-6">
{tab === 'local' ? (
<div className="flex flex-1 flex-col space-y-6">
<label className="space-y-2">
<span className="text-sm text-slate-300">1. </span>
<div className="rounded-2xl border-2 border-dashed border-slate-600 bg-slate-800/40 p-6 text-center">
<input type="file" onChange={(event) => setLocalFiles(event.target.files)} className="mx-auto block text-sm text-slate-300" />
<div className="mt-2 text-xs text-slate-500">{localFiles?.[0]?.name || '支持多选文件/文件夹'}</div>
</div>
</label>
<div className="grid gap-6 md:grid-cols-2">
<label className="space-y-2">
<span className="text-sm text-slate-300">2. </span>
<input className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white" value={localTargetPath} onChange={(event) => setLocalTargetPath(event.target.value)} />
</label>
<div className="space-y-2">
<span className="text-sm text-slate-300"></span>
<div className="rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-slate-400"></div>
</div>
</div>
<div className="flex-1 space-y-2">
<span className="text-sm text-slate-300">3. </span>
<div className="rounded-2xl border border-slate-700 bg-black p-2">
{connections.map((server) => {
const st = connectionStatuses[server.id]
const isOnline = st === 'online'
return (
<label
key={server.id}
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
}`}
>
<input
type="checkbox"
disabled={!isOnline}
checked={targetIds.includes(server.id)}
onChange={() =>
setTargetIds((prev) =>
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
)
}
/>
<StatusDot status={st} />
<Server size={14} className={isOnline ? 'text-emerald-500' : 'text-slate-600'} />
<span className="text-sm text-slate-300">{server.name}</span>
</label>
)
})}
</div>
</div>
<button
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
!localFiles?.length || targetIds.length === 0
? 'cursor-not-allowed bg-slate-800 text-slate-500'
: 'bg-blue-600 text-white hover:bg-blue-500'
}`}
disabled={!localFiles?.length || targetIds.length === 0}
onClick={() => void handleStartLocal()}
>
<Upload size={18} />
{!localFiles?.length ? '请选择本地文件' : targetIds.length === 0 ? '请选择目标服务器' : '开始分发'}
</button>
</div>
) : (
<div className="flex h-full flex-1 gap-6 overflow-hidden">
{/* Source config */}
<div className="flex w-1/2 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 pr-6">
<h3 className="flex items-center gap-2 text-sm font-bold text-purple-400">
<Zap size={16} />
</h3>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-300"></span>
{(() => {
const st = connectionStatuses[remoteSourceId]
if (st === 'online') return <span className="flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs font-medium text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />线</span>
if (st === 'offline') return <span className="flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-medium text-red-400"><span className="h-1.5 w-1.5 rounded-full bg-red-400" />线</span>
if (st === 'checking') return <span className="flex items-center gap-1 rounded-full bg-yellow-500/15 px-2 py-0.5 text-xs font-medium text-yellow-400"><span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400" /></span>
return <span className="flex items-center gap-1 rounded-full bg-slate-700/60 px-2 py-0.5 text-xs font-medium text-slate-500"><span className="h-1.5 w-1.5 rounded-full bg-slate-500" /></span>
})()}
</div>
<select
className={`w-full rounded-xl border bg-black px-4 py-3 text-sm text-white ${
connectionStatuses[remoteSourceId] === 'online'
? 'border-emerald-700'
: connectionStatuses[remoteSourceId] === 'offline'
? 'border-red-800'
: 'border-slate-700'
}`}
value={remoteSourceId}
onChange={(event) => {
setRemoteSourceId(Number(event.target.value))
setShowBrowser(false)
}}
>
{connections.map((server) => {
const st = connectionStatuses[server.id]
const isOnline = st === 'online'
const label = st === 'online' ? '● ' : st === 'offline' ? '● ' : st === 'checking' ? '◔ ' : '◦ '
return (
<option key={server.id} value={server.id} disabled={!isOnline}>
{label}{server.name}{!isOnline ? (st === 'offline' ? ' (离线)' : ' (未知)') : ''}
</option>
)
})}
</select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-300"> / </span>
<button
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs transition ${
showBrowser
? 'bg-purple-600 text-white'
: 'border border-slate-700 text-slate-400 hover:border-purple-500 hover:text-purple-400'
}`}
onClick={() => setShowBrowser((v) => !v)}
>
<FolderOpen size={12} />
{showBrowser ? '收起' : '浏览...'}
</button>
</div>
<input
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 font-mono text-sm text-white"
value={remoteSourcePath}
onChange={(event) => setRemoteSourcePath(event.target.value)}
placeholder="/opt/app"
/>
</div>
<div className="mt-auto rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 text-xs text-slate-500">
<p className="font-medium text-slate-400">📁 </p>
<p className="mt-1"></p>
</div>
</div>
{/* Target config */}
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-hidden">
<h3 className="flex items-center gap-2 text-sm font-bold text-blue-400">
<Target size={16} />
</h3>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white"
value={remoteTargetPath}
onChange={(event) => setRemoteTargetPath(event.target.value)}
/>
</label>
<div className="flex min-h-0 flex-1 flex-col space-y-2">
<div className="flex shrink-0 items-center justify-between">
<span className="text-sm text-slate-300"></span>
<button
className="text-xs text-blue-400"
onClick={() => setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id))}
>
线
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
{connections.map((server) => {
const st = connectionStatuses[server.id]
const isOnline = st === 'online'
return (
<label
key={server.id}
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
}`}
>
<input
type="checkbox"
disabled={!isOnline}
checked={targetIds.includes(server.id)}
onChange={() =>
setTargetIds((prev) =>
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
)
}
/>
<StatusDot status={st} />
<span className="text-sm text-slate-300">{server.name}</span>
</label>
)
})}
</div>
</div>
<button
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()
? 'cursor-not-allowed bg-slate-800 text-slate-500'
: 'bg-purple-600 text-white hover:bg-purple-500'
}`}
disabled={!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()}
onClick={() => void handleStartRemote()}
>
<Zap size={18} fill="currentColor" />
{!remoteSourceId
? '请选择源服务器'
: !remoteSourcePath.trim()
? '请填写源路径'
: targetIds.length === 0
? '请选择目标服务器'
: '跨服同步分发'}
</button>
</div>
</div>
)}
</div>
{/* Task status panel */}
<div className="flex w-[360px] shrink-0 flex-col bg-[#0d1117]">
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-4">
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-200">
<ListTree size={16} className="text-slate-400" />
</h4>
<button className="text-xs text-slate-400" onClick={() => onTasksChange([])}>
</button>
</div>
<div className="flex-1 space-y-4 overflow-auto p-3">
{tasks.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-slate-500">
<FileUp size={32} className="mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
) : (
tasks.map((task) => (
<div key={task.id} className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-800">
<div className="border-b border-slate-700 bg-slate-800/90 p-3">
<div className="mb-2 flex items-start justify-between">
<span className="pr-2 text-sm font-medium text-slate-100">{task.title}</span>
{task.status === 'running' ? (
<button
className="shrink-0 text-xs text-red-400"
onClick={() => {
task.items.forEach((item) => {
if (item.taskId) void cancelRemoteTransferTask(item.taskId)
})
}}
>
</button>
) : null}
</div>
<div className="text-xs text-slate-500">
{task.createdAt}
</div>
</div>
<div className="max-h-48 space-y-1 overflow-auto bg-slate-900/60 p-2">
{task.items.map((item) => (
<div key={item.id} className="rounded-xl p-2 hover:bg-slate-800">
<div className="flex justify-between text-xs">
<span className="truncate text-slate-300">{item.label}</span>
<div className="flex items-center gap-2">
<span className="text-slate-500">{item.message}</span>
<span className={item.status === 'success' ? 'text-emerald-400' : 'text-blue-400'}>{item.progress}%</span>
</div>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-black">
<div
className={`h-1 rounded-full ${item.status === 'success' ? 'bg-emerald-500' : item.status === 'error' ? 'bg-red-500' : 'bg-blue-400'}`}
style={{ width: `${item.progress}%` }}
/>
</div>
{item.status === 'success' ? (
<CheckCircle2 size={12} className="text-emerald-500" />
) : item.status === 'running' ? (
<RefreshCw size={12} className="animate-spin text-blue-400" />
) : null}
</div>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</Modal>
{/* SFTP file selector popup — mounted outside the main modal so it overlays on top */}
{showBrowser && (
<SftpFileSelectorModal
open={showBrowser}
connection={connections.find((c) => c.id === remoteSourceId) ?? connections[0]}
onSelect={(path) => {
setRemoteSourcePath(path)
setShowBrowser(false)
}}
onClose={() => setShowBrowser(false)}
/>
)}
</>
)
}