small feat: svg animation for servers, codebuild, codePipleline etc...
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
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,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -395,7 +399,11 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
queryFn: ({ signal }) => describeEc2Instance(instanceId, signal),
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.state
|
||||
return state === 'pending' || state === 'stopping' || state === 'shutting-down' ? 1_500 : false
|
||||
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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -675,6 +683,9 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
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'
|
||||
@@ -685,17 +696,17 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
</div>}
|
||||
<div className="ec2-readiness-grid">
|
||||
<div className="ec2-readiness-card">
|
||||
<span>Launch</span>
|
||||
<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">
|
||||
<span>SSH readiness</span>
|
||||
<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">
|
||||
<span>Key injection</span>
|
||||
<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>
|
||||
@@ -771,6 +782,29 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
@@ -929,6 +963,7 @@ export function LaunchInstanceForm({
|
||||
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)
|
||||
@@ -1049,6 +1084,7 @@ export function LaunchInstanceForm({
|
||||
securityGroupIds: selectedSgs.length ? selectedSgs.join(',') : undefined,
|
||||
rootVolumeSize,
|
||||
rootVolumeType,
|
||||
userData: userData || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1120,17 +1156,22 @@ export function LaunchInstanceForm({
|
||||
]
|
||||
|
||||
return (
|
||||
<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' }}>
|
||||
<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={`sns-tab${step === item.number ? ' active' : ''}`} onClick={() => item.number < step && setStep(item.number)} disabled={item.number > step}>
|
||||
{item.number}. {item.label}
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{step === 1 && <>
|
||||
<div className="launch-form-group">Name and AMI</div>
|
||||
{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" />
|
||||
@@ -1188,10 +1229,12 @@ export function LaunchInstanceForm({
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{step === 2 && <>
|
||||
<div className="launch-form-group">Instance type and key pair</div>
|
||||
{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)}>
|
||||
@@ -1201,16 +1244,16 @@ export function LaunchInstanceForm({
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Key pair</span>
|
||||
<div className="field-row" style={{ marginTop: 6 }}>
|
||||
<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" style={{ marginTop: 8 }} value={keyName} onChange={(e) => setKeyName(e.target.value)}>
|
||||
<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 style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
) : <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>
|
||||
@@ -1219,10 +1262,12 @@ export function LaunchInstanceForm({
|
||||
{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 && <>
|
||||
<div className="launch-form-group">Network and security</div>
|
||||
{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)}>
|
||||
@@ -1360,10 +1405,12 @@ export function LaunchInstanceForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{step === 4 && <>
|
||||
<div className="launch-form-group">Storage and review</div>
|
||||
{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)} />
|
||||
@@ -1379,9 +1426,9 @@ export function LaunchInstanceForm({
|
||||
<option value="sc1">Cold HDD (sc1)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<div className="launch-field launch-field--span launch-review">
|
||||
<span>Review: fields sent to RunInstances</span>
|
||||
<div className="meta-grid" style={{ marginTop: 8 }}>
|
||||
<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>
|
||||
@@ -1389,14 +1436,22 @@ export function LaunchInstanceForm({
|
||||
<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" style={{ gridColumn: '1 / -1' }}>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user