dev_arc_aws/src/pages/RemoteDesktop.tsx

187 lines
6.9 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import Guacamole from 'guacamole-common-js'
import { api, getToken, type Integration } from '../lib/api'
const TEXT_SECONDARY = '#7A7D85'
type SessionStatus = 'connecting' | 'connected' | 'error'
interface Session {
sessionId: string
hostId: number
name: string
status: SessionStatus
errorMessage: string
}
interface SessionHandle {
client: any
container: HTMLDivElement
}
export default function RemoteDesktop() {
const [hosts, setHosts] = useState<Integration[]>([])
const [sessions, setSessions] = useState<Session[]>([])
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const displayHostRef = useRef<HTMLDivElement>(null)
const handlesRef = useRef<Map<string, SessionHandle>>(new Map())
useEffect(() => {
api.listIntegrations().then(({ integrations }) => {
setHosts(integrations.filter((i) => i.type === 'remote_desktop'))
})
}, [])
useEffect(() => {
return () => {
handlesRef.current.forEach(({ client }) => client.disconnect())
}
}, [])
function patchSession(sessionId: string, patch: Partial<Session>) {
setSessions((prev) => prev.map((s) => (s.sessionId === sessionId ? { ...s, ...patch } : s)))
}
function openSession(host: Integration) {
const sessionId = crypto.randomUUID()
const session: Session = { sessionId, hostId: host.id, name: host.name, status: 'connecting', errorMessage: '' }
setSessions((prev) => [...prev, session])
setActiveSessionId(sessionId)
const container = document.createElement('div')
container.className = 'h-full w-full overflow-auto'
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
// Guacamole.WebSocketTunnel appends its own "?<data>" query string on connect(),
// so the tunnel URL itself must not already contain one.
const tunnel = new Guacamole.WebSocketTunnel(`${proto}://${window.location.host}/api/guacamole`)
const client = new Guacamole.Client(tunnel)
handlesRef.current.set(sessionId, { client, container })
client.onerror = (err: { message?: string }) => {
patchSession(sessionId, { status: 'error', errorMessage: err?.message ?? 'Connection failed' })
}
client.onstatechange = (state: number) => {
if (state === 3) patchSession(sessionId, { status: 'connected' })
}
container.appendChild(client.getDisplay().getElement())
client.connect(`token=${encodeURIComponent(token ?? '')}&integrationId=${host.id}`)
}
function closeSession(sessionId: string) {
const handle = handlesRef.current.get(sessionId)
handle?.client.disconnect()
handle?.container.remove()
handlesRef.current.delete(sessionId)
setSessions((prev) => {
const next = prev.filter((s) => s.sessionId !== sessionId)
if (activeSessionId === sessionId) {
setActiveSessionId(next.length > 0 ? next[next.length - 1].sessionId : null)
}
return next
})
}
// Show only the active session's display element; keep the rest mounted off-DOM so
// background sessions stay connected while their tab isn't focused.
useEffect(() => {
const host = displayHostRef.current
if (!host) return
host.innerHTML = ''
const active = activeSessionId ? handlesRef.current.get(activeSessionId) : null
if (active) host.appendChild(active.container)
}, [activeSessionId])
const activeSession = sessions.find((s) => s.sessionId === activeSessionId)
return (
<div className="flex h-full gap-4">
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>
Remote Desktops
</p>
{hosts.length === 0 && (
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
No remote desktop integrations configured. Add one in Settings Integrations.
</p>
)}
{hosts.map((h) => (
<button
key={h.id}
onClick={() => openSession(h)}
className="mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm transition hover:bg-white/10"
style={{ color: '#E8E6E0' }}
>
{h.name}
</button>
))}
</div>
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-white/10 bg-black">
<div className="flex items-center gap-1 overflow-x-auto border-b border-white/10 px-2 py-1.5">
{sessions.length === 0 && (
<p className="px-1 text-sm" style={{ color: TEXT_SECONDARY }}>
Select a remote desktop to connect
</p>
)}
{sessions.map((s) => (
<div
key={s.sessionId}
onClick={() => setActiveSessionId(s.sessionId)}
className="flex shrink-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-sm"
style={{
background: activeSessionId === s.sessionId ? 'rgba(200,164,52,0.15)' : 'transparent',
color: activeSessionId === s.sessionId ? '#C8A434' : '#E8E6E0',
}}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{
background: s.status === 'connected' ? '#4ADE80' : s.status === 'error' ? '#F87171' : '#FBBF24',
}}
/>
{s.name}
<button
onClick={(e) => {
e.stopPropagation()
closeSession(s.sessionId)
}}
className="ml-1 rounded px-1 text-xs hover:bg-white/10"
style={{ color: TEXT_SECONDARY }}
title="Disconnect"
>
</button>
</div>
))}
</div>
{activeSession && (
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
{activeSession.name}
</p>
<div className="flex items-center gap-3">
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
{activeSession.status === 'connecting' && 'Connecting…'}
{activeSession.status === 'connected' && 'Connected'}
{activeSession.status === 'error' && `Error: ${activeSession.errorMessage}`}
</p>
<button
onClick={() => closeSession(activeSession.sessionId)}
className="rounded-md border border-white/10 px-2 py-1 text-xs transition hover:bg-white/10"
style={{ color: '#E8E6E0' }}
>
Disconnect
</button>
</div>
</div>
)}
<div ref={displayHostRef} className="flex-1 overflow-auto" />
</div>
</div>
)
}