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

294 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react'
import {
AlertCircle,
Network,
Play,
RefreshCw,
StopCircle,
Trash2,
} from 'lucide-react'
import Modal from './Modal'
import {
createPortForward,
listPortForwards,
stopPortForward,
type PortForwardTunnel,
} from '../services/portForwards'
import type { Connection } from '../types'
interface PortForwardModalProps {
open: boolean
connections: Connection[]
initialConnectionId?: number | null
onClose: () => void
}
export default function PortForwardModal({
open,
connections,
initialConnectionId,
onClose,
}: PortForwardModalProps) {
const [tunnels, setTunnels] = useState<PortForwardTunnel[]>([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Form state
const [connectionId, setConnectionId] = useState<number>(0)
const [localPort, setLocalPort] = useState<number>(8080)
const [remoteHost, setRemoteHost] = useState<string>('127.0.0.1')
const [remotePort, setRemotePort] = useState<number>(80)
// Load tunnels
const fetchTunnels = async (showLoading = true) => {
if (showLoading) setLoading(true)
setError(null)
try {
const data = await listPortForwards()
setTunnels(data)
} catch (err: any) {
setError(err.message ?? '获取端口转发列表失败')
} finally {
if (showLoading) setLoading(false)
}
}
useEffect(() => {
if (open) {
fetchTunnels(true)
// Pre-select connection if provided
if (initialConnectionId) {
setConnectionId(initialConnectionId)
} else if (connections.length > 0) {
setConnectionId(connections[0].id)
}
}
}, [open, initialConnectionId, connections])
const handleStartTunnel = async (e: React.FormEvent) => {
e.preventDefault()
if (!connectionId) {
setError('请选择 SSH 连接')
return
}
setError(null)
setSubmitting(true)
try {
await createPortForward({
connectionId,
localPort,
remoteHost: remoteHost.trim(),
remotePort,
})
// Reset form options (keep connection, maybe increment local port)
setLocalPort((prev) => prev + 1)
await fetchTunnels(false)
} catch (err: any) {
setError(err.message ?? '启动端口转发失败')
} finally {
setSubmitting(false)
}
}
const handleStopTunnel = async (id: string) => {
setError(null)
try {
await stopPortForward(id)
await fetchTunnels(false)
} catch (err: any) {
setError(err.message ?? '停止端口转发失败')
}
}
if (!open) return null
return (
<Modal
title="端口转发管理"
onClose={onClose}
open={open}
maxWidth="max-w-5xl"
>
<div className="flex flex-col gap-6 md:flex-row">
{/* Left Column: Form to create a new tunnel */}
<div className="flex-1 rounded-2xl border border-border-main bg-surface-muted/30 p-5 md:max-w-xs lg:max-w-sm">
<h4 className="mb-4 flex items-center gap-2 text-sm font-semibold text-content-main">
<Play size={16} className="text-emerald-500" />
</h4>
<form onSubmit={handleStartTunnel} className="space-y-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-content-muted">
SSH
</label>
<select
value={connectionId}
onChange={(e) => setConnectionId(Number(e.target.value))}
className="w-full rounded-xl border border-border-main bg-surface-control px-3.5 py-2 text-sm text-content-main focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
>
{connections.length === 0 ? (
<option value={0}></option>
) : (
connections.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.host})
</option>
))
)}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-content-muted">
(Local Port)
</label>
<input
type="number"
min={1}
max={65535}
value={localPort}
onChange={(e) => setLocalPort(Number(e.target.value))}
className="w-full rounded-xl border border-border-main bg-surface-control px-3.5 py-2 text-sm text-content-main focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
required
/>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2 flex flex-col gap-1.5">
<label className="text-xs font-medium text-content-muted">
(Remote Host)
</label>
<input
type="text"
value={remoteHost}
onChange={(e) => setRemoteHost(e.target.value)}
className="w-full rounded-xl border border-border-main bg-surface-control px-3.5 py-2 text-sm text-content-main focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-content-muted">
</label>
<input
type="number"
min={1}
max={65535}
value={remotePort}
onChange={(e) => setRemotePort(Number(e.target.value))}
className="w-full rounded-xl border border-border-main bg-surface-control px-3.5 py-2 text-sm text-content-main focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
required
/>
</div>
</div>
{error && (
<div className="flex gap-2 rounded-xl bg-red-500/10 p-3 text-xs text-red-300 border border-red-500/20">
<AlertCircle size={16} className="shrink-0 text-red-400" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={submitting || connections.length === 0}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 py-2.5 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? '正在启动...' : '启动转发'}
</button>
</form>
</div>
{/* Right Column: List of running tunnels */}
<div className="flex-1 flex flex-col">
<div className="mb-4 flex items-center justify-between">
<h4 className="flex items-center gap-2 text-sm font-semibold text-content-main">
<Network size={16} className="text-blue-500 animate-pulse" />
</h4>
<button
onClick={() => fetchTunnels(true)}
disabled={loading}
className="rounded-xl border border-border-main bg-surface-muted p-2 text-content-muted transition hover:text-content-main hover:bg-surface-panel"
title="刷新列表"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<div className="flex-1 overflow-auto max-h-[400px] rounded-2xl border border-border-main bg-surface-panel/40 p-4">
{loading && tunnels.length === 0 ? (
<div className="flex h-48 items-center justify-center text-xs text-content-dim">
...
</div>
) : tunnels.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center text-content-dim">
<Network size={36} className="mb-2 text-content-dim/30" />
<span className="text-xs"></span>
</div>
) : (
<div className="space-y-3">
{tunnels.map((tunnel) => (
<div
key={tunnel.id}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-border-subtle bg-surface-card p-3.5 transition hover:border-border-main"
>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs font-semibold text-content-main">
<span className="max-w-[120px] truncate text-blue-400">
{tunnel.connectionName}
</span>
<span className="text-content-dim font-normal">|</span>
<span className="text-emerald-500 font-mono">
:{tunnel.localPort}
</span>
<span className="text-content-dim font-normal"></span>
<span className="text-content-muted font-mono">
{tunnel.remoteHost}:{tunnel.remotePort}
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-content-dim">
<span>
: {new Date(tunnel.createdAt).toLocaleTimeString()}
</span>
<span className="h-1 w-1 rounded-full bg-content-dim" />
<span className="flex items-center gap-1">
<span
className={`h-1.5 w-1.5 rounded-full ${
tunnel.status === 'running'
? 'bg-emerald-500 animate-pulse'
: tunnel.status === 'error'
? 'bg-red-500'
: 'bg-content-muted'
}`}
/>
{tunnel.status === 'running'
? '运行中'
: tunnel.status === 'error'
? '异常'
: '已停止'}
</span>
</div>
</div>
<button
onClick={() => handleStopTunnel(tunnel.id)}
className="self-end sm:self-center flex items-center gap-1 rounded-lg border border-red-500/20 bg-red-500/5 px-2 py-1 text-[11px] font-medium text-red-300 transition hover:bg-red-500/10 hover:text-red-200"
>
<StopCircle size={12} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
</Modal>
)
}