Files
floci-ui/packages/frontend/src/components/LightsailInstancePanel.tsx
T
2026-10-11 14:19:00 -04:00

136 lines
19 KiB
TypeScript

import {ChevronDown, ExternalLink, HardDrive, Play, RotateCw, Settings, Square, Terminal, Trash2} from 'lucide-react'
import {useEffect, useRef, useState} from 'react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {deleteCloudResource, deployLightsailNodeApplication, deployLightsailStaticFiles, rebootCloudResource, startCloudResource, stopCloudResource, updateCloudResource} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
type Tab = 'overview' | 'deploy' | 'networking' | 'storage' | 'connect'
type PublicPort = {fromPort: number; toPort: number; protocol: string}
type HostPortMapping = {instancePort: number; host: string; hostPort: number; protocol?: string}
type Disk = {name: string; sizeInGb: number; isSystemDisk: boolean; path?: string; state?: string}
export function LightsailInstancePanel({resource, runtimeReachable, onDeleted}: {resource?: CloudResource; runtimeReachable: boolean; onDeleted?: () => void}) {
const queryClient = useQueryClient()
const [tab, setTab] = useState<Tab>('overview')
const [fromPort, setFromPort] = useState('80')
const [toPort, setToPort] = useState('80')
const [protocol, setProtocol] = useState('tcp')
const [nodePort, setNodePort] = useState('3000')
const [startCommand, setStartCommand] = useState('')
const [confirmDelete, setConfirmDelete] = useState(false)
const fileInput = useRef<HTMLInputElement>(null)
const ports = publicPorts(resource?.metadata.networking)
const hostMappings = hostPortMappings(resource?.metadata.hostPortMappings)
const disks = instanceDisks(resource?.metadata.hardware)
const status = resource?.status?.toLowerCase()
const publicIp = stringMetadata(resource?.metadata.publicIpAddress)
const privateIp = stringMetadata(resource?.metadata.privateIpAddress)
const staticIp = staticIpAddress(resource?.metadata.networking)
useEffect(() => {
setTab('overview')
setFromPort('80')
setToPort('80')
setProtocol('tcp')
setConfirmDelete(false)
}, [resource?.id])
const refresh = () => {
if (!resource) return
void queryClient.invalidateQueries({queryKey: ['cloud-resources', resource.cloud, resource.service]})
void queryClient.invalidateQueries({queryKey: ['cloud-resource', resource.cloud, resource.service, resource.id]})
}
const lifecycle = useMutation({
mutationFn: (action: 'start' | 'stop' | 'reboot') => {
if (!resource) throw new Error('Select a Lightsail instance.')
if (action === 'start') return startCloudResource(resource.cloud, resource.service, resource.id)
if (action === 'stop') return stopCloudResource(resource.cloud, resource.service, resource.id)
return rebootCloudResource(resource.cloud, resource.service, resource.id)
},
onSuccess: refresh,
})
const portMutation = useMutation({
mutationFn: (values: {action: 'openPort' | 'closePort'; fromPort: number; toPort: number; protocol: string}) => {
if (!resource) throw new Error('Select a Lightsail instance.')
return updateCloudResource(resource.cloud, resource.service, resource.id, values)
},
onSuccess: refresh,
})
const deleteMutation = useMutation({
mutationFn: () => {
if (!resource) throw new Error('Select a Lightsail instance.')
return deleteCloudResource(resource.cloud, resource.service, resource.id)
},
onSuccess: () => {
refresh()
onDeleted?.()
},
})
const deployment = useMutation({
mutationFn: (files: File[]) => {
if (!resource) throw new Error('Select a Lightsail instance.')
return deployLightsailStaticFiles(resource.id, files)
},
})
const nodeDeployment = useMutation({
mutationFn: async (files: File[]) => {
if (!resource) throw new Error('Select a Lightsail instance.')
const port = Number(nodePort)
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Port must be between 1 and 65535.')
const result = await deployLightsailNodeApplication(resource.id, files, startCommand, port)
try {
await updateCloudResource(resource.cloud, resource.service, resource.id, {action: 'openPort', fromPort: port, toPort: port, protocol: 'tcp'})
} catch (error) {
throw Object.assign(new Error(`Deployment succeeded, but port ${port} could not be opened: ${error instanceof Error ? error.message : 'unknown error'}`), {cause: error})
}
return result
},
onSuccess: refresh,
})
if (!resource) {
return <section className="table-panel" style={{marginTop: 16}}><div className="empty compact"><h3>Select a Lightsail instance</h3><p>Manage its lifecycle, networking, storage metadata, and connection details.</p></div></section>
}
const validPortRange = validPort(fromPort) && validPort(toPort) && Number(fromPort) <= Number(toPort)
const busy = lifecycle.isPending || portMutation.isPending || deleteMutation.isPending || deployment.isPending || nodeDeployment.isPending
const error = lifecycle.error ?? portMutation.error ?? deleteMutation.error ?? deployment.error ?? nodeDeployment.error
const nginx = stringMetadata(resource.metadata.blueprintId) === 'nginx'
const nodejs = stringMetadata(resource.metadata.blueprintId) === 'nodejs'
const wordpress = stringMetadata(resource.metadata.blueprintId) === 'wordpress'
const wordpressPort = hostMappings.find((mapping) => mapping.instancePort === 80)
const stateLabel = resource.status ?? 'Unknown'
return <section className="table-panel lightsail-instance-panel" style={{marginTop: 16}}>
<header className="lightsail-instance-header">
<div><p className="eyebrow">Amazon Lightsail instance</p><h2>{resource.name}</h2><p className="muted"><code>{resource.id}</code>{resource.region && <> · {resource.region}</>}</p></div>
<div className="lightsail-instance-controls"><span className={`runtime-state ${status === 'running' ? 'ready' : 'pending'}`}>{stateLabel}</span><details className="dynamodb-actions"><summary>Actions <ChevronDown size={14}/></summary><div className="dynamodb-actions-menu lightsail-actions-menu"><button type="button" onClick={() => setTab('overview')}><Settings size={14}/>Manage</button><button type="button" disabled={!runtimeReachable || busy || status !== 'stopped'} onClick={() => lifecycle.mutate('start')}><Play size={14}/>Start</button><button type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => lifecycle.mutate('stop')}><Square size={14}/>Stop</button><button type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => lifecycle.mutate('reboot')}><RotateCw size={14}/>Reboot</button><button className="dynamodb-action-delete" type="button" disabled={!runtimeReachable || busy} onClick={() => setConfirmDelete(true)}><Trash2 size={14}/>Delete</button></div></details></div>
</header>
<div className="drawer-tabs lightsail-instance-tabs" role="tablist" aria-label="Instance details">
<button className={`drawer-tab ${tab === 'overview' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'overview'} onClick={() => setTab('overview')}>Overview</button>
{(nginx || nodejs) && <button className={`drawer-tab ${tab === 'deploy' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'deploy'} onClick={() => setTab('deploy')}>Deploy</button>}
<button className={`drawer-tab ${tab === 'networking' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'networking'} onClick={() => setTab('networking')}>Networking</button>
<button className={`drawer-tab ${tab === 'storage' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'storage'} onClick={() => setTab('storage')}>Storage</button>
<button className={`drawer-tab ${tab === 'connect' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'connect'} onClick={() => setTab('connect')}>Connect</button>
</div>
{tab === 'overview' && <div className="lightsail-overview"><div className="lightsail-detail-grid"><Detail label="Blueprint" value={stringMetadata(resource.metadata.blueprintName) ?? stringMetadata(resource.metadata.blueprintId)}/><Detail label="Plan" value={stringMetadata(resource.metadata.bundleId)}/><Detail label="Public IP" value={publicIp}/><Detail label="Private IP" value={privateIp}/></div>{wordpress && wordpressPort && <p className="compact-text"><a className="button compact" href={browserUrl(wordpressPort)} target="_blank" rel="noreferrer">Open site <ExternalLink size={13}/></a> <a className="button compact" href={`${browserUrl(wordpressPort)}/wp-admin`} target="_blank" rel="noreferrer">Open WordPress admin <ExternalLink size={13}/></a></p>}<p className="muted compact-text">Use the Networking tab to manage public firewall rules and open mapped browser endpoints.</p></div>}
{tab === 'deploy' && nginx && <div className="lightsail-tab-content"><h3>Deploy static files</h3><p className="muted compact-text">Selected files are copied to <code>/usr/share/nginx/html</code>. Folder paths are preserved when your browser provides them.</p><input ref={fileInput} type="file" multiple hidden onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length) deployment.mutate(files); event.currentTarget.value = '' }}/><button className="button primary" type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => fileInput.current?.click()}>{deployment.isPending ? 'Deploying' : 'Choose files and deploy'}</button>{deployment.data && <p className="muted compact-text">Deployment complete: {deployment.data.files} {deployment.data.files === 1 ? 'file' : 'files'} copied.</p>}</div>}
{tab === 'deploy' && nodejs && <div className="lightsail-tab-content"><h3>Deploy Node.js application</h3><p className="muted compact-text">Upload a zip archive or source files. <code>package.json</code> is required; <code>package-lock.json</code> uses <code>npm ci</code>, otherwise <code>npm install</code>. Existing releases remain active if deployment fails.</p><div className="dynamic-form lightsail-port-form"><label className="dynamic-field"><span>Start command (optional)</span><input className="input" value={startCommand} placeholder="Uses scripts.start" onChange={(event) => setStartCommand(event.target.value)} disabled={busy}/></label><label className="dynamic-field"><span>PORT</span><input className="input" type="number" min="1" max="65535" value={nodePort} onChange={(event) => setNodePort(event.target.value)} disabled={busy}/></label></div><input ref={fileInput} type="file" multiple hidden onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length) nodeDeployment.mutate(files); event.currentTarget.value = '' }}/><button className="button primary" type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => fileInput.current?.click()}>{nodeDeployment.isPending ? 'Installing and checking health' : 'Choose zip or files and deploy'}</button>{nodeDeployment.data && <p className="muted compact-text">Release <code>{nodeDeployment.data.releaseId}</code> is healthy on port {nodeDeployment.data.port}. The browser port was opened automatically.</p>}</div>}
{tab === 'networking' && <div className="lightsail-tab-content"><div className="lightsail-detail-grid"><Detail label="Public IPv4" value={publicIp}/><Detail label="Private IPv4" value={privateIp}/><Detail label="Static IP" value={staticIp}/></div><p className="muted compact-text">Static IP is metadata-only. Attach or detach operations are not available from this console.</p><h3>Firewall rules</h3><p className="muted compact-text">Public ports returned by the Lightsail instance networking API.</p><form className="dynamic-form lightsail-port-form" onSubmit={(event) => { event.preventDefault(); if (validPortRange) portMutation.mutate({action: 'openPort', fromPort: Number(fromPort), toPort: Number(toPort), protocol}) }}><label className="dynamic-field"><span>From port</span><input className="input" type="number" min="0" max="65535" value={fromPort} onChange={(event) => setFromPort(event.target.value)} disabled={busy}/></label><label className="dynamic-field"><span>To port</span><input className="input" type="number" min="0" max="65535" value={toPort} onChange={(event) => setToPort(event.target.value)} disabled={busy}/></label><label className="dynamic-field"><span>Protocol</span><select className="input" value={protocol} onChange={(event) => setProtocol(event.target.value)} disabled={busy}><option value="tcp">TCP</option><option value="udp">UDP</option><option value="all">All</option></select></label><button className="button primary" type="submit" disabled={!runtimeReachable || busy || !validPortRange}>{portMutation.isPending ? 'Adding' : 'Add rule'}</button></form>{ports.length > 0 ? <div className="cosmos-items-table"><table><thead><tr><th>Port range</th><th>Protocol</th><th/></tr></thead><tbody>{ports.map((port) => <tr key={`${port.protocol}-${port.fromPort}-${port.toPort}`}><td>{formatPortRange(port)}</td><td>{port.protocol.toUpperCase()}</td><td><button className="button compact danger" type="button" disabled={!runtimeReachable || busy} onClick={() => portMutation.mutate({action: 'closePort', ...port})}>Remove</button></td></tr>)}</tbody></table></div> : <p className="muted compact-text">No public firewall rules are configured.</p>}<h3>Browser links</h3>{hostMappings.length > 0 ? <div className="cosmos-items-table"><table><thead><tr><th>Instance port</th><th>Host mapping</th><th/></tr></thead><tbody>{hostMappings.map((mapping) => <tr key={`${mapping.protocol ?? 'tcp'}-${mapping.instancePort}`}><td>{mapping.instancePort}</td><td><code>{mapping.host}:{mapping.hostPort}</code></td><td><a className="button compact" href={`http://${mapping.host}:${mapping.hostPort}`} target="_blank" rel="noreferrer"><ExternalLink size={13}/>Open</a></td></tr>)}</tbody></table></div> : publicIp ? <a className="button compact" href={`http://${publicIp}`} target="_blank" rel="noreferrer"><ExternalLink size={13}/>Open HTTP endpoint</a> : <p className="muted compact-text">No browser endpoint was returned for this instance.</p>}</div>}
{tab === 'storage' && <div className="lightsail-tab-content"><h3><HardDrive size={17}/> Disks</h3><p className="muted compact-text">Disk details are metadata-only. Creating, attaching, detaching, or resizing disks is not available from this console.</p>{disks.length > 0 ? <div className="cosmos-items-table"><table><thead><tr><th>Name</th><th>Size</th><th>Type</th><th>State</th></tr></thead><tbody>{disks.map((disk) => <tr key={disk.name}><td>{disk.name}</td><td>{disk.sizeInGb} GB</td><td>{disk.isSystemDisk ? 'System disk' : 'Attached disk'}</td><td>{disk.state ?? 'Unknown'}</td></tr>)}</tbody></table></div> : <p className="muted compact-text">No disk metadata was returned by the runtime.</p>}</div>}
{tab === 'connect' && <div className="lightsail-tab-content"><h3><Terminal size={17}/> Connect to instance</h3><p className="muted compact-text">Browser SSH and key injection are unavailable in the local Lightsail runtime. This console does not generate credentials or claim a connection is available.</p><div className="lightsail-detail-grid"><Detail label="Default username" value={stringMetadata(resource.metadata.username)}/><Detail label="Public IPv4" value={publicIp}/></div></div>}
{confirmDelete && <div className="lightsail-delete-confirm"><span>Delete <strong>{resource.name}</strong>? This cannot be undone.</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate()}>{deleteMutation.isPending ? 'Deleting' : 'Delete instance'}</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDelete(false)}>Cancel</button></div>}
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
</section>
}
function Detail({label, value}: {label: string; value?: string}) { return <div className="lightsail-detail"><span>{label}</span><code>{value ?? 'Not assigned'}</code></div> }
function validPort(value: string): boolean { return /^\d+$/.test(value) && Number(value) >= 0 && Number(value) <= 65535 }
function formatPortRange(port: PublicPort): string { return port.fromPort === port.toPort ? String(port.fromPort) : `${port.fromPort}-${port.toPort}` }
function browserUrl(mapping: HostPortMapping): string { return `http://${mapping.host}:${mapping.hostPort}` }
function stringMetadata(value: unknown): string | undefined { return typeof value === 'string' && value ? value : undefined }
function publicPorts(networking: unknown): PublicPort[] { if (!networking || typeof networking !== 'object' || !Array.isArray((networking as {ports?: unknown}).ports)) return []; return (networking as {ports: unknown[]}).ports.flatMap((port) => { if (!port || typeof port !== 'object') return []; const value = port as {fromPort?: unknown; toPort?: unknown; protocol?: unknown}; return typeof value.fromPort === 'number' && typeof value.toPort === 'number' && typeof value.protocol === 'string' ? [{fromPort: value.fromPort, toPort: value.toPort, protocol: value.protocol}] : [] }) }
function hostPortMappings(value: unknown): HostPortMapping[] { if (!Array.isArray(value)) return []; return value.flatMap((mapping) => { if (!mapping || typeof mapping !== 'object') return []; const item = mapping as {instancePort?: unknown; host?: unknown; hostPort?: unknown; protocol?: unknown}; return typeof item.instancePort === 'number' && typeof item.host === 'string' && typeof item.hostPort === 'number' ? [{instancePort: item.instancePort, host: item.host, hostPort: item.hostPort, protocol: typeof item.protocol === 'string' ? item.protocol : undefined}] : [] }) }
function staticIpAddress(networking: unknown): string | undefined { return networking && typeof networking === 'object' ? stringMetadata((networking as {staticIp?: unknown}).staticIp) : undefined }
function instanceDisks(hardware: unknown): Disk[] { if (!hardware || typeof hardware !== 'object' || !Array.isArray((hardware as {disks?: unknown}).disks)) return []; return (hardware as {disks: unknown[]}).disks.flatMap((disk) => { if (!disk || typeof disk !== 'object') return []; const value = disk as {name?: unknown; sizeInGb?: unknown; isSystemDisk?: unknown; path?: unknown; state?: unknown}; return typeof value.name === 'string' && typeof value.sizeInGb === 'number' && typeof value.isSystemDisk === 'boolean' ? [{name: value.name, sizeInGb: value.sizeInGb, isSystemDisk: value.isSystemDisk, path: stringMetadata(value.path), state: stringMetadata(value.state)}] : [] }) }