Files
floci-ui/packages/frontend/src/components/ComputePanel.tsx
T

1461 lines
80 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import ec2CpuAnimation from '../../../../svgAsset/animated-cpu.svg'
import { CopyValueButton } from './CopyValueButton'
import { ServerLaunchProgress } from './ServerLaunchProgress'
import {
AlertTriangle,
Cpu,
HardDrive,
Loader2,
Network,
Play,
Plus,
Power,
RefreshCw,
RotateCcw,
Save,
Search,
Server,
Terminal,
Trash2,
X,
} from 'lucide-react'
import {
authorizeEc2SecurityGroupIngress,
checkEc2InstanceSsh,
createEc2Ami,
createEc2KeyPair,
createEc2SecurityGroup,
deregisterEc2Ami,
describeEc2Instance,
getEc2ConsoleOutput,
listEc2Amis,
listEc2KeyPairs,
listEc2SecurityGroups,
listEc2Subnets,
listEc2Vpcs,
rebootEc2Instance,
startEc2Instance,
stopEc2Instance,
terminateEc2Instance,
updateEc2InstanceSecurityGroups,
updateEc2InstanceTags,
type Ec2Image,
type Ec2KeyPair,
type Ec2SecurityGroup,
type Ec2SecurityGroupRef,
type Ec2Subnet,
type Ec2Tag,
type Ec2Vpc,
type IpPermissionInput,
} from '@/api/aws/ec2.api'
import { createCloudResource } from '@/api/cloudProxyClient'
import type { CloudProvider } from '@/types/cloud'
import type { CloudResource } from '@/types/resource'
import alpineImage from '../../../../OS_img/Alpine_Distro.jpg'
import amazonLinuxImage from '../../../../OS_img/amazon_Linux.png'
import debianImage from '../../../../OS_img/debian.jpg'
import redHatImage from '../../../../OS_img/RedHat.png'
import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png'
const EC2_LAUNCH_MINIMUM_MS = 5_000
// ─── Props ────────────────────────────────────────────────────────────────────
interface ComputePanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable?: boolean
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function stateClass(state?: string): string {
switch (state) {
case 'running': return 'healthy'
case 'stopped': return 'inactive'
case 'pending':
case 'stopping': return 'pending'
case 'terminated':
case 'shutting-down': return 'error'
default: return 'unknown'
}
}
// ─── Tag editor (inline) ──────────────────────────────────────────────────────
function InlineTagEditor({ instanceId, initialTags }: { instanceId: string; initialTags: Ec2Tag[] }) {
const qc = useQueryClient()
const [rows, setRows] = useState<Ec2Tag[]>(initialTags)
const [err, setErr] = useState('')
const mutation = useMutation({
mutationFn: ({ toAdd, toRemove }: { toAdd: Ec2Tag[]; toRemove: string[] }) =>
updateEc2InstanceTags(instanceId, toAdd, toRemove),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['ec2', 'instance', instanceId] })
void qc.invalidateQueries({ queryKey: ['ec2', 'instances'] })
},
onError: (e) => setErr(e instanceof Error ? e.message : 'Save failed'),
})
function addRow() { setRows((prev) => [...prev, { key: '', value: '' }]) }
function removeRow(idx: number) { setRows((prev) => prev.filter((_, i) => i !== idx)) }
function setKey(idx: number, key: string) { setRows((prev) => prev.map((r, i) => i === idx ? { ...r, key } : r)) }
function setValue(idx: number, value: string) { setRows((prev) => prev.map((r, i) => i === idx ? { ...r, value } : r)) }
function handleSave() {
const newTags = rows.filter((r) => r.key.trim())
const newKeys = new Set(newTags.map((t) => t.key))
const oldKeys = new Set(initialTags.map((t) => t.key))
const toAdd = newTags.filter((t) => !oldKeys.has(t.key) || initialTags.find((ot) => ot.key === t.key)?.value !== t.value)
const toRemove = initialTags.map((t) => t.key).filter((k) => !newKeys.has(k))
setErr('')
mutation.mutate({ toAdd, toRemove })
}
return (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
{rows.map((tag, idx) => (
<div key={idx} className="field-row" style={{ gap: 4 }}>
<input
className="input"
placeholder="Key"
value={tag.key}
onChange={(e) => setKey(idx, e.target.value)}
style={{ flex: 1 }}
/>
<input
className="input"
placeholder="Value"
value={tag.value}
onChange={(e) => setValue(idx, e.target.value)}
style={{ flex: 2 }}
/>
<button className="icon-btn danger" onClick={() => removeRow(idx)} title="Remove">
<Trash2 size={12} />
</button>
</div>
))}
</div>
{err && <p style={{ fontSize: 11, color: 'var(--status-error)', margin: '0 0 6px' }}>{err}</p>}
<div style={{ display: 'flex', gap: 6 }}>
<button className="button compact" onClick={addRow}>
<Plus size={12} />
Add tag
</button>
<button className="button compact primary" onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending ? <Loader2 size={12} /> : <Save size={12} />}
Save tags
</button>
</div>
</div>
)
}
function InlineSecurityGroupEditor({ instanceId, vpcId, initialGroups }: {
instanceId: string; vpcId?: string; initialGroups: Ec2SecurityGroupRef[]
}) {
const qc = useQueryClient()
const [selected, setSelected] = useState(() => new Set(initialGroups.map((group) => group.id).filter(Boolean) as string[]))
const groups = useQuery({queryKey: ['ec2', 'security-groups', vpcId], queryFn: ({signal}) => listEc2SecurityGroups(vpcId, signal)})
const mutation = useMutation({
mutationFn: (groupIds: string[]) => updateEc2InstanceSecurityGroups(instanceId, groupIds),
onSuccess: () => {
void qc.invalidateQueries({queryKey: ['ec2', 'instance', instanceId]})
void qc.invalidateQueries({queryKey: ['ec2', 'instances']})
void qc.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'compute']})
},
})
const compatibleGroups = (groups.data ?? []).filter((group) => !vpcId || group.vpcId === vpcId)
return <div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
{compatibleGroups.map((group) => <label key={group.groupId} style={{display: 'flex', gap: 6, alignItems: 'center'}}>
<input type="checkbox" checked={selected.has(group.groupId)} onChange={(event) => {
setSelected((previous) => {
const next = new Set(previous)
if (event.target.checked) next.add(group.groupId)
else next.delete(group.groupId)
return next
})
}}/>
<span>{group.groupName} <code>{group.groupId}</code></span>
</label>)}
{groups.isError && <p className="error-text compact-text">Unable to load security groups.</p>}
{mutation.isError && <p className="error-text compact-text">{mutation.error instanceof Error ? mutation.error.message : 'Save failed'}</p>}
<button className="button primary compact" disabled={groups.isLoading || selected.size === 0 || mutation.isPending} onClick={() => mutation.mutate([...selected])}>
<Save size={13}/>{mutation.isPending ? 'Saving' : 'Save security groups'}
</button>
</div>
}
// ─── Create AMI modal (inline) ────────────────────────────────────────────────
function CreateAmiModal({ instanceId, instanceName, onClose, onCreated }: {
instanceId: string
instanceName: string
onClose: () => void
onCreated: () => void
}) {
const [name, setName] = useState(`${instanceName}-ami`)
const [description, setDescription] = useState('')
const [noReboot, setNoReboot] = useState(false)
const [err, setErr] = useState('')
const qc = useQueryClient()
const mutation = useMutation({
mutationFn: () => createEc2Ami(instanceId, {
name: name.trim(),
description: description || undefined,
noReboot,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['ec2', 'amis'] })
onCreated()
onClose()
},
onError: (e) => setErr(e instanceof Error ? e.message : 'AMI creation failed'),
})
function handleSubmit() {
if (!name.trim()) { setErr('Name is required.'); return }
setErr('')
mutation.mutate()
}
return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="create-table-modal" style={{ maxWidth: 420 }}>
<h3>Create AMI</h3>
<div className="modal-section">
<p className="modal-section-title">Image name</p>
<input
className="input"
style={{ width: '100%', minWidth: 'unset' }}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
/>
</div>
<div className="modal-section">
<p className="modal-section-title">Description — optional</p>
<input
className="input"
style={{ width: '100%', minWidth: 'unset' }}
placeholder="e.g. production snapshot"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="modal-section">
<div className="field-row" style={{ alignItems: 'center' }}>
<label className="toggle-switch" style={{ width: 32, height: 18 }}>
<input type="checkbox" checked={noReboot} onChange={(e) => setNoReboot(e.target.checked)} />
<span className="toggle-track" />
</label>
<p className="modal-section-title" style={{ margin: 0 }}>No reboot — skip instance restart before imaging</p>
</div>
</div>
{err && <p style={{ fontSize: 12, color: 'var(--status-error)', margin: '0 0 8px' }}>{err}</p>}
<div className="modal-footer">
<button className="button" onClick={onClose} disabled={mutation.isPending}>Cancel</button>
<button
className="button primary"
onClick={handleSubmit}
disabled={!name.trim() || mutation.isPending}
>
{mutation.isPending ? <Loader2 size={13} /> : <HardDrive size={13} />}
Create AMI
</button>
</div>
</div>
</div>
)
}
// ─── Console output modal (inline) ───────────────────────────────────────────
function ConsoleOutputModal({ instanceId, onClose }: { instanceId: string; onClose: () => void }) {
const query = useQuery({
queryKey: ['ec2', 'console', instanceId],
queryFn: ({ signal }) => getEc2ConsoleOutput(instanceId, signal),
})
return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
<div className="create-table-modal" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Terminal size={14} />
Console output — {instanceId}
</h3>
{query.isLoading && (
<p style={{ fontSize: 12, color: 'var(--text-2)' }}>Loading…</p>
)}
{query.isError && (
<p style={{ fontSize: 12, color: 'var(--status-error)' }}>
{query.error instanceof Error ? query.error.message : 'Failed to fetch console output.'}
</p>
)}
{query.data && (
<>
{query.data.timestamp && (
<p style={{ fontSize: 11, color: 'var(--text-2)', margin: '0 0 8px' }}>
As of {new Date(query.data.timestamp).toLocaleString()}
</p>
)}
<pre
className="mono"
style={{
flex: 1,
overflowY: 'auto',
background: 'var(--surface-2)',
padding: 12,
borderRadius: 4,
fontSize: 11,
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{query.data.output || '(no output yet)'}
</pre>
</>
)}
<div className="modal-footer" style={{ marginTop: 12 }}>
<button className="button" onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}
// ─── Deregister AMI confirm (inline) ─────────────────────────────────────────
function DeregisterAmiConfirm({ imageId, onConfirm, onCancel, isPending }: {
imageId: string
onConfirm: () => void
onCancel: () => void
isPending: boolean
}) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="create-table-modal" style={{ width: 380 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<AlertTriangle size={14} style={{ color: 'var(--status-error)', flexShrink: 0 }} />
<span>Deregister AMI</span>
<button className="icon-btn" style={{ marginLeft: 'auto' }} onClick={onCancel} aria-label="Close" title="Close">
<X size={13} />
</button>
</div>
<div className="modal-section">
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
borderRadius: 4,
padding: '8px 12px',
marginBottom: 12,
}}>
<div style={{ fontFamily: 'monospace', fontSize: 13 }}>{imageId}</div>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)' }}>
This will deregister the AMI. Associated snapshots are not deleted automatically.
</p>
</div>
<div className="modal-footer">
<button className="button" onClick={onCancel} disabled={isPending}>Cancel</button>
<button className="button danger" onClick={onConfirm} disabled={isPending}>
{isPending ? <Loader2 size={13} /> : <Trash2 size={13} />}
Deregister
</button>
</div>
</div>
</div>
)
}
// ─── Instance panel ───────────────────────────────────────────────────────────
function InstancePanel({ instanceId }: { instanceId: string }) {
const qc = useQueryClient()
const [tab, setTab] = useState<'details' | 'connection' | 'securityGroups' | 'tags' | 'console'>('details')
const [showAmiModal, setShowAmiModal] = useState(false)
const [showConsoleModal, setShowConsoleModal] = useState(false)
const [terminateStep, setTerminateStep] = useState<'idle' | 'confirm'>('idle')
const query = useQuery({
queryKey: ['ec2', 'instance', instanceId],
queryFn: ({ signal }) => describeEc2Instance(instanceId, signal),
refetchInterval: (query) => {
const state = query.state.data?.state
if (state === 'pending' || state === 'stopping' || state === 'shutting-down') {
return 1_500
}
const connection = query.state.data?.connection
return connection?.launchStatus === 'in-progress' || connection?.sshStatus === 'initializing' ? 5_000 : false
},
})
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['ec2', 'instance', instanceId] })
void qc.invalidateQueries({ queryKey: ['ec2', 'instances'] })
void qc.invalidateQueries({ queryKey: ['cloud-resources', 'aws', 'compute'] })
}
const startMut = useMutation({
mutationFn: () => startEc2Instance(instanceId),
onSuccess: invalidate,
})
const stopMut = useMutation({
mutationFn: () => stopEc2Instance(instanceId),
onSuccess: invalidate,
})
const rebootMut = useMutation({
mutationFn: () => rebootEc2Instance(instanceId),
onSuccess: invalidate,
})
const terminateMut = useMutation({
mutationFn: () => terminateEc2Instance(instanceId),
onSuccess: () => {
setTerminateStep('idle')
invalidate()
},
})
const sshCheck = useMutation({
mutationFn: () => checkEc2InstanceSsh(instanceId),
})
if (query.isLoading) {
return (
<div className="empty compact">
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
<p>Loading instance…</p>
</div>
)
}
if (query.isError || !query.data) {
return (
<div className="empty compact">
<p style={{ color: 'var(--status-error)' }}>
{query.error instanceof Error ? query.error.message : 'Failed to load instance'}
</p>
</div>
)
}
const inst = query.data
const state = inst.state ?? 'unknown'
const canStart = state === 'stopped'
const canStop = state === 'running'
const canReboot = state === 'running'
const canTerminate = state !== 'terminated' && state !== 'shutting-down'
return (
<>
{showAmiModal && (
<CreateAmiModal
instanceId={instanceId}
instanceName={inst.name}
onClose={() => setShowAmiModal(false)}
onCreated={() => void qc.invalidateQueries({ queryKey: ['ec2', 'amis'] })}
/>
)}
{showConsoleModal && (
<ConsoleOutputModal
instanceId={instanceId}
onClose={() => setShowConsoleModal(false)}
/>
)}
<div className="widget">
<div className="widget-header">
<h3>{inst.name || instanceId}</h3>
<span className={`status ${stateClass(state)}`}>{state}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
<button
className="button compact"
disabled={!canStart || startMut.isPending}
onClick={() => startMut.mutate()}
title="Start"
>
<Play size={12} />
Start
</button>
<button
className="button compact"
disabled={!canStop || stopMut.isPending}
onClick={() => stopMut.mutate()}
title="Stop"
>
<Power size={12} />
Stop
</button>
<button
className="button compact"
disabled={!canReboot || rebootMut.isPending}
onClick={() => rebootMut.mutate()}
title="Reboot"
>
<RotateCcw size={12} />
Reboot
</button>
<button
className="button compact"
onClick={() => setShowAmiModal(true)}
title="Create AMI"
>
<HardDrive size={12} />
Create AMI
</button>
{canTerminate && terminateStep === 'idle' && (
<button
className="button compact danger"
onClick={() => setTerminateStep('confirm')}
title="Terminate"
>
<Trash2 size={12} />
Terminate
</button>
)}
{canTerminate && terminateStep === 'confirm' && (
<>
<button
className="button compact danger"
disabled={terminateMut.isPending}
onClick={() => terminateMut.mutate()}
>
{terminateMut.isPending ? <Loader2 size={12} /> : null}
Confirm terminate
</button>
<button
className="button compact"
onClick={() => setTerminateStep('idle')}
>
Cancel
</button>
</>
)}
<button
className="button compact"
disabled={query.isFetching}
onClick={() => query.refetch()}
>
<RefreshCw size={12} />
</button>
</div>
</div>
{/* Tabs */}
<div className="sns-tabs">
<button
className={`sns-tab${tab === 'details' ? ' active' : ''}`}
onClick={() => setTab('details')}
>
Details
</button>
<button
className={`sns-tab${tab === 'connection' ? ' active' : ''}`}
onClick={() => setTab('connection')}
>
Connect
</button>
<button
className={`sns-tab${tab === 'securityGroups' ? ' active' : ''}`}
onClick={() => setTab('securityGroups')}
>
Security groups
</button>
<button
className={`sns-tab${tab === 'tags' ? ' active' : ''}`}
onClick={() => setTab('tags')}
>
Tags
</button>
<button
className={`sns-tab${tab === 'console' ? ' active' : ''}`}
onClick={() => { setTab('console'); setShowConsoleModal(true) }}
>
Console
</button>
</div>
<div className="widget-body">
{tab === 'details' && (
<div className="meta-grid">
<div className="meta-row">
<span className="meta-label">Instance ID</span>
<span className="meta-value" style={{ fontFamily: 'monospace' }}>{inst.instanceId}</span>
</div>
<div className="meta-row">
<span className="meta-label">Type</span>
<span className="meta-value">{inst.instanceType ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">AMI</span>
<span className="meta-value" style={{ fontFamily: 'monospace', fontSize: 11 }}>{inst.imageId ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">Key pair</span>
<span className="meta-value">{inst.keyName ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">Architecture</span>
<span className="meta-value">{inst.architecture ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">Platform</span>
<span className="meta-value">{inst.platform ?? 'Linux'}</span>
</div>
<div className="meta-row">
<span className="meta-label">Launch time</span>
<span className="meta-value" style={{ color: '#8d9cad' }}>{inst.launchTime ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">Availability zone</span>
<span className="meta-value">{inst.availabilityZone ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">Public IP</span>
<span className="meta-value" style={{ fontFamily: 'monospace' }}>{inst.publicIpAddress ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">Private IP</span>
<span className="meta-value" style={{ fontFamily: 'monospace' }}>{inst.privateIpAddress ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">VPC</span>
<span className="meta-value" style={{ fontFamily: 'monospace', fontSize: 11 }}>{inst.vpcId ?? '—'}</span>
</div>
<div className="meta-row">
<span className="meta-label">Subnet</span>
<span className="meta-value" style={{ fontFamily: 'monospace', fontSize: 11 }}>{inst.subnetId ?? '—'}</span>
</div>
{inst.securityGroups.length > 0 && (
<div className="meta-row">
<span className="meta-label">Security groups</span>
<span className="meta-value">
{inst.securityGroups.map((sg) => sg.name ?? sg.id ?? '').join(', ')}
</span>
</div>
)}
{inst.networkInterfaces.length > 0 && (
<div className="meta-row" style={{ gridColumn: '1 / -1', alignItems: 'start' }}>
<span className="meta-label">Network interfaces</span>
<span className="meta-value" style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{inst.networkInterfaces.map((networkInterface) => (
<span key={networkInterface.networkInterfaceId} style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<code>{networkInterface.networkInterfaceId}</code>
{networkInterface.deviceIndex !== undefined && <span>device {networkInterface.deviceIndex}</span>}
{networkInterface.status && <span>{networkInterface.status}</span>}
{networkInterface.privateIpAddress && <span>private {networkInterface.privateIpAddress}</span>}
{networkInterface.publicIpAddress && <span>public {networkInterface.publicIpAddress}</span>}
</span>
))}
</span>
</div>
)}
</div>
)}
{tab === 'connection' && (() => {
const connection = inst.connection
const sshUser = connection?.sshUser || 'root'
const command = connection?.sshHost && connection.sshPort && inst.keyName
? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} ${sshUser}@${connection.sshHost}`
: undefined
if (!connection) {
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Connection diagnostics are unavailable.</p>
}
const canCheckSsh = state === 'running' && connection.containerStatus === 'running'
&& Boolean(connection.sshPort)
const failure = typeof connection.failure === 'string'
? connection.failure
: connection.failure?.message
const launchPending = connection.launchStatus === 'in-progress'
const sshPending = connection.sshStatus === 'initializing'
const keyInjected = connection.keyInjectionStatus === 'succeeded' || connection.keyInjectionStatus === 'ok'
return <div className="ec2-connect-panel">
{!canCheckSsh && <div className="ec2-connection-notice">
{state === 'terminated'
? 'This instance is terminated. Launch diagnostics remain available below.'
: state === 'stopped'
? 'Start this instance before connecting over SSH.'
: `SSH is unavailable while this instance is ${state}.`}
</div>}
<div className="ec2-readiness-grid">
<div className="ec2-readiness-card">
<div className="ec2-readiness-heading"><span>Launch</span><LaunchGear active={launchPending}/></div>
<strong className={connection.launchStatus === 'failed' ? 'error' : ''}>{connection.launchStatus ?? 'unknown'}</strong>
<small>{connection.selectedImageReference ?? 'No selected image recorded'}</small>
</div>
<div className="ec2-readiness-card">
<div className="ec2-readiness-heading"><span>SSH readiness</span><SshHeartbeat active={sshPending}/></div>
<strong className={connection.sshStatus === 'impaired' ? 'error' : ''}>{connection.sshStatus ?? 'unknown'}</strong>
<small>{connection.sshStatusMessage ?? 'No readiness result recorded'}</small>
</div>
<div className="ec2-readiness-card">
<div className="ec2-readiness-heading"><span>Key injection</span>{keyInjected && <KeyInjectionReady/>}</div>
<strong>{connection.keyInjectionStatus ?? (inst.keyName ? 'unknown' : 'not requested')}</strong>
<small>{inst.keyName ?? 'No key pair selected'}</small>
</div>
</div>
{failure && <div className="ec2-connect-error"><AlertTriangle size={14}/><span>{failure}</span></div>}
<div className="meta-grid ec2-connect-details">
<div className="meta-row"><span className="meta-label">SSH target</span><span className="meta-value">{connection.sshPort ? `${connection.sshHost}:${connection.sshPort}` : 'Not published'}</span></div>
<div className="meta-row"><span className="meta-label">Backing container</span><span className="meta-value">{connection.containerStatus}</span></div>
<div className="meta-row"><span className="meta-label">Image source</span><span className="meta-value">{connection.selectedImageType ?? 'unknown'}</span></div>
<div className="meta-row"><span className="meta-label">Init / SSH service</span><span className="meta-value">{connection.initType ?? 'unknown'} / {connection.sshServiceName ?? 'unknown'}</span></div>
{command && <div className="meta-row ec2-command-row"><span className="meta-label">Command</span><code>{command}</code><CopyValueButton value={command} label="SSH command" /></div>}
</div>
{canCheckSsh && <div className="ec2-ssh-check">
<div>
<strong>Live SSH banner check</strong>
<p>Runs from the UI backend, using the published diagnostic port.</p>
</div>
<button className="button compact primary" disabled={sshCheck.isPending} onClick={() => sshCheck.mutate()}>
{sshCheck.isPending ? <Loader2 size={12} className="spin"/> : <Terminal size={12}/>}
{sshCheck.isPending ? 'Checking' : 'Check SSH'}
</button>
</div>}
{sshCheck.isSuccess && <div className="ec2-check-result success">
<strong>{sshCheck.data.banner}</strong>
<span>Checked from {sshCheck.data.checkedFrom} at {new Date(sshCheck.data.checkedAt).toLocaleString()}</span>
<span>{sshCheck.data.target.host}:{sshCheck.data.target.port}</span>
</div>}
{sshCheck.isError && <div className="ec2-check-result error">
<strong>SSH check failed</strong>
<span>{sshCheck.error instanceof Error ? sshCheck.error.message : 'Unable to verify SSH'}</span>
</div>}
{(connection.launchEvents?.length ?? 0) > 0 && <div className="ec2-launch-timeline">
<h4>Launch timeline</h4>
{connection.launchEvents?.map((event, index) => <div className="ec2-launch-event" key={`${event.timestamp ?? 'event'}-${index}`}>
<span className={`ec2-event-dot ${event.status ?? 'unknown'}`}/>
<div><strong>{event.stage ?? 'Launch event'}</strong><small>{event.message ?? event.status ?? 'Recorded'}</small></div>
<time>{event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : ''}</time>
</div>)}
</div>}
</div>
})()}
{tab === 'tags' && (
<InlineTagEditor instanceId={instanceId} initialTags={inst.tags} />
)}
{tab === 'securityGroups' && (
<InlineSecurityGroupEditor instanceId={instanceId} vpcId={inst.vpcId} initialGroups={inst.securityGroups}/>
)}
{tab === 'console' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
Open the console output modal to view system logs.
</p>
<button
className="button"
onClick={() => setShowConsoleModal(true)}
style={{ alignSelf: 'flex-start' }}
>
<Terminal size={13} />
View console output
</button>
</div>
)}
</div>
</div>
</>
)
}
function LaunchGear({active}: {active: boolean}) {
return <img className={`ec2-launch-cpu${active ? ' active' : ''}`} src={ec2CpuAnimation} alt="Animated instance CPU"/>
}
function SshHeartbeat({active}: {active: boolean}) {
return <svg className={`ec2-ssh-heartbeat${active ? ' active' : ''}`} viewBox="0 0 100 32" aria-hidden="true">
<path d="M2 17h16l6-11 9 22 10-17 6 6h49"/>
</svg>
}
function KeyInjectionReady() {
return <svg className="ec2-key-ready" viewBox="0 0 150 150" aria-label="Key injected">
<rect x="12" y="13" width="126" height="125" rx="9" fill="#f4f1f7" stroke="#19152d" strokeWidth="3"/>
<path d="M12 33V22a9 9 0 0 1 9-9h108a9 9 0 0 1 9 9v11Z" fill="#393044"/>
<circle cx="24" cy="23" r="3" fill="#ff5a36"/><circle cx="35" cy="23" r="3" fill="#ffc33a"/><circle cx="46" cy="23" r="3" fill="#62c9aa"/>
<path d="M63 62v-6c0-7 5-11 12-11s12 4 12 11v6" fill="none" stroke="#19152d" strokeWidth="2.6"/>
<path d="M57 62h36v17c0 6-5 10-10 10H67c-5 0-10-4-10-10Z" fill="#ffc33a" stroke="#19152d" strokeWidth="2.6"/>
<path d="M75 71v7" stroke="#19152d" strokeWidth="2.6" strokeLinecap="round"/>
<rect x="34" y="100" width="79" height="24" rx="12" fill="#45b5fa" stroke="#19152d" strokeWidth="2.6"/>
<circle cx="51" cy="112" r="1.8" fill="#19152d"/><circle cx="66" cy="112" r="1.8" fill="#19152d"/><circle cx="81" cy="112" r="1.8" fill="#19152d"/><circle cx="96" cy="112" r="1.8" fill="#19152d"/>
</svg>
}
// ─── AMI panel ────────────────────────────────────────────────────────────────
function AmiPanel({ imageId }: { imageId: string }) {
const qc = useQueryClient()
const [showDeregister, setShowDeregister] = useState(false)
const deregisterMut = useMutation({
mutationFn: () => deregisterEc2Ami(imageId),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['ec2', 'amis'] })
void qc.invalidateQueries({ queryKey: ['ec2', 'instances'] })
setShowDeregister(false)
},
})
return (
<>
{showDeregister && (
<DeregisterAmiConfirm
imageId={imageId}
onConfirm={() => deregisterMut.mutate()}
onCancel={() => setShowDeregister(false)}
isPending={deregisterMut.isPending}
/>
)}
<div className="widget">
<div className="widget-header">
<h3>{imageId}</h3>
<div style={{ marginLeft: 'auto' }}>
<button
className="button danger compact"
onClick={() => setShowDeregister(true)}
>
<Trash2 size={12} />
Deregister
</button>
</div>
</div>
<div className="widget-body">
<div className="meta-grid">
<div className="meta-row">
<span className="meta-label">Image ID</span>
<span className="meta-value" style={{ fontFamily: 'monospace' }}>{imageId}</span>
</div>
</div>
</div>
</div>
</>
)
}
// ─── ComputePanel ─────────────────────────────────────────────────────────────
export function ComputePanel({ cloud, resource, runtimeReachable }: ComputePanelProps) {
if (cloud !== 'aws' || !runtimeReachable || !resource) {
return null
}
if (resource.type === 'instance') {
return <InstancePanel instanceId={resource.id} />
}
if (resource.type === 'image') {
return <AmiPanel imageId={resource.id} />
}
return (
<div className="empty compact">
<p>Unsupported resource type: {resource.type}</p>
</div>
)
}
// ─── LaunchInstanceForm ────────────────────────────────────────────────────────
type InboundRule = { id: string; protocol: string; portInput: string; cidr: string }
type LaunchStep = 1 | 2 | 3 | 4
type AmiFamily = 'alpine' | 'amazon-linux' | 'fedora' | 'ubuntu' | 'red-hat' | 'debian' | 'suse' | 'other'
type AmiClassification = { family: AmiFamily; label: string; mark: string; color: string }
function classifyAmi(ami: Ec2Image): AmiClassification {
const metadata = `${ami.name} ${ami.description ?? ''} ${ami.platform ?? ''}`.toLowerCase()
if (metadata.includes('alpine')) return { family: 'alpine', label: 'Alpine', mark: 'A', color: '#0d597f' }
if (metadata.includes('ubuntu')) return { family: 'ubuntu', label: 'Ubuntu', mark: 'U', color: '#e95420' }
if (metadata.includes('amazon linux') || metadata.includes('amzn')) return { family: 'amazon-linux', label: 'Amazon Linux', mark: 'A', color: '#ff9900' }
if (metadata.includes('fedora')) return { family: 'fedora', label: 'Fedora', mark: 'F', color: '#51a2da' }
if (metadata.includes('red hat') || metadata.includes('rhel')) return { family: 'red-hat', label: 'Red Hat', mark: 'R', color: '#ee0000' }
if (metadata.includes('debian')) return { family: 'debian', label: 'Debian', mark: 'D', color: '#a80030' }
if (metadata.includes('suse')) return { family: 'suse', label: 'SUSE', mark: 'S', color: '#30ba78' }
return { family: 'other', label: ami.platform || 'Other', mark: '?', color: 'var(--text-2)' }
}
const QUICK_START_AMIS: Array<{ family: Exclude<AmiFamily, 'other'>; label: string; image: string; preferredImageId?: string }> = [
{ family: 'alpine', label: 'Alpine', image: alpineImage, preferredImageId: 'ami-alpine' },
{ family: 'amazon-linux', label: 'Amazon Linux 2023', image: amazonLinuxImage, preferredImageId: 'ami-0abcdef1234567891' },
{ family: 'fedora', label: 'Fedora', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2351a2da"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="34" font-weight="bold" fill="white"%3EF%3C/text%3E%3C/svg%3E' },
{ family: 'ubuntu', label: 'Ubuntu', image: ubuntuImage },
{ family: 'suse', label: 'openSUSE', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2330ba78"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="30" font-weight="bold" fill="white"%3ES%3C/text%3E%3C/svg%3E' },
{ family: 'red-hat', label: 'Red Hat', image: redHatImage },
{ family: 'debian', label: 'Debian 12', image: debianImage, preferredImageId: 'ami-debian12' },
]
const SG_PRESETS: Array<{ label: string; protocol: string; portInput: string; cidr: string }> = [
{ label: 'SSH', protocol: 'tcp', portInput: '22', cidr: '0.0.0.0/0' },
{ label: 'HTTP', protocol: 'tcp', portInput: '80', cidr: '0.0.0.0/0' },
{ label: 'HTTPS', protocol: 'tcp', portInput: '443', cidr: '0.0.0.0/0' },
{ label: 'All', protocol: '-1', portInput: '', cidr: '0.0.0.0/0' },
]
function parseRule(rule: InboundRule): IpPermissionInput {
if (rule.protocol === '-1' || rule.protocol === 'icmp') {
return { protocol: rule.protocol, fromPort: -1, toPort: -1, cidr: rule.cidr }
}
const parts = rule.portInput.split('-').map((s) => parseInt(s.trim(), 10))
const fromPort = isNaN(parts[0]) ? 0 : parts[0]
const toPort = parts.length > 1 && !isNaN(parts[1]) ? parts[1] : fromPort
return { protocol: rule.protocol, fromPort, toPort, cidr: rule.cidr }
}
const X86_INSTANCE_TYPES = [
't2.micro', 't2.small', 't2.medium',
't3.micro', 't3.small', 't3.medium', 't3.large', 't3.xlarge',
'm5.large', 'm5.xlarge',
'c5.large', 'c5.xlarge',
]
const ARM_INSTANCE_TYPES = ['t4g.micro', 't4g.small', 't4g.medium', 'm6gd.large', 'm6gd.2xlarge', 'm7gd.large', 'm7gd.2xlarge', 'm8gd.medium', 'm8gd.large', 'm8gd.2xlarge']
export function LaunchInstanceForm({
cloud,
selectedResource,
onSuccess,
onCancel,
}: {
cloud: CloudProvider
selectedResource?: CloudResource
onSuccess: (resource: CloudResource) => void
onCancel: () => void
}) {
const qc = useQueryClient()
const [step, setStep] = useState<LaunchStep>(1)
const [stepError, setStepError] = useState('')
const [name, setName] = useState('')
const [imageId, setImageId] = useState(selectedResource?.type === 'image' ? selectedResource.id : '')
const [architectureFilter, setArchitectureFilter] = useState('')
const [amiSearch, setAmiSearch] = useState('')
const [showAmiBrowser, setShowAmiBrowser] = useState(false)
const [instanceType, setInstanceType] = useState('t3.micro')
const [keyName, setKeyName] = useState('')
const [keyPairMode, setKeyPairMode] = useState<'existing' | 'new'>('existing')
const [newKeyName, setNewKeyName] = useState('')
const [newKeyType, setNewKeyType] = useState<'rsa' | 'ed25519'>('rsa')
const [createdKeyMaterial, setCreatedKeyMaterial] = useState<{ name: string; material: string }>()
const [rootVolumeSize, setRootVolumeSize] = useState('20')
const [rootVolumeType, setRootVolumeType] = useState('gp3')
const [userData, setUserData] = useState('')
const [subnetId, setSubnetId] = useState('')
const [selectedSgs, setSelectedSgs] = useState<string[]>([])
const [showCreateSg, setShowCreateSg] = useState(false)
const [newSgName, setNewSgName] = useState('')
const [newSgDesc, setNewSgDesc] = useState('')
const [newSgVpc, setNewSgVpc] = useState('')
const [inboundRules, setInboundRules] = useState<InboundRule[]>([])
function addRule(protocol = 'tcp', portInput = '', cidr = '0.0.0.0/0') {
setInboundRules((prev) => [...prev, { id: Math.random().toString(36).slice(2), protocol, portInput, cidr }])
}
function removeRule(id: string) {
setInboundRules((prev) => prev.filter((r) => r.id !== id))
}
function updateRule(id: string, patch: Partial<Omit<InboundRule, 'id'>>) {
setInboundRules((prev) => prev.map((r) => r.id === id ? { ...r, ...patch } : r))
}
const amisQ = useQuery({ queryKey: ['ec2', 'amis'], queryFn: ({ signal }) => listEc2Amis(signal) })
const keysQ = useQuery({ queryKey: ['ec2', 'key-pairs'], queryFn: ({ signal }) => listEc2KeyPairs(signal) })
const vpcsQ = useQuery({ queryKey: ['ec2', 'vpcs'], queryFn: ({ signal }) => listEc2Vpcs(signal) })
const subsQ = useQuery({ queryKey: ['ec2', 'subnets'], queryFn: ({ signal }) => listEc2Subnets(undefined, signal) })
const sgsQ = useQuery({ queryKey: ['ec2', 'security-groups'], queryFn: ({ signal }) => listEc2SecurityGroups(undefined, signal) })
const selectedSubnet = (subsQ.data ?? []).find((subnet) => subnet.subnetId === subnetId)
useEffect(() => {
if (!selectedSubnet) {
return
}
const compatibleGroupIds = new Set((sgsQ.data ?? [])
.filter((group) => group.vpcId === selectedSubnet.vpcId)
.map((group) => group.groupId))
setSelectedSgs((current) => {
const compatible = current.filter((groupId) => compatibleGroupIds.has(groupId))
if (compatible.length !== current.length) {
setStepError('Security groups from another VPC were removed. Select groups in the subnet VPC.')
}
return compatible
})
}, [selectedSubnet, sgsQ.data])
const createMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const startedAt = Date.now()
const created = await createCloudResource(cloud, 'compute', values)
const remaining = EC2_LAUNCH_MINIMUM_MS - (Date.now() - startedAt)
if (remaining > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, remaining))
}
return created
},
onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['cloud-resources', cloud, 'compute'] })
onSuccess(created)
},
})
const createKeyMut = useMutation({
mutationFn: () => createEc2KeyPair(newKeyName.trim(), newKeyType),
onSuccess: (keyPair) => {
setKeyName(keyPair.keyName)
setCreatedKeyMaterial({ name: keyPair.keyName, material: keyPair.keyMaterial })
void qc.invalidateQueries({ queryKey: ['ec2', 'key-pairs'] })
},
})
const createSgMut = useMutation({
mutationFn: async (vars: {name: string; desc: string; vpc: string; rules: InboundRule[]}) => {
const { groupId } = await createEc2SecurityGroup(vars.name, vars.desc || vars.name, vars.vpc || undefined)
for (const rule of vars.rules) {
await authorizeEc2SecurityGroupIngress(groupId, parseRule(rule))
}
return { groupId }
},
onSuccess: ({ groupId }, vars) => {
// Optimistically insert into cache — do NOT invalidate immediately;
// invalidation races the AWS propagation window and overwrites
// the entry with a server list that doesn't yet include the new SG.
qc.setQueryData(['ec2', 'security-groups'], (old: Ec2SecurityGroup[] | undefined) => [
...(old ?? []),
{
groupId,
groupName: vars.name,
description: vars.desc || vars.name,
vpcId: vars.vpc || undefined,
inboundRules: [],
outboundRules: [],
tags: [],
} satisfies Ec2SecurityGroup,
])
setSelectedSgs((prev) => [...prev, groupId])
setShowCreateSg(false)
setNewSgName('')
setNewSgDesc('')
setNewSgVpc('')
setInboundRules([])
},
})
function toggleSg(groupId: string, checked: boolean) {
setSelectedSgs((prev) => checked ? [...prev, groupId] : prev.filter((id) => id !== groupId))
}
function launch() {
const hasIncompatibleSecurityGroup = selectedSubnet && selectedSgs.some((groupId) =>
!(sgsQ.data ?? []).some((group) => group.groupId === groupId && group.vpcId === selectedSubnet.vpcId))
if (hasIncompatibleSecurityGroup) {
setStep(3)
setStepError('Security groups must belong to the selected subnet VPC.')
return
}
createMut.mutate({
name,
imageId,
instanceType,
keyName: keyName || undefined,
subnetId: subnetId || undefined,
securityGroupIds: selectedSgs.length ? selectedSgs.join(',') : undefined,
rootVolumeSize,
rootVolumeType,
userData: userData || undefined,
})
}
function nextStep() {
if (step === 1 && (!name.trim() || !imageId)) {
setStepError('Enter an instance name and select an AMI to continue.')
return
}
if (step === 2 && keyPairMode === 'new' && !keyName) {
setStepError('Create the new key pair before continuing, or choose an existing key pair.')
return
}
setStepError('')
setStep((current) => Math.min(4, current + 1) as LaunchStep)
}
function downloadPrivateKey() {
if (!createdKeyMaterial) return
const href = URL.createObjectURL(new Blob([createdKeyMaterial.material], { type: 'application/x-pem-file' }))
const link = document.createElement('a')
link.href = href
link.download = `${createdKeyMaterial.name}.pem`
link.click()
URL.revokeObjectURL(href)
}
const vpcs = vpcsQ.data ?? []
const subnets = subsQ.data ?? []
const groupByVpc = vpcs.length > 2
function vpcLabel(vpc: Ec2Vpc): string {
const tag = vpc.tags.find((t: Ec2Tag) => t.key === 'Name')?.value
return tag ? `${tag} (${vpc.vpcId})` : vpc.vpcId
}
const amis = amisQ.data ?? []
const architectures = [...new Set(amis.map((ami) => ami.architecture).filter((architecture): architecture is string => Boolean(architecture)))].sort()
const filteredAmis = amis.filter((ami) => !architectureFilter || ami.architecture === architectureFilter)
const quickStartAmis = QUICK_START_AMIS.map((catalogAmi) => ({
...catalogAmi,
ami: filteredAmis.find((candidate) => candidate.imageId === catalogAmi.preferredImageId)
?? filteredAmis.find((candidate) => classifyAmi(candidate).family === catalogAmi.family),
}))
const quickStartImageIds = new Set(quickStartAmis.flatMap((catalogAmi) => catalogAmi.ami ? [catalogAmi.ami.imageId] : []))
const normalizedAmiSearch = amiSearch.trim().toLowerCase()
const visibleQuickStartAmis = quickStartAmis.filter((catalogAmi) => {
const searchable = `${catalogAmi.label} ${catalogAmi.ami?.imageId ?? ''} ${catalogAmi.ami?.name ?? ''} ${catalogAmi.ami?.description ?? ''}`.toLowerCase()
return !normalizedAmiSearch || searchable.includes(normalizedAmiSearch)
})
const browsableAmis = filteredAmis.filter((ami) => {
const searchable = `${ami.imageId} ${ami.name} ${ami.description ?? ''} ${classifyAmi(ami).label}`.toLowerCase()
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
})
const selectedAmi = amis.find((ami) => ami.imageId === imageId)
const compatibleInstanceTypes = selectedAmi?.architecture === 'arm64' ? ARM_INSTANCE_TYPES : X86_INSTANCE_TYPES
const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
const availableSecurityGroups = (sgsQ.data ?? []).filter((group) => !selectedSubnet || group.vpcId === selectedSubnet.vpcId)
useEffect(() => {
if (!compatibleInstanceTypes.includes(instanceType)) {
setInstanceType(compatibleInstanceTypes[0])
}
}, [compatibleInstanceTypes, instanceType])
const steps: Array<{ number: LaunchStep; label: string }> = [
{ number: 1, label: 'Name and AMI' },
{ number: 2, label: 'Instance and key pair' },
{ number: 3, label: 'Network and security' },
{ number: 4, label: 'Storage and review' },
]
return (
<form className="launch-form ec2-launch-form" aria-label="Launch instance wizard" onSubmit={(event) => { event.preventDefault(); if (step === 4) launch() }}>
<header className="launch-form-header">
<div><p className="eyebrow">EC2 compute</p><h3>Launch an instance</h3><p className="muted compact-text">Choose an image, configure access and networking, then review the instance before launch.</p></div>
<span>4-step setup</span>
</header>
<nav className="launch-stepper" aria-label="Launch steps">
{steps.map((item) => (
<button key={item.number} type="button" className={step === item.number ? 'active' : ''} onClick={() => item.number < step && setStep(item.number)} disabled={item.number > step}>
<b>{item.number}</b><span>{item.label}</span>
</button>
))}
</nav>
{step === 1 && <section className="launch-form-section">
<div className="launch-section-title"><Server size={17}/><div><h4>Instance identity</h4><p>Name the instance and select its operating system image.</p></div></div>
<div className="launch-form-grid">
<label className="launch-field launch-field--span">
<span>Instance name <em className="field-required">*</em></span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. my-web-server" />
</label>
<label className="launch-field">
<span>Architecture</span>
<select className="input" value={architectureFilter} onChange={(e) => { setArchitectureFilter(e.target.value); setImageId('') }}>
<option value="">All architectures</option>
{architectures.map((architecture) => <option key={architecture} value={architecture}>{architecture}</option>)}
</select>
</label>
<div className="launch-field launch-field--span">
<span>Amazon Machine Image <em className="field-required">*</em></span>
<div className="ami-catalog">
<div className="ami-search">
<Search size={15} aria-hidden="true" />
<input className="input" value={amiSearch} onChange={(e) => { setAmiSearch(e.target.value); setShowAmiBrowser(Boolean(e.target.value)) }} placeholder="Search AMIs" aria-label="Search available AMIs" />
</div>
{amisQ.isLoading && <span className="muted">Loading AMIs…</span>}
{!amisQ.isLoading && <>
<div className="ami-catalog-heading">Quick Start</div>
<div className="ami-quick-start-grid">
{visibleQuickStartAmis.map((catalogAmi) => (
<button
key={catalogAmi.family}
type="button"
className={`ami-quick-start-tile${imageId === catalogAmi.ami?.imageId ? ' selected' : ''}${catalogAmi.ami ? '' : ' unavailable'}`}
disabled={!catalogAmi.ami}
onClick={() => { if (catalogAmi.ami) { setImageId(catalogAmi.ami.imageId); setArchitectureFilter(catalogAmi.ami.architecture ?? '') } }}
title={catalogAmi.ami ? catalogAmi.ami.name || catalogAmi.ami.imageId : `No local ${catalogAmi.label} AMI is available`}
>
<img src={catalogAmi.image} alt="" />
<span>{catalogAmi.label}</span>
<small>{catalogAmi.ami?.architecture || 'No local AMI'}</small>
</button>
))}
</div>
</>}
{!amisQ.isLoading && amis.length === 0 && <span className="muted">No AMIs are available.</span>}
{!amisQ.isLoading && amis.length > 0 && <>
<button type="button" className="ami-browser-toggle" onClick={() => setShowAmiBrowser((visible) => !visible)}>
{showAmiBrowser ? 'Hide available AMIs' : 'Browse available AMIs'}
</button>
{showAmiBrowser && <div className="ami-browser-list">
{browsableAmis.map((ami) => {
const distro = classifyAmi(ami)
return <label key={ami.imageId} className="ami-browser-item">
<input type="radio" name="ami" checked={imageId === ami.imageId} onChange={() => { setImageId(ami.imageId); setArchitectureFilter(ami.architecture ?? '') }} />
<span><b style={{ background: distro.color }}>{distro.mark}</b>{ami.name || ami.imageId}</span>
<code>{ami.imageId} · {distro.label}</code>
</label>
})}
{browsableAmis.length === 0 && <span className="muted">No additional AMIs match this search and architecture.</span>}
</div>}
</>}
</div>
</div>
</div>
</section>}
{step === 2 && <section className="launch-form-section">
<div className="launch-section-title"><Cpu size={17}/><div><h4>Compute and access</h4><p>Choose the instance capacity and SSH credentials.</p></div></div>
<div className="launch-form-grid">
<label className="launch-field">
<span>Instance type <em className="field-required">*</em></span>
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}>
{compatibleInstanceTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
{selectedAmi?.architecture === 'arm64' && <small>ARM64 AMIs require Graviton instance types.</small>}
</label>
<div className="launch-field launch-field--span">
<span>Key pair</span>
<div className="launch-choice-row">
<label><input type="radio" checked={keyPairMode === 'existing'} onChange={() => setKeyPairMode('existing')} /> Choose existing</label>
<label><input type="radio" checked={keyPairMode === 'new'} onChange={() => { setKeyPairMode('new'); setKeyName('') }} /> Create new</label>
</div>
{keyPairMode === 'existing' ? (
<select className="input" value={keyName} onChange={(e) => setKeyName(e.target.value)}>
<option value="">No key pair</option>
{(keysQ.data ?? []).map((keyPair: Ec2KeyPair) => <option key={keyPair.keyPairId} value={keyPair.keyName}>{keyPair.keyName}</option>)}
</select>
) : <div className="launch-key-create">
<input className="input" value={newKeyName} onChange={(e) => { setNewKeyName(e.target.value); setKeyName('') }} placeholder="Key pair name" />
<select className="input" value={newKeyType} onChange={(e) => setNewKeyType(e.target.value as 'rsa' | 'ed25519')}><option value="rsa">RSA</option><option value="ed25519">ED25519</option></select>
<button type="button" className="button" disabled={!newKeyName.trim() || createKeyMut.isPending} onClick={() => createKeyMut.mutate()}>{createKeyMut.isPending ? 'Creating…' : 'Create key pair'}</button>
{createdKeyMaterial && <button type="button" className="button primary" onClick={downloadPrivateKey}>Download {createdKeyMaterial.name}.pem</button>}
</div>}
{createKeyMut.error instanceof Error && <p className="form-error" style={{ marginTop: 8 }}>{createKeyMut.error.message}</p>}
{createdKeyMaterial && <p className="muted" style={{ margin: '8px 0 0' }}>The private key is available only from this creation response. Download it before leaving this wizard.</p>}
</div>
</div>
</section>}
{step === 3 && <section className="launch-form-section">
<div className="launch-section-title"><Network size={17}/><div><h4>Network and security</h4><p>Place the instance in a subnet and control inbound access.</p></div></div>
<div className="launch-form-grid">
<label className="launch-field launch-field--span">
<span>Subnet</span>
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
<option value="">Default subnet selection</option>
{groupByVpc ? vpcs.map((vpc: Ec2Vpc) => {
const vpcSubnets = subnets.filter((subnet: Ec2Subnet) => subnet.vpcId === vpc.vpcId)
return vpcSubnets.length ? <optgroup key={vpc.vpcId} label={vpcLabel(vpc)}>{vpcSubnets.map((subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.availabilityZone} · {subnet.cidrBlock}</option>)}</optgroup> : null
}) : subnets.map((subnet: Ec2Subnet) => <option key={subnet.subnetId} value={subnet.subnetId}>{subnet.availabilityZone} · {subnet.cidrBlock}</option>)}
</select>
</label>
<div className="launch-field launch-field--span">
<div className="sg-header">
<span>Security Groups</span>
<button
type="button"
className="button small"
onClick={() => setShowCreateSg((v) => !v)}
title={showCreateSg ? 'Cancel new SG' : 'Create a new security group'}
>
{showCreateSg ? <X size={12}/> : <Plus size={12}/>}
{showCreateSg ? 'Cancel' : 'New SG'}
</button>
</div>
<div className="sg-checklist">
{sgsQ.isLoading && <span className="muted">Loading…</span>}
{selectedSubnet && <span className="muted">Showing groups in {selectedSubnet.vpcId} only.</span>}
{sgsQ.data?.length === 0 && !showCreateSg && (
<span className="muted">No security groups — create one above.</span>
)}
{availableSecurityGroups.map((sg: Ec2SecurityGroup) => (
<label key={sg.groupId} className="sg-check-item">
<input
type="checkbox"
checked={selectedSgs.includes(sg.groupId)}
onChange={(e) => toggleSg(sg.groupId, e.target.checked)}
/>
<span>{sg.groupName || sg.groupId}</span>
{sg.groupName && <code>{sg.groupId}</code>}
</label>
))}
</div>
{showCreateSg && (
<div className="sg-create-inline">
<label className="sg-create-field">
<span>Name <em className="field-required">*</em></span>
<input className="input" value={newSgName} onChange={(e) => setNewSgName(e.target.value)} placeholder="e.g. web-servers"/>
</label>
<label className="sg-create-field">
<span>Description</span>
<input className="input" value={newSgDesc} onChange={(e) => setNewSgDesc(e.target.value)} placeholder="Optional description"/>
</label>
<label className="sg-create-field">
<span>VPC</span>
<select className="input" value={newSgVpc} onChange={(e) => setNewSgVpc(e.target.value)}>
<option value="">Default VPC</option>
{vpcs.map((vpc: Ec2Vpc) => (
<option key={vpc.vpcId} value={vpc.vpcId}>{vpcLabel(vpc)}</option>
))}
</select>
</label>
<div className="sg-rules-section">
<div className="sg-rules-header">
<span className="sg-rules-label">Inbound Rules</span>
<div className="sg-rules-divider"/>
<div className="sg-presets">
{SG_PRESETS.map((p) => (
<button key={p.label} type="button" className="button small"
onClick={() => addRule(p.protocol, p.portInput, p.cidr)}>
{p.label}
</button>
))}
<button type="button" className="button small" onClick={() => addRule()}>
<Plus size={10}/> Custom
</button>
</div>
</div>
{inboundRules.length > 0 && (
<div className="sg-rule-list">
<div className="sg-rule-row sg-rule-header-row">
<span>Protocol</span>
<span>Port(s)</span>
<span>Source CIDR</span>
<span/>
</div>
{inboundRules.map((rule) => {
const noPort = rule.protocol === '-1' || rule.protocol === 'icmp'
return (
<div key={rule.id} className="sg-rule-row">
<select className="input" value={rule.protocol}
onChange={(e) => updateRule(rule.id, { protocol: e.target.value, portInput: '' })}>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
<option value="icmp">ICMP</option>
<option value="-1">All</option>
</select>
<input className="input" value={rule.portInput}
placeholder={noPort ? 'All' : '80 or 80-443'}
disabled={noPort}
onChange={(e) => updateRule(rule.id, { portInput: e.target.value })}/>
<input className="input" value={rule.cidr}
placeholder="0.0.0.0/0"
onChange={(e) => updateRule(rule.id, { cidr: e.target.value })}/>
<button type="button" className="icon-btn danger" onClick={() => removeRule(rule.id)}>
<X size={12}/>
</button>
</div>
)
})}
</div>
)}
</div>
<div className="sg-create-actions">
{createSgMut.error instanceof Error && (
<span className="sg-create-error">
{createSgMut.error.message.startsWith('char')
? 'Security group creation failed — runtime returned an unexpected response.'
: createSgMut.error.message}
</span>
)}
<button
type="button"
className="button primary small"
disabled={!newSgName || createSgMut.isPending}
onClick={() => createSgMut.mutate({name: newSgName, desc: newSgDesc, vpc: newSgVpc, rules: inboundRules})}
>
<Plus size={12}/>
{createSgMut.isPending ? 'Creating…' : 'Create SG'}
</button>
</div>
</div>
)}
</div>
</div>
</section>}
{step === 4 && <section className="launch-form-section">
<div className="launch-section-title"><HardDrive size={17}/><div><h4>Storage and review</h4><p>Configure the root disk, bootstrap script, and verify the launch request.</p></div></div>
<div className="launch-form-grid">
<label className="launch-field">
<span>Root volume size (GiB)</span>
<input className="input" type="number" min="1" value={rootVolumeSize} onChange={(event) => setRootVolumeSize(event.target.value)} />
<small>Creates the root EBS volume at this size.</small>
</label>
<label className="launch-field">
<span>Root volume type</span>
<select className="input" value={rootVolumeType} onChange={(event) => setRootVolumeType(event.target.value)}>
<option value="gp3">General Purpose SSD (gp3)</option>
<option value="gp2">General Purpose SSD (gp2)</option>
<option value="io1">Provisioned IOPS SSD (io1)</option>
<option value="st1">Throughput Optimized HDD (st1)</option>
<option value="sc1">Cold HDD (sc1)</option>
</select>
</label>
<div className="launch-field launch-field--span launch-review">
<span>Review: fields sent to RunInstances</span>
<div className="meta-grid">
<div className="meta-row"><span className="meta-label">Name</span><span className="meta-value">{name}</span></div>
<div className="meta-row"><span className="meta-label">AMI</span><span className="meta-value">{selectedAmi?.name || imageId} ({imageId})</span></div>
<div className="meta-row"><span className="meta-label">Instance type</span><span className="meta-value">{instanceType}</span></div>
<div className="meta-row"><span className="meta-label">Key pair</span><span className="meta-value">{keyName || 'Not sent'}</span></div>
<div className="meta-row"><span className="meta-label">Subnet</span><span className="meta-value">{selectedSubnet ? `${selectedSubnet.availabilityZone} (${subnetId})` : 'Not sent'}</span></div>
<div className="meta-row"><span className="meta-label">Security groups</span><span className="meta-value">{selectedGroups.length ? selectedGroups.map((group) => group.groupName || group.groupId).join(', ') : 'Not sent'}</span></div>
<div className="meta-row"><span className="meta-label">Root volume</span><span className="meta-value">{rootVolumeSize} GiB {rootVolumeType}, delete on termination</span></div>
<div className="meta-row"><span className="meta-label">User data</span><span className="meta-value">{userData ? 'Shell script or cloud-init supplied' : 'Not sent'}</span></div>
</div>
</div>
<label className="launch-field launch-field--span">
<span>User data</span>
<textarea className="textarea code-textarea small" value={userData} onChange={(event) => setUserData(event.target.value)} placeholder={'#!/bin/bash\napt-get update\napt-get upgrade -y\napt-get install -y curl git'} rows={7}/>
<small>Runs once after the instance starts. Use a shell script or cloud-init document, matching the AWS RunInstances UserData field.</small>
<input type="file" accept=".sh,.txt,.yaml,.yml,.cfg,text/plain,text/x-shellscript" onChange={async (event) => { const file = event.currentTarget.files?.[0]; if (file) setUserData(await file.text()); event.currentTarget.value = '' }}/>
</label>
</div>
</section>}
{stepError && <div className="form-error launch-field--span">{stepError}</div>}
{createMut.error instanceof Error && <div className="form-error launch-field--span">{createMut.error.message}</div>}
{createMut.isPending && <div className="launch-field--span"><ServerLaunchProgress name={name} description="Creating the instance and preparing its local runtime."/></div>}
<div className="launch-form-actions">
<button type="button" className="button" onClick={step === 1 ? onCancel : () => { setStep((current) => (current - 1) as LaunchStep); setStepError('') }}>{step === 1 ? 'Cancel' : 'Back'}</button>
{step < 4 ? <button type="button" className="button primary" onClick={nextStep}>Next</button> : <button type="button" className="button primary" disabled={createMut.isPending} onClick={launch}><Plus size={14}/>{createMut.isPending ? 'Launching…' : 'Launch instance'}</button>}
</div>
</form>
)
}