feat: EC2 UI polished + storage persisting
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user