feat: EC2 UI polished + storage persisting

This commit is contained in:
Zakaria
2026-09-25 18:18:33 -04:00
parent 1b129e9dcf
commit 093afb9b2b
40 changed files with 2586 additions and 102 deletions
+259 -85
View File
@@ -1,4 +1,4 @@
import { type FormEvent, useState } from 'react'
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
AlertTriangle,
@@ -10,6 +10,7 @@ import {
RefreshCw,
RotateCcw,
Save,
Search,
Terminal,
Trash2,
X,
@@ -17,6 +18,7 @@ import {
import {
authorizeEc2SecurityGroupIngress,
createEc2Ami,
createEc2KeyPair,
createEc2SecurityGroup,
deregisterEc2Ami,
describeEc2Instance,
@@ -42,6 +44,10 @@ import {
import { createCloudResource } from '@/api/cloudProxyClient'
import type { CloudProvider } from '@/types/cloud'
import type { CloudResource } from '@/types/resource'
import amazonLinuxImage from '../../../../OS_img/amazon_Linux.png'
import debianImage from '../../../../OS_img/Debian_linux.png'
import redHatImage from '../../../../OS_img/red-hat-logo.png'
import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png'
// ─── Props ────────────────────────────────────────────────────────────────────
@@ -343,11 +349,16 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
const query = useQuery({
queryKey: ['ec2', 'instance', instanceId],
queryFn: ({ signal }) => describeEc2Instance(instanceId, signal),
refetchInterval: (query) => {
const state = query.state.data?.state
return state === 'pending' || state === 'stopping' || state === 'shutting-down' ? 1_500 : 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({
@@ -678,6 +689,32 @@ export function ComputePanel({ cloud, resource, runtimeReachable }: ComputePanel
type InboundRule = { id: string; protocol: string; portInput: string; cidr: string }
type LaunchStep = 1 | 2 | 3 | 4
type AmiFamily = '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('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: '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', image: debianImage },
]
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' },
@@ -714,18 +751,23 @@ export function LaunchInstanceForm({
onCancel: () => void
}) {
const qc = useQueryClient()
// Instance fields
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')
// Networking fields
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 [subnetId, setSubnetId] = useState('')
const [selectedSgs, setSelectedSgs] = useState<string[]>([])
// Inline SG creation
const [showCreateSg, setShowCreateSg] = useState(false)
const [newSgName, setNewSgName] = useState('')
const [newSgDesc, setNewSgDesc] = useState('')
@@ -756,6 +798,15 @@ export function LaunchInstanceForm({
},
})
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)
@@ -793,8 +844,7 @@ export function LaunchInstanceForm({
setSelectedSgs((prev) => checked ? [...prev, groupId] : prev.filter((id) => id !== groupId))
}
function submit(e: FormEvent) {
e.preventDefault()
function launch() {
createMut.mutate({
name,
imageId,
@@ -802,9 +852,34 @@ export function LaunchInstanceForm({
keyName: keyName || undefined,
subnetId: subnetId || undefined,
securityGroupIds: selectedSgs.length ? selectedSgs.join(',') : undefined,
rootVolumeSize,
rootVolumeType,
})
}
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
@@ -814,75 +889,148 @@ export function LaunchInstanceForm({
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 selectedSubnet = subnets.find((subnet) => subnet.subnetId === subnetId)
const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
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" onSubmit={submit}>
{/* ── Required ──────────────────────────────────────────── */}
<div className="launch-form-group">Required</div>
<form className="launch-form" aria-label="Launch instance wizard" onSubmit={(event) => { event.preventDefault(); if (step === 4) launch() }}>
<div className="sns-tabs" aria-label="Launch steps" style={{ gridColumn: '1 / -1', overflowX: 'auto' }}>
{steps.map((item) => (
<button key={item.number} type="button" className={`sns-tab${step === item.number ? ' active' : ''}`} onClick={() => item.number < step && setStep(item.number)} disabled={item.number > step}>
{item.number}. {item.label}
</button>
))}
</div>
<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)} required placeholder="e.g. my-web-server"/>
</label>
{step === 1 && <>
<div className="launch-form-group">Name and AMI</div>
<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>
</>}
<label className="launch-field">
<span>AMI <em className="field-required">*</em></span>
<select className="input" value={imageId} onChange={(e) => setImageId(e.target.value)} required>
<option value="">Select an AMI…</option>
{(amisQ.data ?? []).map((ami: Ec2Image) => (
<option key={ami.imageId} value={ami.imageId}>{ami.name || ami.imageId}</option>
))}
</select>
</label>
{step === 2 && <>
<div className="launch-form-group">Instance type and key pair</div>
<label className="launch-field">
<span>Instance type <em className="field-required">*</em></span>
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}>
{INSTANCE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
</label>
<div className="launch-field launch-field--span">
<span>Key pair</span>
<div className="field-row" style={{ marginTop: 6 }}>
<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" style={{ marginTop: 8 }} 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 style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<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>
</>}
<label className="launch-field">
<span>Instance Type <em className="field-required">*</em></span>
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)} required>
{INSTANCE_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
{/* ── Networking — optional ─────────────────────────────── */}
<div className="launch-form-group">Networking — optional</div>
<label className="launch-field">
<span>Key Pair</span>
<select className="input" value={keyName} onChange={(e) => setKeyName(e.target.value)}>
<option value="">None</option>
{(keysQ.data ?? []).map((kp: Ec2KeyPair) => (
<option key={kp.keyPairId} value={kp.keyName}>{kp.keyName}</option>
))}
</select>
</label>
<label className="launch-field">
<span>Subnet</span>
<select className="input" value={subnetId} onChange={(e) => setSubnetId(e.target.value)}>
<option value="">Default</option>
{groupByVpc
? vpcs.map((vpc: Ec2Vpc) => {
const vpcSubnets = subnets.filter((s: Ec2Subnet) => s.vpcId === vpc.vpcId)
if (!vpcSubnets.length) return null
return (
<optgroup key={vpc.vpcId} label={vpcLabel(vpc)}>
{vpcSubnets.map((s: Ec2Subnet) => (
<option key={s.subnetId} value={s.subnetId}>
{s.availabilityZone} — {s.cidrBlock}
</option>
))}
</optgroup>
)
})
: subnets.map((s: Ec2Subnet) => (
<option key={s.subnetId} value={s.subnetId}>
{s.availabilityZone} — {s.cidrBlock}
</option>
))
}
</select>
</label>
{/* ── Security Groups ───────────────────────────────────── */}
<div className="launch-field launch-field--span">
{step === 3 && <>
<div className="launch-form-group">Network and security</div>
<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
@@ -934,7 +1082,6 @@ export function LaunchInstanceForm({
</select>
</label>
{/* Inbound Rules */}
<div className="sg-rules-section">
<div className="sg-rules-header">
<span className="sg-rules-label">Inbound Rules</span>
@@ -1008,18 +1155,45 @@ export function LaunchInstanceForm({
</div>
</div>
)}
</div>
</div>
</>}
{createMut.error instanceof Error && (
<div className="form-error launch-field--span">{createMut.error.message}</div>
)}
{step === 4 && <>
<div className="launch-form-group">Storage and review</div>
<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">
<span>Review: fields sent to RunInstances</span>
<div className="meta-grid" style={{ marginTop: 8 }}>
<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>
</div>
</>}
<div className="launch-form-actions">
<button type="button" className="button" onClick={onCancel}>Cancel</button>
<button type="submit" className="button primary" disabled={createMut.isPending}>
<Plus size={14}/>
{createMut.isPending ? 'Launching…' : 'Launch instance'}
</button>
{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>}
<div className="launch-form-actions" style={{ gridColumn: '1 / -1' }}>
<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>
)