({
+ deleteCloudResource: vi.fn(),
+ rebootCloudResource: vi.fn(),
+ startCloudResource: vi.fn(),
+ stopCloudResource: vi.fn(),
+ updateCloudResource: vi.fn(),
+ deployLightsailStaticFiles: vi.fn(),
+ deployLightsailNodeApplication: vi.fn(),
+}))
+
+vi.mock('@/api/cloudProxyClient', () => apiMocks)
+
+const instance: CloudResource = {
+ id: 'web', name: 'web', cloud: 'aws', service: 'lightsail', type: 'instance', region: 'us-east-1a', createdAt: null, status: 'running',
+ metadata: {
+ blueprintName: 'Ubuntu', bundleId: 'nano_3_0', publicIpAddress: '127.0.0.1', privateIpAddress: '10.0.0.10', username: 'ubuntu',
+ networking: {staticIp: '127.0.0.2', ports: [{fromPort: 80, toPort: 80, protocol: 'tcp'}]},
+ hardware: {disks: [{name: 'web-root', sizeInGb: 20, isSystemDisk: true, state: 'in-use'}]},
+ hostPortMappings: [{instancePort: 80, host: '127.0.0.1', hostPort: 8080}],
+ },
+}
+
+function renderPanel(onDeleted = vi.fn()) {
+ const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}})
+ render(
)
+ return {onDeleted}
+}
+
+beforeEach(() => Object.values(apiMocks).forEach((mock) => mock.mockReset()))
+
+test('shows Lightsail instance state and invokes lifecycle actions from Actions', async () => {
+ apiMocks.stopCloudResource.mockResolvedValue(undefined)
+ const user = userEvent.setup()
+ renderPanel()
+
+ expect(screen.getByText('running')).toBeVisible()
+ await user.click(screen.getByText(/Actions/))
+ await user.click(screen.getByRole('button', {name: 'Stop'}))
+
+ await waitFor(() => expect(apiMocks.stopCloudResource).toHaveBeenCalledWith('aws', 'lightsail', 'web'))
+})
+
+test('manages firewall rules through generic resource updates and shows browser mappings', async () => {
+ apiMocks.updateCloudResource.mockResolvedValue(instance)
+ const user = userEvent.setup()
+ renderPanel()
+
+ await user.click(screen.getByRole('tab', {name: 'Networking'}))
+ expect(screen.getByText('127.0.0.2')).toBeVisible()
+ expect(screen.getByRole('link', {name: 'Open'})).toHaveAttribute('href', 'http://127.0.0.1:8080')
+ await user.click(screen.getByRole('button', {name: 'Remove'}))
+
+ await waitFor(() => expect(apiMocks.updateCloudResource).toHaveBeenCalledWith('aws', 'lightsail', 'web', {action: 'closePort', fromPort: 80, toPort: 80, protocol: 'tcp'}))
+})
+
+test('shows WordPress site and admin links from the allocated host port', () => {
+ const wordpress = {...instance, metadata: {...instance.metadata, blueprintId: 'wordpress'}}
+ const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}})
+ render(
)
+ expect(screen.getByRole('link', {name: /Open site/})).toHaveAttribute('href', 'http://127.0.0.1:8080')
+ expect(screen.getByRole('link', {name: /Open WordPress admin/})).toHaveAttribute('href', 'http://127.0.0.1:8080/wp-admin')
+})
+
+test('labels storage and connect limitations without exposing unavailable operations', async () => {
+ const user = userEvent.setup()
+ renderPanel()
+
+ await user.click(screen.getByRole('tab', {name: 'Storage'}))
+ expect(screen.getByText(/metadata-only/i)).toBeVisible()
+ expect(screen.getByText('web-root')).toBeVisible()
+ await user.click(screen.getByRole('tab', {name: 'Connect'}))
+ expect(screen.getByText(/Browser SSH and key injection are unavailable/i)).toBeVisible()
+})
+
+test('confirms deletion through the generic resource delete method', async () => {
+ apiMocks.deleteCloudResource.mockResolvedValue(undefined)
+ const user = userEvent.setup()
+ const {onDeleted} = renderPanel()
+
+ await user.click(screen.getByText(/Actions/))
+ await user.click(screen.getByRole('button', {name: 'Delete'}))
+ await user.click(screen.getByRole('button', {name: 'Delete instance'}))
+
+ await waitFor(() => expect(apiMocks.deleteCloudResource).toHaveBeenCalledWith('aws', 'lightsail', 'web'))
+ expect(onDeleted).toHaveBeenCalled()
+})
+
+test('offers static file deployment only for Nginx instances', async () => {
+ const nginx = {...instance, metadata: {...instance.metadata, blueprintId: 'nginx'}}
+ const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}})
+ render(
)
+ expect(screen.getByRole('tab', {name: 'Deploy'})).toBeVisible()
+})
+
+test('offers a package-aware deployment form for Node.js instances', () => {
+ const node = {...instance, metadata: {...instance.metadata, blueprintId: 'nodejs'}}
+ const queryClient = new QueryClient({defaultOptions: {mutations: {retry: false}}})
+ render(
)
+ expect(screen.getByRole('tab', {name: 'Deploy'})).toBeVisible()
+})
diff --git a/packages/frontend/src/components/LightsailInstancePanel.tsx b/packages/frontend/src/components/LightsailInstancePanel.tsx
new file mode 100644
index 0000000..4e01182
--- /dev/null
+++ b/packages/frontend/src/components/LightsailInstancePanel.tsx
@@ -0,0 +1,135 @@
+import {ChevronDown, ExternalLink, HardDrive, Play, RotateCw, Settings, Square, Terminal, Trash2} from 'lucide-react'
+import {useEffect, useRef, useState} from 'react'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+import {deleteCloudResource, deployLightsailNodeApplication, deployLightsailStaticFiles, rebootCloudResource, startCloudResource, stopCloudResource, updateCloudResource} from '@/api/cloudProxyClient'
+import type {CloudResource} from '@/types/resource'
+
+type Tab = 'overview' | 'deploy' | 'networking' | 'storage' | 'connect'
+type PublicPort = {fromPort: number; toPort: number; protocol: string}
+type HostPortMapping = {instancePort: number; host: string; hostPort: number; protocol?: string}
+type Disk = {name: string; sizeInGb: number; isSystemDisk: boolean; path?: string; state?: string}
+
+export function LightsailInstancePanel({resource, runtimeReachable, onDeleted}: {resource?: CloudResource; runtimeReachable: boolean; onDeleted?: () => void}) {
+ const queryClient = useQueryClient()
+ const [tab, setTab] = useState
('overview')
+ const [fromPort, setFromPort] = useState('80')
+ const [toPort, setToPort] = useState('80')
+ const [protocol, setProtocol] = useState('tcp')
+ const [nodePort, setNodePort] = useState('3000')
+ const [startCommand, setStartCommand] = useState('')
+ const [confirmDelete, setConfirmDelete] = useState(false)
+ const fileInput = useRef(null)
+ const ports = publicPorts(resource?.metadata.networking)
+ const hostMappings = hostPortMappings(resource?.metadata.hostPortMappings)
+ const disks = instanceDisks(resource?.metadata.hardware)
+ const status = resource?.status?.toLowerCase()
+ const publicIp = stringMetadata(resource?.metadata.publicIpAddress)
+ const privateIp = stringMetadata(resource?.metadata.privateIpAddress)
+ const staticIp = staticIpAddress(resource?.metadata.networking)
+
+ useEffect(() => {
+ setTab('overview')
+ setFromPort('80')
+ setToPort('80')
+ setProtocol('tcp')
+ setConfirmDelete(false)
+ }, [resource?.id])
+
+ const refresh = () => {
+ if (!resource) return
+ void queryClient.invalidateQueries({queryKey: ['cloud-resources', resource.cloud, resource.service]})
+ void queryClient.invalidateQueries({queryKey: ['cloud-resource', resource.cloud, resource.service, resource.id]})
+ }
+ const lifecycle = useMutation({
+ mutationFn: (action: 'start' | 'stop' | 'reboot') => {
+ if (!resource) throw new Error('Select a Lightsail instance.')
+ if (action === 'start') return startCloudResource(resource.cloud, resource.service, resource.id)
+ if (action === 'stop') return stopCloudResource(resource.cloud, resource.service, resource.id)
+ return rebootCloudResource(resource.cloud, resource.service, resource.id)
+ },
+ onSuccess: refresh,
+ })
+ const portMutation = useMutation({
+ mutationFn: (values: {action: 'openPort' | 'closePort'; fromPort: number; toPort: number; protocol: string}) => {
+ if (!resource) throw new Error('Select a Lightsail instance.')
+ return updateCloudResource(resource.cloud, resource.service, resource.id, values)
+ },
+ onSuccess: refresh,
+ })
+ const deleteMutation = useMutation({
+ mutationFn: () => {
+ if (!resource) throw new Error('Select a Lightsail instance.')
+ return deleteCloudResource(resource.cloud, resource.service, resource.id)
+ },
+ onSuccess: () => {
+ refresh()
+ onDeleted?.()
+ },
+ })
+ const deployment = useMutation({
+ mutationFn: (files: File[]) => {
+ if (!resource) throw new Error('Select a Lightsail instance.')
+ return deployLightsailStaticFiles(resource.id, files)
+ },
+ })
+ const nodeDeployment = useMutation({
+ mutationFn: async (files: File[]) => {
+ if (!resource) throw new Error('Select a Lightsail instance.')
+ const port = Number(nodePort)
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Port must be between 1 and 65535.')
+ const result = await deployLightsailNodeApplication(resource.id, files, startCommand, port)
+ try {
+ await updateCloudResource(resource.cloud, resource.service, resource.id, {action: 'openPort', fromPort: port, toPort: port, protocol: 'tcp'})
+ } catch (error) {
+ throw Object.assign(new Error(`Deployment succeeded, but port ${port} could not be opened: ${error instanceof Error ? error.message : 'unknown error'}`), {cause: error})
+ }
+ return result
+ },
+ onSuccess: refresh,
+ })
+
+ if (!resource) {
+ return Select a Lightsail instance Manage its lifecycle, networking, storage metadata, and connection details.
+ }
+
+ const validPortRange = validPort(fromPort) && validPort(toPort) && Number(fromPort) <= Number(toPort)
+ const busy = lifecycle.isPending || portMutation.isPending || deleteMutation.isPending || deployment.isPending || nodeDeployment.isPending
+ const error = lifecycle.error ?? portMutation.error ?? deleteMutation.error ?? deployment.error ?? nodeDeployment.error
+ const nginx = stringMetadata(resource.metadata.blueprintId) === 'nginx'
+ const nodejs = stringMetadata(resource.metadata.blueprintId) === 'nodejs'
+ const wordpress = stringMetadata(resource.metadata.blueprintId) === 'wordpress'
+ const wordpressPort = hostMappings.find((mapping) => mapping.instancePort === 80)
+ const stateLabel = resource.status ?? 'Unknown'
+
+ return
+
+
+ setTab('overview')}>Overview
+ {(nginx || nodejs) && setTab('deploy')}>Deploy }
+ setTab('networking')}>Networking
+ setTab('storage')}>Storage
+ setTab('connect')}>Connect
+
+ {tab === 'overview' &&
{wordpress && wordpressPort &&
Open site Open WordPress admin
}
Use the Networking tab to manage public firewall rules and open mapped browser endpoints.
}
+ {tab === 'deploy' && nginx && Deploy static files Selected files are copied to /usr/share/nginx/html. Folder paths are preserved when your browser provides them.
{ const files = Array.from(event.target.files ?? []); if (files.length) deployment.mutate(files); event.currentTarget.value = '' }}/>
fileInput.current?.click()}>{deployment.isPending ? 'Deploying' : 'Choose files and deploy'} {deployment.data &&
Deployment complete: {deployment.data.files} {deployment.data.files === 1 ? 'file' : 'files'} copied.
}
}
+ {tab === 'deploy' && nodejs && }
+ {tab === 'networking' &&
Static IP is metadata-only. Attach or detach operations are not available from this console.
Firewall rules Public ports returned by the Lightsail instance networking API.
{ports.length > 0 ?
Port range Protocol {ports.map((port) => {formatPortRange(port)} {port.protocol.toUpperCase()} portMutation.mutate({action: 'closePort', ...port})}>Remove )}
:
No public firewall rules are configured.
}
Browser links {hostMappings.length > 0 ?
Instance port Host mapping {hostMappings.map((mapping) => {mapping.instancePort} {mapping.host}:{mapping.hostPort}Open )}
: publicIp ?
Open HTTP endpoint :
No browser endpoint was returned for this instance.
}
}
+ {tab === 'storage' && Disks Disk details are metadata-only. Creating, attaching, detaching, or resizing disks is not available from this console.
{disks.length > 0 ?
Name Size Type State {disks.map((disk) => {disk.name} {disk.sizeInGb} GB {disk.isSystemDisk ? 'System disk' : 'Attached disk'} {disk.state ?? 'Unknown'} )}
:
No disk metadata was returned by the runtime.
}
}
+ {tab === 'connect' && Connect to instance Browser SSH and key injection are unavailable in the local Lightsail runtime. This console does not generate credentials or claim a connection is available.
}
+ {confirmDelete && Delete {resource.name} ? This cannot be undone. deleteMutation.mutate()}>{deleteMutation.isPending ? 'Deleting' : 'Delete instance'} setConfirmDelete(false)}>Cancel
}
+ {error instanceof Error && {error.message}
}
+
+}
+
+function Detail({label, value}: {label: string; value?: string}) { return {label} {value ?? 'Not assigned'}
}
+function validPort(value: string): boolean { return /^\d+$/.test(value) && Number(value) >= 0 && Number(value) <= 65535 }
+function formatPortRange(port: PublicPort): string { return port.fromPort === port.toPort ? String(port.fromPort) : `${port.fromPort}-${port.toPort}` }
+function browserUrl(mapping: HostPortMapping): string { return `http://${mapping.host}:${mapping.hostPort}` }
+function stringMetadata(value: unknown): string | undefined { return typeof value === 'string' && value ? value : undefined }
+function publicPorts(networking: unknown): PublicPort[] { if (!networking || typeof networking !== 'object' || !Array.isArray((networking as {ports?: unknown}).ports)) return []; return (networking as {ports: unknown[]}).ports.flatMap((port) => { if (!port || typeof port !== 'object') return []; const value = port as {fromPort?: unknown; toPort?: unknown; protocol?: unknown}; return typeof value.fromPort === 'number' && typeof value.toPort === 'number' && typeof value.protocol === 'string' ? [{fromPort: value.fromPort, toPort: value.toPort, protocol: value.protocol}] : [] }) }
+function hostPortMappings(value: unknown): HostPortMapping[] { if (!Array.isArray(value)) return []; return value.flatMap((mapping) => { if (!mapping || typeof mapping !== 'object') return []; const item = mapping as {instancePort?: unknown; host?: unknown; hostPort?: unknown; protocol?: unknown}; return typeof item.instancePort === 'number' && typeof item.host === 'string' && typeof item.hostPort === 'number' ? [{instancePort: item.instancePort, host: item.host, hostPort: item.hostPort, protocol: typeof item.protocol === 'string' ? item.protocol : undefined}] : [] }) }
+function staticIpAddress(networking: unknown): string | undefined { return networking && typeof networking === 'object' ? stringMetadata((networking as {staticIp?: unknown}).staticIp) : undefined }
+function instanceDisks(hardware: unknown): Disk[] { if (!hardware || typeof hardware !== 'object' || !Array.isArray((hardware as {disks?: unknown}).disks)) return []; return (hardware as {disks: unknown[]}).disks.flatMap((disk) => { if (!disk || typeof disk !== 'object') return []; const value = disk as {name?: unknown; sizeInGb?: unknown; isSystemDisk?: unknown; path?: unknown; state?: unknown}; return typeof value.name === 'string' && typeof value.sizeInGb === 'number' && typeof value.isSystemDisk === 'boolean' ? [{name: value.name, sizeInGb: value.sizeInGb, isSystemDisk: value.isSystemDisk, path: stringMetadata(value.path), state: stringMetadata(value.state)}] : [] }) }
diff --git a/packages/frontend/src/components/LightsailLaunchPanel.tsx b/packages/frontend/src/components/LightsailLaunchPanel.tsx
new file mode 100644
index 0000000..a4835e2
--- /dev/null
+++ b/packages/frontend/src/components/LightsailLaunchPanel.tsx
@@ -0,0 +1,101 @@
+import {useState} from 'react'
+import {Box, Check, Terminal} from 'lucide-react'
+import amazonLinuxImage from '../../../../OS_img/amazon_Linux.png'
+import nginxImage from '../../../../OS_img/nginx.svg'
+import nodeJsImage from '../../../../OS_img/node-js.svg'
+import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png'
+import wordpressImage from '../../../../OS_img/wordpress.svg'
+import {ServerLaunchProgress} from './ServerLaunchProgress'
+
+type Blueprint = {
+ id: string
+ name: string
+ description: string
+ kind: 'app' | 'os'
+ icon?: string
+ available: boolean
+ unavailableReason?: string
+}
+
+const blueprints: Blueprint[] = [
+ {id: 'nginx', name: 'Nginx', description: 'Nginx web server', kind: 'app', icon: nginxImage, available: true},
+ {id: 'nodejs', name: 'Node.js', description: 'Node.js current LTS application server', kind: 'app', icon: nodeJsImage, available: true},
+ {id: 'wordpress', name: 'WordPress', description: 'WordPress application stack with managed MariaDB', kind: 'app', icon: wordpressImage, available: true},
+ {id: 'ubuntu_22_04', name: 'Ubuntu 22.04 LTS', description: 'Ubuntu Linux', kind: 'os', icon: ubuntuImage, available: true},
+ {id: 'ubuntu_24_04', name: 'Ubuntu 24.04 LTS', description: 'Ubuntu Linux', kind: 'os', icon: ubuntuImage, available: true},
+ {id: 'amazon_linux_2023', name: 'Amazon Linux 2023', description: 'Amazon Linux', kind: 'os', icon: amazonLinuxImage, available: true},
+]
+
+const plans = [
+ {id: 'nano_3_0', name: 'Nano', detail: '1 vCPU, 512 MB RAM, 20 GB SSD'},
+ {id: 'micro_3_0', name: 'Micro', detail: '2 vCPU, 1 GB RAM, 40 GB SSD'},
+ {id: 'small_3_0', name: 'Small', detail: '2 vCPU, 2 GB RAM, 60 GB SSD'},
+ {id: 'medium_3_0', name: 'Medium', detail: '2 vCPU, 4 GB RAM, 80 GB SSD'},
+]
+
+interface LightsailLaunchPanelProps {
+ isSubmitting: boolean
+ submitError: string | null
+ onCancel: () => void
+ onSubmit: (values: Record) => void
+}
+
+export function LightsailLaunchPanel({isSubmitting, submitError, onCancel, onSubmit}: LightsailLaunchPanelProps) {
+ const [category, setCategory] = useState<'app' | 'os'>('app')
+ const [blueprintId, setBlueprintId] = useState('nginx')
+ const [bundleId, setBundleId] = useState('nano_3_0')
+ const [name, setName] = useState('')
+ const [availabilityZone, setAvailabilityZone] = useState('us-east-1a')
+ const visibleBlueprints = blueprints.filter((blueprint) => blueprint.kind === category)
+
+ function selectCategory(next: 'app' | 'os') {
+ setCategory(next)
+ setBlueprintId(blueprints.find((blueprint) => blueprint.kind === next && blueprint.available)?.id ?? '')
+ }
+
+ return
+
+
+
Amazon Lightsail
+
Create an instance
+
Choose a blueprint, a plan, and a name. The instance launches locally through Floci.
+
+
×
+
+
+
+
Select a platform
+
+ Linux/Unix Supported blueprints
+ Microsoft Windows Not supported locally
+
+
+
+
+
Select a blueprint
+
+ selectCategory('app')}>Apps + OS
+ selectCategory('os')}>OS Only
+
+
+ {visibleBlueprints.map((blueprint) =>
setBlueprintId(blueprint.id)}>
+ {blueprint.icon ? : W }
+ {blueprint.name} {blueprint.available ? blueprint.description : blueprint.unavailableReason}
+ )}
+
+
+
+
+
Choose your instance plan
+
{plans.map((plan) => setBundleId(plan.id)}>{plan.name} {plan.detail} )}
+
+
+
+ Instance name setName(event.target.value)} placeholder="e.g. web-server" autoFocus/>
+ Availability Zone setAvailabilityZone(event.target.value)} />
+
+ {isSubmitting && }
+ {submitError && {submitError}
}
+ Cancel onSubmit({name, blueprintId, bundleId, availabilityZone})}>{isSubmitting ? 'Creating...' : 'Create instance'}
+
+}
diff --git a/packages/frontend/src/components/LoadBalancerListenerPanel.tsx b/packages/frontend/src/components/LoadBalancerListenerPanel.tsx
index 13c7cc9..8dd816e 100644
--- a/packages/frontend/src/components/LoadBalancerListenerPanel.tsx
+++ b/packages/frontend/src/components/LoadBalancerListenerPanel.tsx
@@ -3,6 +3,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton'
+import {LoadBalancingProgress} from '@/components/LoadBalancingProgress'
import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource'
@@ -33,7 +34,13 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
}, [resource?.id])
const createMutation = useMutation({
- mutationFn: () => putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn}),
+ mutationFn: async () => {
+ const startedAt = Date.now()
+ const created = await putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn})
+ const remaining = 5_000 - (Date.now() - startedAt)
+ if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining))
+ return created
+ },
onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}),
})
const deleteMutation = useMutation({
@@ -62,6 +69,7 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
Default target group setTargetGroupArn(event.target.value)} disabled={targetGroupsQuery.isLoading || pending}>Select a target group {targetGroups.map((group) => {group.name} ({group.id}) )}
{createMutation.isPending ? 'Creating' : 'Create listener'}
{targetGroups.length === 0 && targetGroupsQuery.isSuccess && No target groups are available in this account.
}
+ {createMutation.isPending &&
}
{error instanceof Error && {error.message}
}
{listenersQuery.isLoading && Loading listeners
}
diff --git a/packages/frontend/src/components/LoadBalancingProgress.tsx b/packages/frontend/src/components/LoadBalancingProgress.tsx
new file mode 100644
index 0000000..1777dd6
--- /dev/null
+++ b/packages/frontend/src/components/LoadBalancingProgress.tsx
@@ -0,0 +1,19 @@
+interface LoadBalancingProgressProps {
+ kind: 'listener' | 'target'
+ description: string
+}
+
+export function LoadBalancingProgress({kind, description}: LoadBalancingProgressProps) {
+ return
+ {kind === 'listener' ?
:
}
+
{kind === 'listener' ? 'Creating listener' : 'Registering target'} {description}
+
+}
+
+function ListenerIcon() {
+ return
+}
+
+function TargetIcon() {
+ return {[178, 197, 216, 236, 256, 276, 295].map((cx, index) => )}{[178, 197, 216, 236, 256, 276].map((cx, index) => )}
+}
diff --git a/packages/frontend/src/components/NetworkingPanel.tsx b/packages/frontend/src/components/NetworkingPanel.tsx
index 43b98e9..51e2be2 100644
--- a/packages/frontend/src/components/NetworkingPanel.tsx
+++ b/packages/frontend/src/components/NetworkingPanel.tsx
@@ -55,6 +55,7 @@ import {deleteFlatItem, listFlatItems, putFlatItem} from '@/api/cloudProxyClient
import type {CloudProvider} from '@/types/cloud'
import type {ChildItem, CloudResource} from '@/types/resource'
import {calculateSubnetCidrs, isValidCidr, isValidPort} from '@/lib/network'
+import {clientId} from '@/lib/clientId'
// ─── Props ────────────────────────────────────────────────────────────────────
@@ -193,7 +194,7 @@ function ConfirmDeleteModal({title, detail, onConfirm, onClose, isPending}: {
function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules: EditRule[]) => void}) {
function addRule() {
- onChange([...rules, {rid: crypto.randomUUID(), protocol: 'tcp', fromPort: '', toPort: '', cidr: '0.0.0.0/0'}])
+ onChange([...rules, {rid: clientId(), protocol: 'tcp', fromPort: '', toPort: '', cidr: '0.0.0.0/0'}])
}
function removeRule(rid: string) {
onChange(rules.filter((r) => r.rid !== rid))
diff --git a/packages/frontend/src/components/ServerLaunchProgress.tsx b/packages/frontend/src/components/ServerLaunchProgress.tsx
new file mode 100644
index 0000000..f69e016
--- /dev/null
+++ b/packages/frontend/src/components/ServerLaunchProgress.tsx
@@ -0,0 +1,19 @@
+interface ServerLaunchProgressProps {
+ name: string
+ description: string
+}
+
+export function ServerLaunchProgress({name, description}: ServerLaunchProgressProps) {
+ return
+
+
+
+
+
+
+
+
+
+
Launching {name || 'instance'} {description}
+
+}
diff --git a/packages/frontend/src/components/TargetGroupPanel.tsx b/packages/frontend/src/components/TargetGroupPanel.tsx
index fd5bd73..8792dc6 100644
--- a/packages/frontend/src/components/TargetGroupPanel.tsx
+++ b/packages/frontend/src/components/TargetGroupPanel.tsx
@@ -3,6 +3,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {getLoadBalancingDependencies, listFlatItems, putFlatItem, deleteFlatItem} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
+import {LoadBalancingProgress} from '@/components/LoadBalancingProgress'
import type {CloudResource} from '@/types/resource'
type Props = {
@@ -37,7 +38,21 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
}
const registerMutation = useMutation({
- mutationFn: () => putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)}),
+ mutationFn: async () => {
+ const startedAt = Date.now()
+ await putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)})
+ while (true) {
+ const health = await listFlatItems('aws', 'targetgroups', resource?.id ?? '')
+ const target = health.items.find((item) => stringValue(item.body.id) === instanceId && Number(item.body.port) === Number(port))
+ const state = stringValue(target?.body.state).toLowerCase()
+ if (state === 'healthy' || state === 'unhealthy') {
+ const remaining = 5_000 - (Date.now() - startedAt)
+ if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining))
+ return
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ }
+ },
onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}),
})
const deregisterMutation = useMutation({
@@ -63,6 +78,7 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
Target port setPort(event.target.value)} disabled={registerMutation.isPending}/>
{registerMutation.isPending ? 'Registering' : 'Register target'}
{instances.length === 0 && dependenciesQuery.isSuccess && No running instances are available in this account.
}
+ {registerMutation.isPending &&
}
{error instanceof Error && {error.message}
}
{healthQuery.isLoading && Loading target health
}
diff --git a/packages/frontend/src/components/serviceIcons.tsx b/packages/frontend/src/components/serviceIcons.tsx
index 56eb112..065b405 100644
--- a/packages/frontend/src/components/serviceIcons.tsx
+++ b/packages/frontend/src/components/serviceIcons.tsx
@@ -41,6 +41,7 @@ import eventBridgeIcon from '@/assets/aws-services/eventbridge.svg'
import iamIcon from '@/assets/aws-services/iam.svg'
import kmsIcon from '@/assets/aws-services/kms.svg'
import lambdaIcon from '@/assets/aws-services/lambda.svg'
+import lightsailIcon from '../../../../aws-icons-svg/Compute/Lightsail.svg'
import parameterStoreIcon from '@/assets/aws-services/parameter-store.svg'
import rdsIcon from '@/assets/aws-services/rds.svg'
import s3Icon from '@/assets/aws-services/s3.svg'
@@ -68,6 +69,7 @@ const SERVICE_ICONS: Record = {
database: Table2,
nosql: Database,
k8s: Boxes,
+ lightsail: Server,
compute: Server,
containers: Container,
networking: Network,
@@ -112,6 +114,7 @@ const AWS_SERVICE_ICON_ASSETS: Record = {
kms: kmsIcon,
k8s: eksIcon,
loadbalancing: elasticLoadBalancingIcon,
+ lightsail: lightsailIcon,
logs: cloudWatchLogsIcon,
messaging: sqsIcon,
kinesis: kinesisIcon,
diff --git a/packages/frontend/src/features/ec2/sgRuleHelpers.ts b/packages/frontend/src/features/ec2/sgRuleHelpers.ts
index cc912f9..5996a8a 100644
--- a/packages/frontend/src/features/ec2/sgRuleHelpers.ts
+++ b/packages/frontend/src/features/ec2/sgRuleHelpers.ts
@@ -1,9 +1,10 @@
import { isValidCidr, isValidPort } from "@/lib/network";
+import { clientId } from "@/lib/clientId";
import type { SgRule } from "./SgRuleTable";
export function newRule(): SgRule {
return {
- id: crypto.randomUUID(),
+ id: clientId(),
type: "custom-tcp",
protocol: "tcp",
fromPort: "",
diff --git a/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx b/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx
index 194ef77..ca6d961 100644
--- a/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx
+++ b/packages/frontend/src/features/secretsmanager/SecretsManagerPage.tsx
@@ -30,6 +30,7 @@ import {
usePutSecretValueMutation,
} from '@/api/aws/secretsmanager.mutations'
import { timeAgo } from '@/lib/utils'
+import { clientId } from '@/lib/clientId'
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
type JsonValueKind = 'string' | 'number' | 'boolean' | 'null' | 'json'
@@ -338,7 +339,7 @@ function SecretDrawer({
function addKeyValueEntry() {
setKeyValueEntries((entries) => [
...entries,
- { id: crypto.randomUUID(), key: '', value: '', kind: 'string' },
+ { id: clientId(), key: '', value: '', kind: 'string' },
])
setEditorError('')
}
diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css
index a4353b0..14682ab 100644
--- a/packages/frontend/src/index.css
+++ b/packages/frontend/src/index.css
@@ -4875,6 +4875,181 @@ button.console-service-card {
word-break: break-all;
}
+.ec2-connect-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.ec2-readiness-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.ec2-readiness-card {
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: color-mix(in srgb, var(--surface) 82%, var(--bg));
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.ec2-readiness-card > span,
+.ec2-readiness-card small {
+ color: var(--text-3);
+ font-size: 10px;
+}
+
+.ec2-readiness-card strong {
+ color: var(--status-success);
+ font: 600 13px "Space Mono", monospace;
+}
+
+.ec2-readiness-card strong.error,
+.ec2-check-result.error strong {
+ color: var(--status-error);
+}
+
+.ec2-connect-error,
+.ec2-check-result {
+ display: flex;
+ gap: 8px;
+ padding: 10px 12px;
+ border-radius: 6px;
+ font-size: 11px;
+}
+
+.ec2-connect-error {
+ color: var(--status-error);
+ border: 1px solid color-mix(in srgb, var(--status-error) 35%, transparent);
+ background: color-mix(in srgb, var(--status-error) 8%, transparent);
+}
+
+.ec2-connection-notice {
+ padding: 9px 11px;
+ border-left: 2px solid var(--text-3);
+ background: color-mix(in srgb, var(--surface) 82%, var(--bg));
+ color: var(--text-2);
+ font-size: 11px;
+}
+
+.ec2-connect-details {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.ec2-command-row {
+ grid-column: 1 / -1;
+}
+
+.ec2-command-row code {
+ overflow-wrap: anywhere;
+}
+
+.ec2-ssh-check {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+
+.ec2-ssh-check strong,
+.ec2-launch-timeline h4 {
+ margin: 0;
+ font-size: 12px;
+}
+
+.ec2-ssh-check p {
+ margin: 3px 0 0;
+ color: var(--text-3);
+ font-size: 10px;
+}
+
+.ec2-check-result {
+ flex-direction: column;
+ border: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.ec2-check-result.success strong {
+ color: var(--status-success);
+ font-family: "Space Mono", monospace;
+}
+
+.ec2-check-result span {
+ color: var(--text-3);
+}
+
+.ec2-launch-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+
+.ec2-launch-event {
+ display: grid;
+ grid-template-columns: 10px minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: start;
+ padding: 7px 0;
+}
+
+.ec2-launch-event div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ec2-launch-event strong {
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.ec2-launch-event small,
+.ec2-launch-event time {
+ color: var(--text-3);
+ font-size: 10px;
+}
+
+.ec2-event-dot {
+ width: 7px;
+ height: 7px;
+ margin-top: 3px;
+ border-radius: 50%;
+ background: var(--text-3);
+}
+
+.ec2-event-dot.ok,
+.ec2-event-dot.ready,
+.ec2-event-dot.completed,
+.ec2-event-dot.success {
+ background: var(--status-success);
+}
+
+.ec2-event-dot.failed,
+.ec2-event-dot.error {
+ background: var(--status-error);
+}
+
+@media (max-width: 720px) {
+ .ec2-readiness-grid,
+ .ec2-connect-details {
+ grid-template-columns: 1fr;
+ }
+
+ .ec2-ssh-check {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
+
/* ─── Copy object modal ──────────────────────────────────────────────────── */
.copy-modal-overlay {
position: fixed;
@@ -5709,3 +5884,64 @@ button.console-service-card {
.redis-value { flex: 1; min-height: 110px; max-height: 310px; overflow: auto; margin: 0; padding: 12px; color: var(--text); font-size: 11px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.redis-set-form { border-top: 1px solid var(--border); background: var(--raised); }
.redis-set-form strong { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 12px; }
+
+.lightsail-launch { max-width: 980px; padding: 28px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
+.lightsail-launch-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; }
+.lightsail-launch h2, .lightsail-launch h3 { margin: 0; }
+.lightsail-launch h2 { font-size: 25px; }
+.lightsail-launch h3 { font-size: 17px; margin-bottom: 13px; }
+.lightsail-section { margin-top: 30px; }
+.lightsail-platforms, .lightsail-blueprints, .lightsail-plans { display: flex; flex-wrap: wrap; gap: 12px; }
+.lightsail-platform, .lightsail-blueprint, .lightsail-plans button { border: 1px solid var(--border); border-radius: 9px; background: var(--surface); color: var(--text); text-align: left; }
+.lightsail-platform { display: flex; align-items: center; gap: 11px; min-width: 250px; padding: 16px; }
+.lightsail-platform.selected, .lightsail-blueprint.selected, .lightsail-plans button.selected { border: 2px solid #ec7211; box-shadow: 0 0 0 1px color-mix(in srgb, #ec7211 18%, transparent); }
+.lightsail-platform svg:last-child { margin-left: auto; color: #ec7211; }
+.lightsail-platform span, .lightsail-blueprint span, .lightsail-plans button { display: flex; flex-direction: column; gap: 3px; }
+.lightsail-launch small { color: var(--text-muted); }
+.lightsail-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
+.lightsail-tabs button { border: 1px solid #ec7211; border-radius: 6px; padding: 8px 14px; background: transparent; color: #b7550a; }
+.lightsail-tabs button.active { background: #ec7211; color: white; }
+.lightsail-blueprint { width: 210px; min-height: 84px; padding: 13px; display: flex; align-items: center; gap: 13px; }
+.lightsail-blueprint img { width: 42px; height: 42px; object-fit: contain; }
+.lightsail-wordpress { display: grid; place-items: center; width: 42px; height: 42px; border: 2px solid #21759b; border-radius: 50%; color: #21759b; font-family: Georgia, serif; font-size: 29px; }
+.lightsail-plans button { width: 205px; padding: 13px; }
+.lightsail-config { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 28px; }
+.lightsail-config label { display: grid; gap: 6px; font-weight: 600; }
+.server-launch-progress { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #ec7211 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #ec7211 8%, var(--surface)); }
+.server-launch-progress svg { width: 70px; height: 56px; flex: 0 0 auto; }
+.server-launch-progress div { display: flex; flex-direction: column; gap: 3px; }
+.server-launch-progress small { color: var(--text-muted); }
+.database-launch-progress { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #1769c2 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #1769c2 8%, var(--surface)); }
+.database-launch-progress svg { width: 56px; height: 56px; flex: 0 0 auto; }
+.database-launch-progress div { display: flex; flex-direction: column; gap: 3px; }
+.database-launch-progress small { color: var(--text-muted); }
+.database-led { animation: database-led-blink 1.4s ease-in-out infinite; }
+.database-led-b { animation-delay: .22s; }
+.database-led-c { animation-delay: .44s; }
+.database-led-d { animation-delay: .66s; }
+@keyframes database-led-blink { 0%, 25%, 100% { opacity: .28; } 45%, 72% { opacity: 1; } }
+.load-balancing-progress { display: flex; align-items: center; gap: 14px; margin-top: 16px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #ffc52e 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #ffc52e 8%, var(--surface)); }
+.load-balancing-progress svg { width: 72px; height: 64px; flex: 0 0 auto; }
+.load-balancing-progress div { display: flex; flex-direction: column; gap: 3px; }
+.load-balancing-progress small { color: var(--text-muted); }
+.lb-device { fill: #30283d; }.lb-panel { fill: #2b2440; stroke: #17132f; stroke-width: 5; }.lb-wire { fill: none; stroke: #17132f; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }.lb-yellow { fill: #ffc12d; }.lb-green { fill: #31aa98; }.lb-port { fill: none; stroke: #ffc52e; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }.lb-ports { animation: lb-port-morph 4.5s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }.lb-hub { animation: lb-hub-pulse 4.5s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }.lb-blink-1 { animation: lb-blink-one 4.5s steps(1, end) infinite; }.lb-blink-2 { animation: lb-blink-two 4.5s steps(1, end) infinite; }.lb-blink-3 { animation: lb-blink-three 4.5s steps(1, end) infinite; }
+@keyframes lb-blink-one { 0%, 18%, 42%, 68%, 100% { opacity: 1; } 19%, 41%, 69%, 84% { opacity: .22; } } @keyframes lb-blink-two { 0%, 28%, 58%, 88%, 100% { opacity: .28; } 29%, 57%, 89%, 99% { opacity: 1; } } @keyframes lb-blink-three { 0%, 12%, 48%, 76%, 100% { opacity: 1; } 13%, 47%, 77%, 92% { opacity: .2; } } @keyframes lb-port-morph { 0%, 18%, 82%, 100% { transform: scaleX(.62); } 42%, 62% { transform: scaleX(1.18); } } @keyframes lb-hub-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.13); } }
+@media (max-width: 640px) { .lightsail-launch { padding: 18px; } .lightsail-config { grid-template-columns: 1fr; } .lightsail-platform, .lightsail-blueprint, .lightsail-plans button { width: 100%; } }
+
+.lightsail-instance-panel { overflow: visible; }
+.lightsail-instance-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 18px 18px 8px; }
+.lightsail-instance-header h2 { margin: 3px 0; font-size: 22px; }
+.lightsail-instance-controls { display: flex; align-items: center; gap: 10px; }
+.lightsail-instance-controls summary { display: flex; align-items: center; gap: 4px; }
+.lightsail-actions-menu { min-width: 154px; right: 0; left: auto; }
+.lightsail-instance-tabs { margin: 0 18px; overflow-x: auto; }
+.lightsail-overview, .lightsail-tab-content { padding: 18px; }
+.lightsail-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 18px; }
+.lightsail-detail { display: grid; gap: 5px; padding: 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-raised, var(--surface)); min-width: 0; }
+.lightsail-detail span { color: var(--text-muted); font-size: 12px; }
+.lightsail-detail code { overflow-wrap: anywhere; }
+.lightsail-tab-content h3 { display: flex; align-items: center; gap: 7px; margin: 20px 0 5px; font-size: 16px; }
+.lightsail-tab-content h3:first-child { margin-top: 0; }
+.lightsail-port-form { align-items: end; margin: 16px 0; }
+.lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; }
+@media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } }
diff --git a/packages/frontend/src/lib/clientId.ts b/packages/frontend/src/lib/clientId.ts
new file mode 100644
index 0000000..cf7842b
--- /dev/null
+++ b/packages/frontend/src/lib/clientId.ts
@@ -0,0 +1,7 @@
+export function clientId(): string {
+ if (typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID()
+ }
+
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2dd105a..6387232 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -76,6 +76,9 @@ importers:
'@aws-sdk/client-lambda':
specifier: ^3.1076.0
version: 3.1076.0
+ '@aws-sdk/client-lightsail':
+ specifier: ^3.1076.0
+ version: 3.1148.0
'@aws-sdk/client-rds':
specifier: ^3.1076.0
version: 3.1076.0
@@ -368,6 +371,10 @@ packages:
resolution: {integrity: sha512-uueu0Do1dy3cm42AdjL3zivHoc5b55vOL3g6BjxUv2xMifSLKZi5Ilh5FhXVDdS/Uwxak/JH+vVQDDnEuQ52gA==}
engines: {node: '>=20.0.0'}
+ '@aws-sdk/client-lightsail@3.1148.0':
+ resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==}
+ engines: {node: '>=20.0.0'}
+
'@aws-sdk/client-rds-data@3.1144.0':
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
engines: {node: '>=20.0.0'}
@@ -3047,6 +3054,17 @@ snapshots:
'@smithy/types': 4.15.1
tslib: 2.8.1
+ '@aws-sdk/client-lightsail@3.1148.0':
+ dependencies:
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/credential-provider-node': 3.972.84
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.0
+ '@smithy/fetch-http-handler': 5.8.0
+ '@smithy/node-http-handler': 4.12.1
+ '@smithy/types': 4.19.0
+ tslib: 2.8.1
+
'@aws-sdk/client-rds-data@3.1144.0':
dependencies:
'@aws-sdk/core': 3.978.1