feat: Lightsail + svg animation

This commit is contained in:
Zakaria
2026-10-11 14:19:00 -04:00
parent 0036ef7e51
commit ca1e845cb6
40 changed files with 1887 additions and 34 deletions
+102 -20
View File
@@ -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>}