dev_arc_aws/src/pages/Tunnels.tsx

288 lines
10 KiB
TypeScript
Raw Normal View History

import { useEffect, useState } from 'react'
import { Plus, Play, Square, Trash2, ArrowRightLeft, ArrowLeftRight, Shuffle } from 'lucide-react'
import { api, type Tunnel, type Integration } from '../lib/api'
const TEXT_PRIMARY = '#E8E6E0'
const TEXT_SECONDARY = '#7A7D85'
const GOLD = '#C8A434'
const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.08)',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
}
const MODE_LABEL: Record<Tunnel['mode'], string> = {
local: 'Local Forward',
remote: 'Remote Forward',
dynamic: 'Dynamic (SOCKS5)',
}
const MODE_ICON: Record<Tunnel['mode'], React.ComponentType<{ size?: number; color?: string }>> = {
local: ArrowRightLeft,
remote: ArrowLeftRight,
dynamic: Shuffle,
}
const STATUS_COLOR: Record<Tunnel['status'], string> = {
stopped: TEXT_SECONDARY,
connecting: GOLD,
retrying: '#E0A030',
connected: '#2ECC71',
error: '#E74C3C',
}
function inputStyle(): React.CSSProperties {
return {
backgroundColor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '6px',
padding: '6px 10px',
color: TEXT_PRIMARY,
fontSize: '13px',
width: '100%',
}
}
export default function Tunnels() {
const [tunnels, setTunnels] = useState<Tunnel[]>([])
const [hosts, setHosts] = useState<Integration[]>([])
const [showForm, setShowForm] = useState(false)
const [busyId, setBusyId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState('')
const [integrationId, setIntegrationId] = useState<number | ''>('')
const [mode, setMode] = useState<Tunnel['mode']>('local')
const [sourcePort, setSourcePort] = useState('')
const [endpointHost, setEndpointHost] = useState('')
const [endpointPort, setEndpointPort] = useState('')
const [autoStart, setAutoStart] = useState(false)
function refresh() {
api.listTunnels().then(({ tunnels }) => setTunnels(tunnels))
}
useEffect(() => {
refresh()
api.listIntegrations().then(({ integrations }) => setHosts(integrations.filter((i) => i.type === 'ssh')))
const interval = setInterval(refresh, 3000)
return () => clearInterval(interval)
}, [])
async function handleCreate() {
if (!name.trim() || !integrationId || !sourcePort) {
setError('Name, SSH host, and source port are required')
return
}
setError(null)
try {
await api.createTunnel({
name: name.trim(),
integrationId: Number(integrationId),
mode,
sourcePort: Number(sourcePort),
endpointHost: mode === 'dynamic' ? '' : endpointHost,
endpointPort: mode === 'dynamic' ? 0 : Number(endpointPort) || 0,
autoStart,
})
setName('')
setIntegrationId('')
setSourcePort('')
setEndpointHost('')
setEndpointPort('')
setAutoStart(false)
setShowForm(false)
refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create tunnel')
}
}
async function handleConnect(id: number) {
setBusyId(id)
try {
await api.connectTunnel(id)
refresh()
} finally {
setBusyId(null)
}
}
async function handleDisconnect(id: number) {
setBusyId(id)
try {
await api.disconnectTunnel(id)
refresh()
} finally {
setBusyId(null)
}
}
async function handleDelete(id: number) {
setBusyId(id)
try {
await api.deleteTunnel(id)
refresh()
} finally {
setBusyId(null)
}
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold" style={{ color: TEXT_PRIMARY }}>
SSH Tunnels
</h1>
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
Local / remote / dynamic SOCKS5 port forwarding through your configured SSH hosts.
</p>
</div>
<button
onClick={() => setShowForm((s) => !s)}
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium"
style={{ backgroundColor: GOLD, color: '#0A0A0C' }}
>
<Plus size={16} />
New Tunnel
</button>
</div>
{showForm && (
<div style={cardBase} className="space-y-3">
{error && <div style={{ color: '#E74C3C', fontSize: '13px' }}>{error}</div>}
<div className="grid grid-cols-2 gap-3">
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Name</label>
<input style={inputStyle()} value={name} onChange={(e) => setName(e.target.value)} placeholder="my-tunnel" />
</div>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>SSH Host</label>
<select
style={inputStyle()}
value={integrationId}
onChange={(e) => setIntegrationId(e.target.value ? Number(e.target.value) : '')}
>
<option value="">Select host</option>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</div>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Mode</label>
<select style={inputStyle()} value={mode} onChange={(e) => setMode(e.target.value as Tunnel['mode'])}>
<option value="local">Local Forward</option>
<option value="remote">Remote Forward</option>
<option value="dynamic">Dynamic (SOCKS5)</option>
</select>
</div>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>
{mode === 'dynamic' ? 'SOCKS5 Listen Port' : 'Source Port'}
</label>
<input style={inputStyle()} value={sourcePort} onChange={(e) => setSourcePort(e.target.value)} placeholder="8080" />
</div>
{mode !== 'dynamic' && (
<>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Endpoint Host</label>
<input style={inputStyle()} value={endpointHost} onChange={(e) => setEndpointHost(e.target.value)} placeholder="127.0.0.1" />
</div>
<div>
<label style={{ color: TEXT_SECONDARY, fontSize: '11px' }}>Endpoint Port</label>
<input style={inputStyle()} value={endpointPort} onChange={(e) => setEndpointPort(e.target.value)} placeholder="80" />
</div>
</>
)}
</div>
<label className="flex items-center gap-2 text-xs" style={{ color: TEXT_PRIMARY }}>
<input type="checkbox" checked={autoStart} onChange={(e) => setAutoStart(e.target.checked)} />
Auto-start when the server boots
</label>
<div className="flex gap-2">
<button onClick={handleCreate} className="rounded-md px-3 py-1.5 text-sm font-medium" style={{ backgroundColor: GOLD, color: '#0A0A0C' }}>
Create
</button>
<button onClick={() => setShowForm(false)} className="rounded-md px-3 py-1.5 text-sm" style={{ color: TEXT_SECONDARY }}>
Cancel
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tunnels.map((t) => {
const Icon = MODE_ICON[t.mode]
const host = hosts.find((h) => h.id === t.integrationId)
return (
<div key={t.id} style={cardBase} className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon size={16} color={GOLD} />
<span className="font-medium" style={{ color: TEXT_PRIMARY }}>
{t.name}
</span>
</div>
<span
className="text-xs rounded-full px-2 py-0.5"
style={{ color: STATUS_COLOR[t.status], border: `1px solid ${STATUS_COLOR[t.status]}33` }}
>
{t.status}
{t.status === 'retrying' ? ` (${t.retryCount}/${t.maxRetries})` : ''}
</span>
</div>
<div style={{ color: TEXT_SECONDARY, fontSize: '12px' }} className="space-y-1">
<div>{MODE_LABEL[t.mode]}</div>
<div>via {host?.name ?? `integration #${t.integrationId}`}</div>
<div>
localhost:{t.sourcePort} {t.mode === 'dynamic' ? '(SOCKS5 proxy)' : `${t.endpointHost}:${t.endpointPort}`}
</div>
{t.error && <div style={{ color: '#E74C3C' }}>{t.error}</div>}
</div>
<div className="flex gap-2">
{t.status === 'connected' || t.status === 'connecting' || t.status === 'retrying' ? (
<button
disabled={busyId === t.id}
onClick={() => handleDisconnect(t.id)}
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}
>
<Square size={12} /> Stop
</button>
) : (
<button
disabled={busyId === t.id}
onClick={() => handleConnect(t.id)}
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
style={{ backgroundColor: GOLD, color: '#0A0A0C' }}
>
<Play size={12} /> Start
</button>
)}
<button
disabled={busyId === t.id}
onClick={() => handleDelete(t.id)}
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
style={{ border: '1px solid rgba(231,76,60,0.3)', color: '#E74C3C' }}
>
<Trash2 size={12} /> Delete
</button>
</div>
</div>
)
})}
{tunnels.length === 0 && !showForm && (
<div style={{ color: TEXT_SECONDARY, fontSize: '13px' }}>No tunnels configured yet.</div>
)}
</div>
</div>
)
}