1461 lines
80 KiB
TypeScript
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>
|
|
)
|
|
}
|