feat: Lightsail + svg animation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { CopyValueButton } from './CopyValueButton'
|
||||
import { ServerLaunchProgress } from './ServerLaunchProgress'
|
||||
import {
|
||||
AlertTriangle,
|
||||
HardDrive,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
authorizeEc2SecurityGroupIngress,
|
||||
checkEc2InstanceSsh,
|
||||
createEc2Ami,
|
||||
createEc2KeyPair,
|
||||
createEc2SecurityGroup,
|
||||
@@ -53,6 +55,8 @@ 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 {
|
||||
@@ -420,6 +424,9 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
const sshCheck = useMutation({
|
||||
mutationFn: () => checkEc2InstanceSsh(instanceId),
|
||||
})
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
@@ -656,25 +663,82 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
|
||||
{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} root@${connection.sshHost}`
|
||||
? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} ${sshUser}@${connection.sshHost}`
|
||||
: undefined
|
||||
if (state === 'stopped') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Start this instance before connecting over SSH.</p>
|
||||
if (!connection) {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Connection diagnostics are unavailable.</p>
|
||||
}
|
||||
if (state === 'terminated') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Terminated instances cannot accept connections.</p>
|
||||
}
|
||||
if (state !== 'running') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable while this instance is {state}.</p>
|
||||
}
|
||||
if (!command || connection?.containerStatus !== 'running') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable. Backing container status: {connection?.containerStatus ?? 'unavailable'}.</p>
|
||||
}
|
||||
return <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className="meta-row"><span className="meta-label">SSH port</span><span className="meta-value">{connection.sshPort}</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" style={{ gridColumn: '1 / -1' }}><span className="meta-label">Command</span><code style={{ overflowWrap: 'anywhere' }}>{command}</code><CopyValueButton value={command} label="SSH command" /></div>
|
||||
const canCheckSsh = state === 'running' && connection.containerStatus === 'running'
|
||||
&& Boolean(connection.sshPort)
|
||||
const failure = typeof connection.failure === 'string'
|
||||
? connection.failure
|
||||
: connection.failure?.message
|
||||
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">
|
||||
<span>Launch</span>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
})()}
|
||||
|
||||
@@ -810,7 +874,7 @@ const QUICK_START_AMIS: Array<{ family: Exclude<AmiFamily, 'other'>; label: stri
|
||||
{ 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 13', image: debianImage, preferredImageId: 'ami-debian13' },
|
||||
{ family: 'debian', label: 'Debian 12', image: debianImage, preferredImageId: 'ami-debian12' },
|
||||
]
|
||||
|
||||
const SG_PRESETS: Array<{ label: string; protocol: string; portInput: string; cidr: string }> = [
|
||||
@@ -830,12 +894,13 @@ function parseRule(rule: InboundRule): IpPermissionInput {
|
||||
return { protocol: rule.protocol, fromPort, toPort, cidr: rule.cidr }
|
||||
}
|
||||
|
||||
const INSTANCE_TYPES = [
|
||||
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,
|
||||
@@ -906,7 +971,15 @@ export function LaunchInstanceForm({
|
||||
}, [selectedSubnet, sgsQ.data])
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => createCloudResource(cloud, 'compute', values),
|
||||
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)
|
||||
@@ -1030,8 +1103,15 @@ export function LaunchInstanceForm({
|
||||
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' },
|
||||
@@ -1115,8 +1195,9 @@ export function LaunchInstanceForm({
|
||||
<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>)}
|
||||
{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>
|
||||
@@ -1314,6 +1395,7 @@ export function LaunchInstanceForm({
|
||||
|
||||
{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' }}>
|
||||
<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>}
|
||||
|
||||
Reference in New Issue
Block a user