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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M255 0h1.2c6.1 2.9 12 6.1 17.8 9.7 66.2 38.1 132.4 76.1 198.6 114.2 5.2 2.8 8.2 8.5 7.6 14.4-.1 80.3 0 160.5-.1 240.7-.9 3.8-3.2 7.2-6.5 9.3L262.4 509.8c-3.5 2.7-8.2 2.9-12 .7-70.5-40.4-140.9-80.9-211.3-121.5-4.7-2.2-7.6-7-7.3-12.2V136.1c-.4-5.1 2.4-10 7.1-12.3 66.1-37.9 132.2-76 198.3-114 5.9-3.4 11.7-6.9 17.8-9.8" style="fill:#019639"/><path d="M123.7 156.1v198.6c-.2 7.4 2.6 14.6 7.8 19.9 10.1 10 25.8 11.5 37.7 3.8 7.8-5.3 12.5-14.2 12.5-23.6 0-47.9-.1-95.8 0-143.7 43.7 52.3 87.5 104.5 131.3 156.7 12.4 12.5 30.3 17.9 47.5 14.3 12.3-2.4 21.6-12.6 22.9-25.1.1-67.6.1-135.1 0-202.6-1.4-15.9-15.4-27.7-31.3-26.3-14 1.2-25.1 12.3-26.3 26.3 0 48.7-.3 97.3 0 146-42.9-50.6-85.3-101.6-128-152.4-11.5-14.7-30-22.1-48.4-19.2-14.2 1.4-25.1 13.1-25.7 27.3" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 870 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><linearGradient id="node-js_svg__a" x1="277.292" x2="118.519" y1="461.638" y2="89.76" gradientTransform="matrix(1 0 0 -1 53.428 542.628)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#3f8b3d"/><stop offset=".64" style="stop-color:#3f873f"/><stop offset=".93" style="stop-color:#3da92e"/><stop offset="1" style="stop-color:#3dae2b"/></linearGradient><path d="M268.1 3.2c-7.5-4.3-16.7-4.3-24.2 0L44.6 119c-7.6 4.3-11.8 12.4-11.8 21v232c0 8.6 4.7 16.7 11.8 21L244 508.8c7.5 4.3 16.7 4.3 24.2 0L467.4 393c7.6-4.3 11.8-12.4 11.8-21V140c0-8.6-4.7-16.7-11.8-21z" style="fill:url(#node-js_svg__a)"/><linearGradient id="node-js_svg__b" x1="171.637" x2="677.901" y1="269.999" y2="541.653" gradientTransform="matrix(1 0 0 -1 53.428 542.628)" gradientUnits="userSpaceOnUse"><stop offset=".14" style="stop-color:#3f873f"/><stop offset=".4" style="stop-color:#52a044"/><stop offset=".71" style="stop-color:#64b749"/><stop offset=".91" style="stop-color:#6abf4b"/></linearGradient><path d="M467.4 119 268.1 3.2c-5.9-3.4-12.9-4-19.3-2L37 385.5c2 3 4.5 5.6 7.5 7.5l199.4 115.8c6.1 3.5 13.2 4 19.7 1.8l211.2-384.3c-1.9-2.9-4.4-5.5-7.4-7.3" style="fill:url(#node-js_svg__b)"/><linearGradient id="node-js_svg__c" x1="-18.131" x2="432.053" y1="286.639" y2="286.639" gradientTransform="matrix(1 0 0 -1 53.428 542.628)" gradientUnits="userSpaceOnUse"><stop offset=".09" style="stop-color:#6abf4b"/><stop offset=".29" style="stop-color:#64b749"/><stop offset=".6" style="stop-color:#52a044"/><stop offset=".86" style="stop-color:#3f873f"/></linearGradient><path d="M243.9 3.2 44.6 119c-3.3 1.8-5.8 4.5-7.7 7.5l211.5 384.1c6.5 2.2 13.6 1.7 19.7-1.8L467.4 393c3.1-1.7 5.5-4.2 7.4-7L263.2 1.2c-6.4-2-13.4-1.4-19.3 2" style="fill:url(#node-js_svg__c)"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M36.4 256c0 87.2 50.4 162.4 124 197.6l-105-287C43.2 193.6 36 224 36 256zm367.8-11.1c0-27.2-9.8-45.9-18.1-60.6-11.1-18.1-21.6-33.4-21.6-51.5 0-20.2 15.3-39 36.9-39 1 0 1.6 0 3.2.2-39.4-35.6-91.8-58-148.6-57.6-76.7 0-144.2 39.4-183.5 99.2 5.2 0 10.4 0 14.4.3 23 0 58.5-2.8 58.5-2.8 11.8-.7 13.6 16.8 1.6 18.4 0 0-11.9 1.4-25.1 2.1l80 237.9L250 247.4l-34.2-93.7c-11.8-.7-23-2.1-23-2.1-11.8-.7-10.4-18.8 1.4-18.1 0 0 36 2.4 57.6 2.8 23 0 58.5-2.8 58.5-2.8 11.8-.7 13.6 16.8 1.6 18.1 0 0-11.9 1.4-25.1 2.1l79.4 236.1 21.9-73.2c9.1-30.8 16-52.4 16-71.5h.1zm-144.4 30.3L194 466.7c19.7 5.6 40.8 8.8 62.4 8.8 25.6 0 50.1-4.4 72.9-12.4-.6-.9-1.1-1.9-1.6-3zm188.9-124.8c1 7.2 1.6 14.4 1.6 22.6 0 22.3-4.2 47.3-16.7 78.7l-67.1 193.9c65.3-38.1 109.2-108.8 109.2-189.8-.1-38.2-10.1-74.2-26.9-105.1zM256 0C114.8 0 0 114.8 0 256c0 140.8 115.2 256 256 256s256-114.8 256-256C512 115.2 396.8 0 256 0m0 500.3C121.3 500.3 11.7 390.7 11.7 256 11.7 121.6 121.6 12 256 12s244.3 109.6 244 244.3c.3 134.1-109.6 243.7-244 244" style="fill:#0073aa"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -29,6 +29,7 @@ services:
- "4501:4501" - "4501:4501"
environment: environment:
FLOCI_ENDPOINT: http://floci:4566 FLOCI_ENDPOINT: http://floci:4566
FLOCI_EC2_SSH_HOST: host.docker.internal
FLOCI_AZURE_ENDPOINT: http://floci-az:4577 FLOCI_AZURE_ENDPOINT: http://floci-az:4577
AWS_REGION: us-east-1 AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: test AWS_ACCESS_KEY_ID: test
@@ -36,6 +37,8 @@ services:
FLOCI_GCP_ENDPOINT: http://floci-gcp:4588 FLOCI_GCP_ENDPOINT: http://floci-gcp:4588
FLOCI_GCP_PROJECT: floci-local FLOCI_GCP_PROJECT: floci-local
PORT: "4501" PORT: "4501"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on: depends_on:
- floci - floci
networks: networks:
+1
View File
@@ -32,6 +32,7 @@
"@aws-sdk/client-kinesis": "^3.1076.0", "@aws-sdk/client-kinesis": "^3.1076.0",
"@aws-sdk/client-kms": "^3.1096.0", "@aws-sdk/client-kms": "^3.1096.0",
"@aws-sdk/client-lambda": "^3.1076.0", "@aws-sdk/client-lambda": "^3.1076.0",
"@aws-sdk/client-lightsail": "^3.1076.0",
"@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-rds-data": "^3.1076.0", "@aws-sdk/client-rds-data": "^3.1076.0",
"@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-s3": "^3.1076.0",
@@ -0,0 +1,113 @@
import {afterEach, describe, expect, test} from 'bun:test'
import {
CloseInstancePublicPortsCommand,
CreateInstancesCommand,
DeleteInstanceCommand,
GetInstanceCommand,
GetInstancesCommand,
OpenInstancePublicPortsCommand,
RebootInstanceCommand,
StartInstanceCommand,
StopInstanceCommand,
type LightsailClient,
} from '@aws-sdk/client-lightsail'
import {AwsLightsailAdapter} from './AwsLightsailAdapter'
const originalEndpoint = process.env.FLOCI_ENDPOINT
afterEach(() => {
process.env.FLOCI_ENDPOINT = originalEndpoint
})
function stubLightsail() {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
if (command instanceof GetInstancesCommand) return {instances: [instance('web')]}
if (command instanceof GetInstanceCommand) return {instance: instance(command.input.instanceName ?? 'web')}
return {}
},
} as unknown as LightsailClient
return {client, sent}
}
function instance(name: string) {
return {
name,
arn: `arn:aws:lightsail:us-east-1:000000000000:Instance/${name}`,
createdAt: new Date('2026-10-09T00:00:00Z'),
state: {name: 'running'},
blueprintId: 'ubuntu_22_04',
blueprintName: 'Ubuntu',
bundleId: 'nano_3_0',
publicIpAddress: '203.0.113.10',
privateIpAddress: '10.0.0.10',
networking: {staticIp: '203.0.113.11', ports: [{fromPort: 80, toPort: 80, protocol: 'tcp'}]},
hardware: {disks: [{name: `${name}-root`, sizeInGb: 20, isSystemDisk: true, state: 'in-use'}]},
location: {availabilityZone: 'us-east-1a'},
}
}
describe('AwsLightsailAdapter', () => {
test('maps Lightsail instances to Cloud Explorer resources', async () => {
const [resource] = await new AwsLightsailAdapter(stubLightsail().client).list()
expect(resource).toMatchObject({id: 'web', service: 'lightsail', status: 'running', region: 'us-east-1a'})
expect(resource?.metadata.publicIpAddress).toBe('203.0.113.10')
expect(resource?.metadata).toMatchObject({privateIpAddress: '10.0.0.10', networking: {staticIp: '203.0.113.11'}, hardware: {disks: [{name: 'web-root'}]}})
})
test('merges diagnostic host-port mappings into UI metadata', async () => {
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
const fetchMock = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify({mappings: [
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
]}))
try {
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
expect(resource?.metadata.hostPortMappings).toEqual([
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
])
} finally {
globalThis.fetch = fetchMock
}
})
test('uses Lightsail CreateInstances request fields', async () => {
const stub = stubLightsail()
await new AwsLightsailAdapter(stub.client).create({values: {
name: 'web', blueprintId: 'ubuntu_22_04', bundleId: 'nano_3_0', availabilityZone: 'us-east-1a',
}})
expect(stub.sent.find((command) => command instanceof CreateInstancesCommand))
.toMatchObject({input: {
instanceNames: ['web'], blueprintId: 'ubuntu_22_04', bundleId: 'nano_3_0', availabilityZone: 'us-east-1a',
}})
})
test('deletes instances by name', async () => {
const stub = stubLightsail()
await new AwsLightsailAdapter(stub.client).delete('web')
expect(stub.sent.find((command) => command instanceof DeleteInstanceCommand))
.toMatchObject({input: {instanceName: 'web'}})
})
test('uses Lightsail lifecycle commands', async () => {
const stub = stubLightsail()
const adapter = new AwsLightsailAdapter(stub.client)
await adapter.start('web')
await adapter.stop('web')
await adapter.reboot('web')
expect(stub.sent.find((command) => command instanceof StartInstanceCommand)).toMatchObject({input: {instanceName: 'web'}})
expect(stub.sent.find((command) => command instanceof StopInstanceCommand)).toMatchObject({input: {instanceName: 'web'}})
expect(stub.sent.find((command) => command instanceof RebootInstanceCommand)).toMatchObject({input: {instanceName: 'web'}})
})
test('opens and closes public ports with the Lightsail port contract', async () => {
const stub = stubLightsail()
const adapter = new AwsLightsailAdapter(stub.client)
await adapter.update('web', {values: {action: 'openPort', fromPort: 8080, toPort: 8080, protocol: 'tcp'}})
await adapter.update('web', {values: {action: 'closePort', fromPort: 8080, toPort: 8080, protocol: 'tcp'}})
expect(stub.sent.find((command) => command instanceof OpenInstancePublicPortsCommand)).toMatchObject({input: {instanceName: 'web', portInfo: {fromPort: 8080, toPort: 8080, protocol: 'tcp'}}})
expect(stub.sent.find((command) => command instanceof CloseInstancePublicPortsCommand)).toMatchObject({input: {instanceName: 'web', portInfo: {fromPort: 8080, toPort: 8080, protocol: 'tcp'}}})
})
})
@@ -0,0 +1,168 @@
import {
CloseInstancePublicPortsCommand,
CreateInstancesCommand,
DeleteInstanceCommand,
GetInstanceCommand,
GetInstancesCommand,
OpenInstancePublicPortsCommand,
type NetworkProtocol,
RebootInstanceCommand,
StartInstanceCommand,
StopInstanceCommand,
type Instance,
type LightsailClient,
} from '@aws-sdk/client-lightsail'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import {awsLightsailSchema} from '../cloud-spi/lightsailSchema'
import {awsClients} from '../aws'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema, UpdateResourceInput} from '../cloud-spi/types'
export class AwsLightsailAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'lightsail' as const
constructor(private readonly lightsail: LightsailClient = awsClients.lightsail) {}
schema(): ServiceSchema {
return awsLightsailSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const response = await this.lightsail.send(new GetInstancesCommand({}))
const search = query.search?.trim().toLowerCase()
const resources = await Promise.all((response.instances ?? []).map((instance) => toResource(instance)))
return resources
.filter((instance) => !search || instance.name.toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
try {
const response = await this.lightsail.send(new GetInstanceCommand({instanceName: id}))
return response.instance ? await toResource(response.instance) : null
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = text(input.values.name)
const blueprintId = text(input.values.blueprintId)
const bundleId = text(input.values.bundleId)
const availabilityZone = text(input.values.availabilityZone)
if (!name || !blueprintId || !bundleId || !availabilityZone) {
throw new ValidationError('name, blueprintId, bundleId, and availabilityZone are required')
}
await this.lightsail.send(new CreateInstancesCommand({
instanceNames: [name], blueprintId, bundleId, availabilityZone,
}))
const instance = await this.get(name)
if (!instance) throw new ValidationError(`Lightsail did not return instance ${name} after creation`)
return instance
}
async delete(id: string): Promise<void> {
if (!await this.get(id)) throw new NotFoundError(`Lightsail instance ${id} does not exist`)
await this.lightsail.send(new DeleteInstanceCommand({instanceName: id}))
}
async update(id: string, input: UpdateResourceInput): Promise<CloudResource> {
const action = text(input.values.action)
const fromPort = port(input.values.fromPort)
const toPort = port(input.values.toPort)
const protocol = text(input.values.protocol)
if ((action !== 'openPort' && action !== 'closePort') || fromPort === null || toPort === null || fromPort > toPort || protocol !== 'tcp') {
throw new ValidationError('action (openPort or closePort), valid fromPort/toPort, and protocol tcp are required')
}
const portInfo = {fromPort, toPort, protocol: protocol as NetworkProtocol, cidrs: ['0.0.0.0/0']}
if (action === 'openPort') {
await this.lightsail.send(new OpenInstancePublicPortsCommand({instanceName: id, portInfo}))
} else {
await this.lightsail.send(new CloseInstancePublicPortsCommand({instanceName: id, portInfo}))
}
const instance = await this.get(id)
if (!instance) throw new NotFoundError(`Lightsail instance ${id} does not exist`)
return instance
}
async start(id: string): Promise<void> {
await this.lightsail.send(new StartInstanceCommand({instanceName: id}))
}
async stop(id: string): Promise<void> {
await this.lightsail.send(new StopInstanceCommand({instanceName: id}))
}
async reboot(id: string): Promise<void> {
await this.lightsail.send(new RebootInstanceCommand({instanceName: id}))
}
async health(): Promise<void> {
await this.lightsail.send(new GetInstancesCommand({}))
}
}
async function toResource(instance: Instance): Promise<CloudResource> {
const name = instance.name ?? ''
const hostPortMappings = await lightsailHostPortMappings(name)
return {
id: name,
name,
cloud: 'aws',
service: 'lightsail',
type: 'instance',
region: instance.location?.availabilityZone ?? null,
createdAt: instance.createdAt?.toISOString() ?? null,
status: instance.state?.name ?? null,
metadata: {
arn: instance.arn,
blueprintId: instance.blueprintId,
blueprintName: instance.blueprintName,
bundleId: instance.bundleId,
publicIpAddress: instance.publicIpAddress,
privateIpAddress: instance.privateIpAddress,
username: instance.username,
networking: instance.networking,
hardware: instance.hardware,
hostPortMappings,
},
}
}
type HostPortMapping = {instancePort: number; host: string; hostPort: number; protocol?: string}
async function lightsailHostPortMappings(instanceName: string): Promise<HostPortMapping[]> {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint || !instanceName) return []
try {
const response = await fetch(new URL(`/_floci/diagnostics/lightsail/instances/${encodeURIComponent(instanceName)}/host-ports`, endpoint), {
redirect: 'error', signal: AbortSignal.timeout(5_000),
})
if (!response.ok) return []
const body = await response.json() as {mappings?: unknown}
if (!Array.isArray(body.mappings)) return []
return body.mappings.flatMap((mapping): HostPortMapping[] => {
if (!mapping || typeof mapping !== 'object') return []
const value = mapping as {instancePort?: unknown; host?: unknown; hostPort?: unknown; protocol?: unknown}
return typeof value.instancePort === 'number' && typeof value.host === 'string' && typeof value.hostPort === 'number'
? [{instancePort: value.instancePort, host: value.host, hostPort: value.hostPort,
protocol: typeof value.protocol === 'string' ? value.protocol : undefined}]
: []
})
} catch {
return []
}
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function port(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 65535 ? value : null
}
function isNotFound(error: unknown): boolean {
return typeof error === 'object' && error !== null
&& (error as {name?: string}).name === 'NotFoundException'
}
+3
View File
@@ -29,6 +29,7 @@ import { KinesisClient } from "@aws-sdk/client-kinesis";
import { SESv2Client } from "@aws-sdk/client-sesv2"; import { SESv2Client } from "@aws-sdk/client-sesv2";
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch"; import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
import { ElastiCacheClient } from "@aws-sdk/client-elasticache"; import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
import { LightsailClient } from "@aws-sdk/client-lightsail";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -89,6 +90,7 @@ export type AwsClients = {
sesv2: SESv2Client; sesv2: SESv2Client;
cloudwatch: CloudWatchClient; cloudwatch: CloudWatchClient;
elasticache: ElastiCacheClient; elasticache: ElastiCacheClient;
lightsail: LightsailClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -133,6 +135,7 @@ function buildClients(accountId: string): AwsClients {
sesv2: new SESv2Client(base), sesv2: new SESv2Client(base),
cloudwatch: new CloudWatchClient(base), cloudwatch: new CloudWatchClient(base),
elasticache: new ElastiCacheClient(base), elasticache: new ElastiCacheClient(base),
lightsail: new LightsailClient(base),
}; };
} }
@@ -0,0 +1,54 @@
import type {CapabilitySchema, FieldSchema, ResourceActionName, ServiceSchema} from './types'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List instances', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'start', label: 'Start instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'stop', label: 'Stop instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'reboot', label: 'Reboot instance', enabled: true, status: 'available', runtimeRequired: true},
{name: 'update', label: 'Manage public ports', enabled: true, status: 'available', runtimeRequired: true},
]
const fields: FieldSchema[] = [
{name: 'name', label: 'Instance name', type: 'text', required: true, validation: {minLength: 1, maxLength: 255, message: 'Provide an instance name.'}},
{
name: 'blueprintId', label: 'Blueprint', type: 'select', required: true, defaultValue: 'ubuntu_22_04', options: [
{label: 'Ubuntu 22.04 LTS', value: 'ubuntu_22_04'},
{label: 'Ubuntu 24.04 LTS', value: 'ubuntu_24_04'},
{label: 'Amazon Linux 2023', value: 'amazon_linux_2023'},
{label: 'Nginx', value: 'nginx'},
{label: 'WordPress', value: 'wordpress'},
],
},
{
name: 'bundleId', label: 'Instance plan', type: 'select', required: true, defaultValue: 'nano_3_0', options: [
{label: 'Nano', value: 'nano_3_0'},
{label: 'Micro', value: 'micro_3_0'},
{label: 'Small', value: 'small_3_0'},
{label: 'Medium', value: 'medium_3_0'},
],
},
{name: 'availabilityZone', label: 'Availability Zone', type: 'text', required: true, defaultValue: 'us-east-1a'},
]
export function awsLightsailSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'lightsail',
displayName: 'Amazon Lightsail',
fields,
actions: ['list', 'create', 'inspect', 'update', 'delete'],
capabilities: {resourceActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'Name'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'blueprint', label: 'Blueprint', path: 'metadata.blueprintName'},
{name: 'bundle', label: 'Plan', path: 'metadata.bundleId'},
{name: 'publicIp', label: 'Public IP', path: 'metadata.publicIpAddress'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
],
}
}
@@ -93,6 +93,12 @@ export const SERVICE_CATALOG = {
group: 'Compute', group: 'Compute',
order: 51, order: 51,
}, },
lightsail: {
displayName: 'Lightsail',
iconKey: 'lightsail',
group: 'Compute',
order: 60,
},
codebuild: { codebuild: {
displayName: 'CodeBuild', displayName: 'CodeBuild',
iconKey: 'codebuild', iconKey: 'codebuild',
+2
View File
@@ -46,6 +46,7 @@ import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter' import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter' import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter' import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -94,6 +95,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)), new AwsFargateAdapter(new AwsEcsAdapter(clients.ecs, clients.elbv2)),
new AwsKinesisAdapter(clients.kinesis), new AwsKinesisAdapter(clients.kinesis),
new AwsElastiCacheAdapter(clients.elasticache), new AwsElastiCacheAdapter(clients.elasticache),
new AwsLightsailAdapter(clients.lightsail),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
+35
View File
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
import {mapAwsSdkError} from '../adapter-aws/awsErrors' import {mapAwsSdkError} from '../adapter-aws/awsErrors'
import {serviceForAccount} from '../cloudProxy' import {serviceForAccount} from '../cloudProxy'
import {CloudProxyService} from '../service/CloudProxyService' import {CloudProxyService} from '../service/CloudProxyService'
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
// Header (and query-param fallback for direct links such as object downloads) // Header (and query-param fallback for direct links such as object downloads)
// used by the frontend to scope every request to an AWS account. // used by the frontend to scope every request to an AWS account.
@@ -136,6 +137,40 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
}) })
}) })
app.post('/aws/services/lightsail/resources/:id/static-files', async (c) => {
return withRuntime(c, async () => {
const form = await c.req.formData()
const files = form.getAll('files').filter((entry): entry is File => entry instanceof File)
if (files.length === 0 || files.length !== form.getAll('files').length) {
throw new ValidationError('At least one file is required.')
}
const upload = new FormData()
for (const file of files) {
upload.append('files', file, file.name)
}
return c.json(await deployLightsailStaticFiles(c.req.param('id'), upload))
})
})
app.post('/aws/services/lightsail/resources/:id/node-application', async (c) => {
return withRuntime(c, async () => {
const form = await c.req.formData()
const files = form.getAll('files').filter((entry): entry is File => entry instanceof File)
if (files.length === 0 || files.length !== form.getAll('files').length) {
throw new ValidationError('At least one file or zip archive is required.')
}
const portValue = form.get('port')
const port = typeof portValue === 'string' && /^\d+$/.test(portValue) ? Number(portValue) : 3000
if (port < 1 || port > 65535) throw new ValidationError('Port must be between 1 and 65535.')
const upload = new FormData()
for (const file of files) upload.append('files', file, file.name)
const startCommand = form.get('startCommand')
if (typeof startCommand === 'string' && startCommand.trim()) upload.append('startCommand', startCommand.trim())
upload.append('port', String(port))
return c.json(await deployLightsailNodeApplication(c.req.param('id'), upload))
})
})
app.get('/:cloud/services/nosql/resources/:id/containers', async (c) => { app.get('/:cloud/services/nosql/resources/:id/containers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
+46
View File
@@ -1,5 +1,6 @@
import {describe, expect, test} from 'bun:test' import {describe, expect, test} from 'bun:test'
import {createEc2Router} from './ec2' import {createEc2Router} from './ec2'
import {SshCheckError, type SshCheckResult} from '../services/sshCheck'
import type { import type {
CreateAmiInput, CreateAmiInput,
CreateNatGatewayInput, CreateNatGatewayInput,
@@ -202,6 +203,51 @@ describe('POST /instances/:id/terminate', () => {
}) })
}) })
describe('POST /instances/:id/ssh/check', () => {
test('returns the backend SSH check response', async () => {
let checkedInstanceId: string | undefined
const router = createEc2Router(fakeService, {
check: async (instanceId: string): Promise<SshCheckResult> => {
checkedInstanceId = instanceId
return {
ok: true,
banner: 'SSH-2.0-OpenSSH_9.7',
checkedFrom: 'UI backend',
checkedAt: '2026-10-08T12:00:00.000Z',
target: {host: 'host.docker.internal', port: 2222},
}
},
})
const res = await router.request('/instances/i-test1/ssh/check', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({host: 'attacker.example', port: 22}),
})
expect(res.status).toBe(200)
expect(checkedInstanceId).toBe('i-test1')
expect(await res.json()).toEqual({
ok: true,
banner: 'SSH-2.0-OpenSSH_9.7',
checkedFrom: 'UI backend',
checkedAt: '2026-10-08T12:00:00.000Z',
target: {host: 'host.docker.internal', port: 2222},
})
})
test('maps typed checker errors', async () => {
const router = createEc2Router(fakeService, {
check: async () => { throw new SshCheckError('CONNECTION_TIMEOUT', 'SSH connection timed out', 504) },
})
const res = await router.request('/instances/i-test1/ssh/check', {method: 'POST'})
expect(res.status).toBe(504)
expect(await res.json()).toEqual({ok: false, code: 'CONNECTION_TIMEOUT', error: 'SSH connection timed out'})
})
})
describe('PUT /instances/:id/security-groups', () => { describe('PUT /instances/:id/security-groups', () => {
test('replaces security groups', async () => { test('replaces security groups', async () => {
let receivedGroupIds: string[] | undefined let receivedGroupIds: string[] | undefined
+32 -1
View File
@@ -11,8 +11,10 @@ import type {
VpcWizardInput, VpcWizardInput,
} from '../services/ec2' } from '../services/ec2'
import {ec2Service} from '../services/ec2' import {ec2Service} from '../services/ec2'
import {SshCheckError, sshCheckService} from '../services/sshCheck'
type Ec2Service = typeof ec2Service type Ec2Service = typeof ec2Service
type SshCheckService = typeof sshCheckService
type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType} type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType}
type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{ type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
@@ -21,6 +23,21 @@ type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
sshPort?: number sshPort?: number
containerId?: string containerId?: string
containerStatus?: 'running' | 'stopped' | 'unavailable' containerStatus?: 'running' | 'stopped' | 'unavailable'
state?: string
launchStatus?: string
launchEvents?: Array<{stage?: string; status?: string; timestamp?: string; message?: string}>
selectedImageType?: string
selectedImageReference?: string
sshUser?: string
sshPreinstalled?: boolean
initType?: string
sshServiceName?: string
sshStatus?: string
sshStatusMessage?: string
keyName?: string
keyInjectionStatus?: string
publishedPorts?: Record<string, number>
failure?: {code?: string; message?: string} | string
}>}} }>}}
async function connectionDetails(instanceId: string) { async function connectionDetails(instanceId: string) {
@@ -49,7 +66,7 @@ function handleError(c: Context, error: unknown) {
return c.json({error: msg}, 400) return c.json({error: msg}, 400)
} }
export function createEc2Router(svc: Ec2Service = ec2Service) { export function createEc2Router(svc: Ec2Service = ec2Service, sshChecker: SshCheckService = sshCheckService) {
const app = new Hono() const app = new Hono()
// Instances // Instances
@@ -108,9 +125,23 @@ export function createEc2Router(svc: Ec2Service = ec2Service) {
} }
}) })
app.post('/instances/:instanceId/ssh/check', async (c) => {
try {
return c.json(await sshChecker.check(c.req.param('instanceId')))
} catch (error) {
if (error instanceof SshCheckError) {
return c.json({ok: false, code: error.code, error: error.message}, error.status)
}
return c.json({ok: false, code: 'DIAGNOSTICS_UNAVAILABLE', error: 'SSH check failed'}, 502)
}
})
app.put('/instances/:instanceId/tags', async (c) => { app.put('/instances/:instanceId/tags', async (c) => {
try { try {
const {toAdd, toRemove} = await c.req.json<{toAdd: Ec2Tag[]; toRemove: string[]}>() const {toAdd, toRemove} = await c.req.json<{toAdd: Ec2Tag[]; toRemove: string[]}>()
if (!Array.isArray(toAdd) || !Array.isArray(toRemove)) {
return c.json({error: 'toAdd and toRemove must be arrays'}, 400)
}
await svc.updateInstanceTags(c.req.param('instanceId'), toAdd, toRemove) await svc.updateInstanceTags(c.req.param('instanceId'), toAdd, toRemove)
return c.json({ok: true}) return c.json({ok: true})
} catch (error) { } catch (error) {
@@ -0,0 +1,42 @@
import {afterEach, describe, expect, mock, test} from 'bun:test'
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from './lightsailDeployment'
const originalEndpoint = process.env.FLOCI_ENDPOINT
const originalFetch = globalThis.fetch
afterEach(() => {
process.env.FLOCI_ENDPOINT = originalEndpoint
globalThis.fetch = originalFetch
mock.restore()
})
describe('deployLightsailNodeApplication', () => {
test('forwards package uploads and preserves core deployment diagnostics', async () => {
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
const fetch = mock(() => Promise.resolve(new Response(JSON.stringify({status: 'deployed', files: 2, port: 3000, releaseId: 'release'}), {headers: {'content-type': 'application/json'}})))
globalThis.fetch = fetch as unknown as typeof globalThis.fetch
const form = new FormData()
form.append('files', new File(['{}'], 'package.json'))
form.append('port', '3000')
await expect(deployLightsailNodeApplication('node app', form)).resolves.toEqual({status: 'deployed', files: 2, port: 3000, releaseId: 'release'})
expect(fetch).toHaveBeenCalledWith('http://core.test:4566/_floci/diagnostics/lightsail/instances/node%20app/node-application', expect.objectContaining({
method: 'POST', body: form, redirect: 'error',
}))
})
})
describe('deployLightsailStaticFiles', () => {
test('forwards multipart files only to the diagnostics deployment endpoint', async () => {
process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
const fetch = mock(() => Promise.resolve(new Response(JSON.stringify({status: 'deployed', files: 1}), {headers: {'content-type': 'application/json'}})))
globalThis.fetch = fetch as unknown as typeof globalThis.fetch
const form = new FormData()
form.append('files', new File(['<h1>ok</h1>'], 'site/index.html'))
await expect(deployLightsailStaticFiles('web site', form)).resolves.toEqual({status: 'deployed', files: 1})
expect(fetch).toHaveBeenCalledWith('http://core.test:4566/_floci/diagnostics/lightsail/instances/web%20site/static-files', expect.objectContaining({
method: 'POST', body: form, redirect: 'error',
}))
})
})
@@ -0,0 +1,44 @@
import {httpStatusToCloudError, RuntimeUnavailableError} from '../cloud-spi/errors'
export type LightsailStaticDeployment = {status: 'deployed'; files: number}
export type LightsailNodeDeployment = {status: 'deployed'; files: number; port: number; releaseId: string}
export async function deployLightsailStaticFiles(instanceName: string, form: FormData): Promise<LightsailStaticDeployment> {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
let response: Response
try {
response = await fetch(new URL(`/_floci/diagnostics/lightsail/instances/${encodeURIComponent(instanceName)}/static-files`, endpoint).toString(), {
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(30_000),
})
} catch (error) {
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
}
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown}
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Static file deployment failed')
if (body.status !== 'deployed' || typeof body.files !== 'number') {
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid deployment response')
}
return {status: 'deployed', files: body.files}
}
export async function deployLightsailNodeApplication(instanceName: string, form: FormData): Promise<LightsailNodeDeployment> {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) throw new RuntimeUnavailableError('Floci diagnostics are unavailable')
let response: Response
try {
response = await fetch(new URL(`/_floci/diagnostics/lightsail/instances/${encodeURIComponent(instanceName)}/node-application`, endpoint).toString(), {
method: 'POST', body: form, redirect: 'error', signal: AbortSignal.timeout(360_000),
})
} catch (error) {
throw new RuntimeUnavailableError('Floci diagnostics are unavailable', {cause: error})
}
const body = await response.json().catch(() => ({})) as {message?: unknown; files?: unknown; status?: unknown; port?: unknown; releaseId?: unknown}
if (!response.ok) throw httpStatusToCloudError(response.status, typeof body.message === 'string' ? body.message : 'Node.js deployment failed')
if (body.status !== 'deployed' || typeof body.files !== 'number' || typeof body.port !== 'number' || typeof body.releaseId !== 'string') {
throw new RuntimeUnavailableError('Floci diagnostics returned an invalid Node.js deployment response')
}
return {status: 'deployed', files: body.files, port: body.port, releaseId: body.releaseId}
}
+157
View File
@@ -0,0 +1,157 @@
import {afterEach, describe, expect, test} from 'bun:test'
import {createServer, type Server} from 'node:net'
import {
createSshCheckService,
readSshBanner,
SshCheckError,
type SshTarget,
} from './sshCheck'
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))))
})
async function listen(handler: (socket: import('node:net').Socket) => void): Promise<SshTarget> {
const server = createServer(handler)
servers.push(server)
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Test server has no TCP address')
return {host: '127.0.0.1', port: address.port}
}
describe('readSshBanner', () => {
test('reads a real SSH identification banner', async () => {
const target = await listen((socket) => socket.end('SSH-2.0-OpenSSH_9.7\r\n'))
expect(await readSshBanner(target, 500)).toBe('SSH-2.0-OpenSSH_9.7')
})
test('assembles a fragmented banner', async () => {
const target = await listen((socket) => {
socket.write('SSH-2.0-Open')
setTimeout(() => socket.end('SSH_9.7\r\n'), 10)
})
expect(await readSshBanner(target, 500)).toBe('SSH-2.0-OpenSSH_9.7')
})
test('rejects a connection that closes without an SSH banner', async () => {
const target = await listen((socket) => socket.end('HTTP/1.1 200 OK\r\n'))
await expect(readSshBanner(target, 500)).rejects.toMatchObject({code: 'SSH_BANNER_MISSING'})
})
test('rejects an SSH identification line longer than the protocol limit', async () => {
const target = await listen((socket) => socket.end(`SSH-2.0-${'x'.repeat(246)}\r\n`))
await expect(readSshBanner(target, 500)).rejects.toMatchObject({code: 'SSH_BANNER_MISSING'})
})
test('maps a silent socket to a timeout', async () => {
const target = await listen(() => {})
await expect(readSshBanner(target, 20)).rejects.toMatchObject({code: 'CONNECTION_TIMEOUT'})
})
test('maps a refused socket connection', async () => {
const temporaryServer = createServer()
await new Promise<void>((resolve) => temporaryServer.listen(0, '127.0.0.1', resolve))
const address = temporaryServer.address()
if (!address || typeof address === 'string') throw new Error('Test server has no TCP address')
const port = address.port
await new Promise<void>((resolve) => temporaryServer.close(() => resolve()))
await expect(readSshBanner({host: '127.0.0.1', port}, 500)).rejects.toMatchObject({code: 'CONNECTION_REFUSED'})
})
})
describe('sshCheckService', () => {
test('derives the exact instance target and overrides a loopback host', async () => {
let receivedTarget: SshTarget | undefined
const service = createSshCheckService({
fetchDiagnostics: async () => ({ec2: {instances: [
{instanceId: 'i-other', sshHost: '192.0.2.10', sshPort: 2200, containerStatus: 'running'},
{instanceId: 'i-wanted', sshHost: '127.0.0.1', sshPort: 2222, containerStatus: 'running'},
]}}),
loopbackHost: 'ssh-host.test',
connect: async (target) => {
receivedTarget = target
return 'SSH-2.0-test'
},
now: () => new Date('2026-10-08T12:00:00.000Z'),
})
const result = await service.check('i-wanted')
expect(receivedTarget).toEqual({host: 'ssh-host.test', port: 2222})
expect(result).toEqual({
ok: true,
banner: 'SSH-2.0-test',
checkedFrom: 'UI backend',
checkedAt: '2026-10-08T12:00:00.000Z',
target: {host: 'ssh-host.test', port: 2222},
})
})
test('does not allow diagnostics to redirect the backend probe', async () => {
let receivedTarget: SshTarget | undefined
const service = createSshCheckService({
fetchDiagnostics: async () => ({ec2: {instances: [
{instanceId: 'i-wanted', sshHost: '192.0.2.20', sshPort: 2222, containerStatus: 'running'},
]}}),
loopbackHost: 'ssh-host.test',
connect: async (target) => {
receivedTarget = target
return 'SSH-2.0-test'
},
})
await service.check('i-wanted')
expect(receivedTarget).toEqual({host: 'ssh-host.test', port: 2222})
})
test('reports malformed diagnostics before attempting a connection', async () => {
const service = createSshCheckService({
fetchDiagnostics: async () => ({ec2: {instances: {} as never}}),
connect: async () => { throw new Error('must not connect') },
})
await expect(service.check('i-running')).rejects.toMatchObject({
code: 'DIAGNOSTICS_UNAVAILABLE',
status: 502,
})
})
test('reports missing diagnostics for an unmatched instance', async () => {
const service = createSshCheckService({fetchDiagnostics: async () => ({ec2: {instances: []}})})
await expect(service.check('i-missing')).rejects.toMatchObject({code: 'DIAGNOSTICS_UNAVAILABLE', status: 404})
})
test('reports a stopped instance before attempting a connection', async () => {
const service = createSshCheckService({
fetchDiagnostics: async () => ({ec2: {instances: [
{instanceId: 'i-stopped', sshHost: '127.0.0.1', sshPort: 2222, containerStatus: 'stopped'},
]}}),
connect: async () => { throw new Error('must not connect') },
})
await expect(service.check('i-stopped')).rejects.toMatchObject({code: 'INSTANCE_NOT_RUNNING', status: 409})
})
test('rejects an invalid banner returned by a connector', async () => {
const service = createSshCheckService({
fetchDiagnostics: async () => ({ec2: {instances: [
{instanceId: 'i-running', sshHost: '127.0.0.1', sshPort: 2222, containerStatus: 'running'},
]}}),
connect: async () => 'not ssh',
})
await expect(service.check('i-running')).rejects.toBeInstanceOf(SshCheckError)
await expect(service.check('i-running')).rejects.toMatchObject({code: 'SSH_BANNER_MISSING'})
})
})
+194
View File
@@ -0,0 +1,194 @@
import {createConnection} from 'node:net'
const DEFAULT_TIMEOUT_MS = 3_000
const MAX_BANNER_BYTES = 8_192
type Ec2DiagnosticInstance = {
instanceId?: string
sshHost?: string
sshPort?: number
containerStatus?: 'running' | 'stopped' | 'unavailable'
}
type DiagnosticResponse = {
ec2?: {instances?: Ec2DiagnosticInstance[]}
}
export type SshTarget = {
host: string
port: number
}
export type SshCheckResult = {
ok: true
banner: string
checkedFrom: 'UI backend'
checkedAt: string
target: SshTarget
}
export type SshCheckErrorCode =
| 'DIAGNOSTICS_UNAVAILABLE'
| 'INSTANCE_NOT_RUNNING'
| 'CONNECTION_REFUSED'
| 'CONNECTION_TIMEOUT'
| 'SSH_BANNER_MISSING'
export class SshCheckError extends Error {
constructor(
readonly code: SshCheckErrorCode,
message: string,
readonly status: 404 | 409 | 502 | 503 | 504,
) {
super(message)
this.name = 'SshCheckError'
}
}
export type DiagnosticFetcher = () => Promise<DiagnosticResponse>
export type SshSocketConnector = (target: SshTarget, timeoutMs: number) => Promise<string>
type SshCheckDependencies = {
fetchDiagnostics?: DiagnosticFetcher
connect?: SshSocketConnector
loopbackHost?: string
now?: () => Date
timeoutMs?: number
}
async function fetchDiagnostics(): Promise<DiagnosticResponse> {
const endpoint = process.env.FLOCI_ENDPOINT
if (!endpoint) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
}
let response: Response
try {
response = await fetch(`${endpoint}/_floci/diagnose`, {signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})
} catch {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
}
if (!response.ok) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
}
try {
return await response.json() as DiagnosticResponse
} catch {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
}
}
export function readSshBanner(target: SshTarget, timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
const socket = createConnection(target)
let settled = false
let buffer = Buffer.alloc(0)
const finish = (error?: SshCheckError, banner?: string) => {
if (settled) return
settled = true
clearTimeout(timer)
socket.destroy()
if (error) reject(error)
else resolve(banner!)
}
const timer = setTimeout(() => {
finish(new SshCheckError('CONNECTION_TIMEOUT', `SSH connection to ${target.host}:${target.port} timed out`, 504))
}, timeoutMs)
socket.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk])
if (buffer.length > MAX_BANNER_BYTES) {
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server did not send a valid banner', 502))
return
}
const lines = buffer.toString('utf8').split(/\r?\n/)
for (let index = 0; index < lines.length - 1; index++) {
const line = lines[index]
if (/^SSH-\d+\.\d+-[\x21-\x7e]+(?: [\x20-\x7e]*)?$/.test(line)
&& Buffer.byteLength(line, 'ascii') <= 253) {
finish(undefined, line)
return
}
}
})
socket.on('end', () => {
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server closed without sending a valid banner', 502))
})
socket.on('close', () => {
finish(new SshCheckError('SSH_BANNER_MISSING', 'SSH server closed without sending a valid banner', 502))
})
socket.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'ECONNREFUSED') {
finish(new SshCheckError('CONNECTION_REFUSED', `SSH connection to ${target.host}:${target.port} was refused`, 503))
return
}
if (error.code === 'ETIMEDOUT') {
finish(new SshCheckError('CONNECTION_TIMEOUT', `SSH connection to ${target.host}:${target.port} timed out`, 504))
return
}
finish(new SshCheckError('CONNECTION_REFUSED', `SSH connection to ${target.host}:${target.port} failed`, 503))
})
})
}
export function createSshCheckService(dependencies: SshCheckDependencies = {}) {
const diagnosticFetcher = dependencies.fetchDiagnostics ?? fetchDiagnostics
const connector = dependencies.connect ?? readSshBanner
const sshHost = dependencies.loopbackHost || process.env.FLOCI_EC2_SSH_HOST || 'localhost'
const now = dependencies.now ?? (() => new Date())
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS
return {
async check(instanceId: string): Promise<SshCheckResult> {
let diagnostics: DiagnosticResponse
try {
diagnostics = await diagnosticFetcher()
} catch (error) {
if (error instanceof SshCheckError) throw error
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics are unavailable', 503)
}
if (!diagnostics || typeof diagnostics !== 'object'
|| !Array.isArray(diagnostics.ec2?.instances)) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', 'Floci diagnostics returned an invalid response', 502)
}
const instance = diagnostics.ec2.instances.find((candidate) => candidate.instanceId === instanceId)
if (!instance) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
}
if (!instance.containerStatus) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
}
if (instance.containerStatus !== 'running') {
throw new SshCheckError('INSTANCE_NOT_RUNNING', `Instance ${instanceId} is not running`, 409)
}
if (typeof instance.sshPort !== 'number' || !Number.isInteger(instance.sshPort)
|| instance.sshPort < 1 || instance.sshPort > 65_535) {
throw new SshCheckError('DIAGNOSTICS_UNAVAILABLE', `SSH diagnostics for instance ${instanceId} are unavailable`, 404)
}
const target = {
host: sshHost,
port: instance.sshPort,
}
const banner = await connector(target, timeoutMs)
if (!/^SSH-\d+\.\d+-/.test(banner)) {
throw new SshCheckError('SSH_BANNER_MISSING', 'SSH server did not send a valid banner', 502)
}
return {
ok: true,
banner,
checkedFrom: 'UI backend',
checkedAt: now().toISOString(),
target,
}
},
}
}
export const sshCheckService = createSshCheckService()
+18
View File
@@ -26,6 +26,8 @@ export const apiEndpointKeys = {
start: "clouds.services.resources.start", start: "clouds.services.resources.start",
stop: "clouds.services.resources.stop", stop: "clouds.services.resources.stop",
reboot: "clouds.services.resources.reboot", reboot: "clouds.services.resources.reboot",
deployLightsailStaticFiles: "clouds.services.resources.lightsail.static-files.deploy",
deployLightsailNodeApplication: "clouds.services.resources.lightsail.node-application.deploy",
publish: "clouds.services.resources.notifications.publish", publish: "clouds.services.resources.notifications.publish",
createSnsSubscription: "clouds.services.resources.notifications.subscription.create", createSnsSubscription: "clouds.services.resources.notifications.subscription.create",
listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list", listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list",
@@ -228,6 +230,7 @@ export const apiEndpointKeys = {
securityGroups: "aws.ec2.instances.securityGroups", securityGroups: "aws.ec2.instances.securityGroups",
createImage: "aws.ec2.instances.create-image", createImage: "aws.ec2.instances.create-image",
console: "aws.ec2.instances.console", console: "aws.ec2.instances.console",
checkSsh: "aws.ec2.instances.ssh.check",
}, },
amis: { amis: {
list: "aws.ec2.amis.list", list: "aws.ec2.amis.list",
@@ -643,6 +646,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "PATCH", method: "PATCH",
}, },
], ],
[
apiEndpointKeys.clouds.resources.deployLightsailStaticFiles,
{
path: "/clouds/aws/services/lightsail/resources/:id/static-files",
method: "POST",
},
],
[
apiEndpointKeys.clouds.resources.deployLightsailNodeApplication,
{
path: "/clouds/aws/services/lightsail/resources/:id/node-application",
method: "POST",
},
],
[ [
apiEndpointKeys.clouds.resources.delete, apiEndpointKeys.clouds.resources.delete,
{ {
@@ -1129,6 +1146,7 @@ export const endpointRegistry: EndpointRegistry = new Map([
[apiEndpointKeys.aws.ec2.instances.securityGroups, { path: "/ec2/instances/:instanceId/security-groups", method: "PUT" }], [apiEndpointKeys.aws.ec2.instances.securityGroups, { path: "/ec2/instances/:instanceId/security-groups", method: "PUT" }],
[apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST" }], [apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST" }],
[apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET" }], [apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET" }],
[apiEndpointKeys.aws.ec2.instances.checkSsh, { path: "/ec2/instances/:instanceId/ssh/check", method: "POST" }],
[apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET" }], [apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET" }],
[apiEndpointKeys.aws.ec2.amis.deregister, { path: "/ec2/amis/:imageId/deregister", method: "POST" }], [apiEndpointKeys.aws.ec2.amis.deregister, { path: "/ec2/amis/:imageId/deregister", method: "POST" }],
[apiEndpointKeys.aws.ec2.keyPairs.list, { path: "/ec2/key-pairs", method: "GET" }], [apiEndpointKeys.aws.ec2.keyPairs.list, { path: "/ec2/key-pairs", method: "GET" }],
@@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
authorizeEc2SecurityGroupEgress, authorizeEc2SecurityGroupEgress,
authorizeEc2SecurityGroupIngress, authorizeEc2SecurityGroupIngress,
checkEc2InstanceSsh,
createEc2SecurityGroup, createEc2SecurityGroup,
revokeEc2SecurityGroupEgress, revokeEc2SecurityGroupEgress,
revokeEc2SecurityGroupIngress, revokeEc2SecurityGroupIngress,
updateEc2InstanceTags,
updateEc2InstanceSecurityGroups, updateEc2InstanceSecurityGroups,
} from "./ec2.api"; } from "./ec2.api";
@@ -50,6 +52,37 @@ describe("createEc2SecurityGroup", () => {
); );
}); });
describe("checkEc2InstanceSsh", () => {
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
ok: true,
banner: "SSH-2.0-OpenSSH_9.6",
checkedFrom: "UI backend",
checkedAt: "2026-10-09T00:00:00Z",
target: { host: "host.docker.internal", port: 2200 },
}), {status: 200, headers: {"Content-Type": "application/json"}}));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
fetchMock.mockReset();
});
it("checks SSH through the UI backend", async () => {
await expect(checkEc2InstanceSsh("i-test")).resolves.toMatchObject({
ok: true,
banner: "SSH-2.0-OpenSSH_9.6",
});
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/api/ec2/instances/i-test/ssh/check");
expect(init?.method).toBe("POST");
});
});
describe("EC2 security-group rule requests", () => { describe("EC2 security-group rule requests", () => {
const fetchMock = vi.fn<typeof fetch>(); const fetchMock = vi.fn<typeof fetch>();
@@ -111,3 +144,32 @@ describe("updateEc2InstanceSecurityGroups", () => {
expect(JSON.parse(String(init?.body))).toEqual({groupIds: ["sg-default", "sg-web"]}); expect(JSON.parse(String(init?.body))).toEqual({groupIds: ["sg-default", "sg-web"]});
}); });
}); });
describe("updateEc2InstanceTags", () => {
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
fetchMock.mockReset();
});
it("sends tags as one JSON object", async () => {
await updateEc2InstanceTags("i-test", [{ key: "Name", value: "web-01" }], ["old-tag"]);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/api/ec2/instances/i-test/tags");
expect(init?.method).toBe("PUT");
expect(JSON.parse(String(init?.body))).toEqual({
toAdd: [{ key: "Name", value: "web-01" }],
toRemove: ["old-tag"],
});
});
});
+39 -1
View File
@@ -83,6 +83,35 @@ export type Ec2InstanceConnection = {
sshPort?: number; sshPort?: number;
containerId?: string; containerId?: string;
containerStatus: 'running' | 'stopped' | 'unavailable'; containerStatus: 'running' | 'stopped' | 'unavailable';
state?: string;
launchStatus?: string;
launchEvents?: Ec2LaunchEvent[];
selectedImageType?: string;
selectedImageReference?: string;
sshUser?: string;
sshPreinstalled?: boolean;
initType?: string;
sshServiceName?: string;
sshStatus?: string;
sshStatusMessage?: string;
keyInjectionStatus?: string;
publishedPorts?: Record<string, number>;
failure?: { code?: string; message?: string } | string;
};
export type Ec2LaunchEvent = {
stage?: string;
status?: string;
timestamp?: string;
message?: string;
};
export type Ec2SshCheckResult = {
ok: true;
banner: string;
checkedFrom: 'UI backend';
checkedAt: string;
target: { host: string; port: number };
}; };
export type Ec2KeyPair = { export type Ec2KeyPair = {
@@ -323,6 +352,15 @@ export async function terminateEc2Instance(instanceId: string): Promise<void> {
); );
} }
export async function checkEc2InstanceSsh(instanceId: string): Promise<Ec2SshCheckResult> {
const res = await apiClient.call<Ec2SshCheckResult>(
apiEndpointKeys.aws.ec2.instances.checkSsh,
{ body: {} },
{ instanceId },
);
return res.data;
}
export async function updateEc2InstanceTags( export async function updateEc2InstanceTags(
instanceId: string, instanceId: string,
toAdd: Ec2Tag[], toAdd: Ec2Tag[],
@@ -330,7 +368,7 @@ export async function updateEc2InstanceTags(
): Promise<void> { ): Promise<void> {
await apiClient.call( await apiClient.call(
apiEndpointKeys.aws.ec2.instances.tags, apiEndpointKeys.aws.ec2.instances.tags,
{ body: JSON.stringify({ toAdd, toRemove }), headers: { "content-type": "application/json" } }, { body: { toAdd, toRemove } },
{ instanceId }, { instanceId },
); );
} }
@@ -45,6 +45,7 @@ import { getAccountId } from "@/lib/accountStore";
type CloudPathParams = Record<string, string>; type CloudPathParams = Record<string, string>;
const DATABASE_MUTATION_TIMEOUT_MS = 5 * 60_000; const DATABASE_MUTATION_TIMEOUT_MS = 5 * 60_000;
const LIGHTSAIL_CREATION_TIMEOUT_MS = 3 * 60_000;
const SQL_DATA_TIMEOUT_MS = 45_000; const SQL_DATA_TIMEOUT_MS = 45_000;
/** Floci completes Insights queries instantly by default, but bounded server-side polling can take longer. */ /** Floci completes Insights queries instantly by default, but bounded server-side polling can take longer. */
const LOGS_QUERY_TIMEOUT_MS = 20_000; const LOGS_QUERY_TIMEOUT_MS = 20_000;
@@ -167,6 +168,8 @@ export async function createCloudResource(
const timeout = const timeout =
(cloud === "azure" || cloud === "aws") && service === "database" (cloud === "azure" || cloud === "aws") && service === "database"
? DATABASE_MUTATION_TIMEOUT_MS ? DATABASE_MUTATION_TIMEOUT_MS
: cloud === "aws" && service === "lightsail"
? LIGHTSAIL_CREATION_TIMEOUT_MS
: undefined; : undefined;
const res = await apiClient.call<CloudResource, Record<string, unknown>>( const res = await apiClient.call<CloudResource, Record<string, unknown>>(
apiEndpointKeys.clouds.resources.create, apiEndpointKeys.clouds.resources.create,
@@ -1104,6 +1107,36 @@ export async function uploadStorageObject(
); );
} }
export type LightsailStaticDeployment = {status: 'deployed'; files: number}
export type LightsailNodeDeployment = {status: 'deployed'; files: number; port: number; releaseId: string}
export async function deployLightsailStaticFiles(instanceId: string, files: File[]): Promise<LightsailStaticDeployment> {
const form = new FormData()
for (const file of files) {
const path = file.webkitRelativePath || file.name
form.append('files', file, path)
}
const res = await apiClient.call<LightsailStaticDeployment>(
apiEndpointKeys.clouds.resources.deployLightsailStaticFiles,
requestOptions('aws', 'lightsail', {rawBody: form}),
{id: instanceId},
)
return res.data
}
export async function deployLightsailNodeApplication(instanceId: string, files: File[], startCommand: string, port: number): Promise<LightsailNodeDeployment> {
const form = new FormData()
for (const file of files) form.append('files', file, file.webkitRelativePath || file.name)
if (startCommand.trim()) form.append('startCommand', startCommand.trim())
form.append('port', String(port))
const res = await apiClient.call<LightsailNodeDeployment>(
apiEndpointKeys.clouds.resources.deployLightsailNodeApplication,
requestOptions('aws', 'lightsail', {rawBody: form, timeout: 370_000}),
{id: instanceId},
)
return res.data
}
export function storageObjectDownloadUrl( export function storageObjectDownloadUrl(
cloud: CloudProvider, cloud: CloudProvider,
resourceId: string, resourceId: string,
+101 -19
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CopyValueButton } from './CopyValueButton' import { CopyValueButton } from './CopyValueButton'
import { ServerLaunchProgress } from './ServerLaunchProgress'
import { import {
AlertTriangle, AlertTriangle,
HardDrive, HardDrive,
@@ -18,6 +19,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { import {
authorizeEc2SecurityGroupIngress, authorizeEc2SecurityGroupIngress,
checkEc2InstanceSsh,
createEc2Ami, createEc2Ami,
createEc2KeyPair, createEc2KeyPair,
createEc2SecurityGroup, createEc2SecurityGroup,
@@ -53,6 +55,8 @@ import debianImage from '../../../../OS_img/debian.jpg'
import redHatImage from '../../../../OS_img/RedHat.png' import redHatImage from '../../../../OS_img/RedHat.png'
import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png' import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png'
const EC2_LAUNCH_MINIMUM_MS = 5_000
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
interface ComputePanelProps { interface ComputePanelProps {
@@ -420,6 +424,9 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
invalidate() invalidate()
}, },
}) })
const sshCheck = useMutation({
mutationFn: () => checkEc2InstanceSsh(instanceId),
})
if (query.isLoading) { if (query.isLoading) {
return ( return (
@@ -656,25 +663,82 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
{tab === 'connection' && (() => { {tab === 'connection' && (() => {
const connection = inst.connection const connection = inst.connection
const sshUser = connection?.sshUser || 'root'
const command = connection?.sshHost && connection.sshPort && inst.keyName 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 : undefined
if (state === 'stopped') { if (!connection) {
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Start this instance before connecting over SSH.</p> return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Connection diagnostics are unavailable.</p>
} }
if (state === 'terminated') { const canCheckSsh = state === 'running' && connection.containerStatus === 'running'
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Terminated instances cannot accept connections.</p> && Boolean(connection.sshPort)
} const failure = typeof connection.failure === 'string'
if (state !== 'running') { ? connection.failure
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable while this instance is {state}.</p> : connection.failure?.message
} return <div className="ec2-connect-panel">
if (!command || connection?.containerStatus !== 'running') { {!canCheckSsh && <div className="ec2-connection-notice">
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable. Backing container status: {connection?.containerStatus ?? 'unavailable'}.</p> {state === 'terminated'
} ? 'This instance is terminated. Launch diagnostics remain available below.'
return <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> : state === 'stopped'
<div className="meta-row"><span className="meta-label">SSH port</span><span className="meta-value">{connection.sshPort}</span></div> ? '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">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> <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> </div>
})()} })()}
@@ -810,7 +874,7 @@ const QUICK_START_AMIS: Array<{ family: Exclude<AmiFamily, 'other'>; label: stri
{ family: 'ubuntu', label: 'Ubuntu', image: ubuntuImage }, { 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: '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: '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 }> = [ 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 } return { protocol: rule.protocol, fromPort, toPort, cidr: rule.cidr }
} }
const INSTANCE_TYPES = [ const X86_INSTANCE_TYPES = [
't2.micro', 't2.small', 't2.medium', 't2.micro', 't2.small', 't2.medium',
't3.micro', 't3.small', 't3.medium', 't3.large', 't3.xlarge', 't3.micro', 't3.small', 't3.medium', 't3.large', 't3.xlarge',
'm5.large', 'm5.xlarge', 'm5.large', 'm5.xlarge',
'c5.large', 'c5.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({ export function LaunchInstanceForm({
cloud, cloud,
@@ -906,7 +971,15 @@ export function LaunchInstanceForm({
}, [selectedSubnet, sgsQ.data]) }, [selectedSubnet, sgsQ.data])
const createMut = useMutation({ 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) => { onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['cloud-resources', cloud, 'compute'] }) void qc.invalidateQueries({ queryKey: ['cloud-resources', cloud, 'compute'] })
onSuccess(created) onSuccess(created)
@@ -1030,8 +1103,15 @@ export function LaunchInstanceForm({
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch)) return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
}) })
const selectedAmi = amis.find((ami) => ami.imageId === imageId) 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 selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
const availableSecurityGroups = (sgsQ.data ?? []).filter((group) => !selectedSubnet || group.vpcId === selectedSubnet.vpcId) 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 }> = [ const steps: Array<{ number: LaunchStep; label: string }> = [
{ number: 1, label: 'Name and AMI' }, { number: 1, label: 'Name and AMI' },
{ number: 2, label: 'Instance and key pair' }, { number: 2, label: 'Instance and key pair' },
@@ -1115,8 +1195,9 @@ export function LaunchInstanceForm({
<label className="launch-field"> <label className="launch-field">
<span>Instance type <em className="field-required">*</em></span> <span>Instance type <em className="field-required">*</em></span>
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}> <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> </select>
{selectedAmi?.architecture === 'arm64' && <small>ARM64 AMIs require Graviton instance types.</small>}
</label> </label>
<div className="launch-field launch-field--span"> <div className="launch-field launch-field--span">
<span>Key pair</span> <span>Key pair</span>
@@ -1314,6 +1395,7 @@ export function LaunchInstanceForm({
{stepError && <div className="form-error launch-field--span">{stepError}</div>} {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.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" 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> <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>} {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>}
@@ -11,8 +11,10 @@ import {
} from "@/api/aws/ec2.api"; } from "@/api/aws/ec2.api";
import type { CloudProvider } from "@/types/cloud"; import type { CloudProvider } from "@/types/cloud";
import type { CloudResource } from "@/types/resource"; import type { CloudResource } from "@/types/resource";
import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
const IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const DATABASE_CREATION_MINIMUM_MS = 5_000;
interface CreateRdsInstanceFormProps { interface CreateRdsInstanceFormProps {
cloud: CloudProvider; cloud: CloudProvider;
@@ -70,8 +72,15 @@ export function CreateRdsInstanceForm({
} }
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) => mutationFn: async (values: Record<string, unknown>) => {
createCloudResource(cloud, "database", values), const startedAt = Date.now();
const created = await createCloudResource(cloud, "database", values);
const remaining = DATABASE_CREATION_MINIMUM_MS - (Date.now() - startedAt);
if (remaining > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, remaining));
}
return created;
},
onSuccess: (created) => { onSuccess: (created) => {
void qc.invalidateQueries({ void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, "database"], queryKey: ["cloud-resources", cloud, "database"],
@@ -300,6 +309,7 @@ export function CreateRdsInstanceForm({
</label> </label>
{/* ── Actions ────────────────────────────────────────────────── */} {/* ── Actions ────────────────────────────────────────────────── */}
{createMut.isPending && <div className="launch-field--span"><DatabaseLaunchProgress name={dbInstanceIdentifier} description="Starting the database container and applying its configuration." /></div>}
<div className="launch-form-actions"> <div className="launch-form-actions">
<button <button
className="button" className="button"
@@ -0,0 +1,16 @@
interface DatabaseLaunchProgressProps {
name: string;
description: string;
}
export function DatabaseLaunchProgress({ name, description }: DatabaseLaunchProgressProps) {
return <div className="database-launch-progress" role="status" aria-live="polite">
<svg viewBox="0 0 48 48" aria-hidden="true">
<path d="M7 11C7 15 14.6 17 24 17S41 15 41 11v26c0 4-7.6 6-17 6S7 41 7 37Z" fill="#0D3D8C" />
<path d="M7 11C7 15 14.6 17 24 17S41 15 41 11v14c0 4-7.6 6-17 6S7 29 7 25Z" fill="#1769C2"><animate attributeName="d" dur="1.3s" repeatCount="indefinite" values="M7 11C7 15 14.6 17 24 17S41 15 41 11v14c0 4-7.6 6-17 6S7 29 7 25Z;M7 9C7 13 14.6 15 24 15S41 13 41 9v14c0 4-7.6 6-17 6S7 27 7 23Z;M7 11C7 15 14.6 17 24 17S41 15 41 11v14c0 4-7.6 6-17 6S7 29 7 25Z" /></path>
<g><path d="M7 11C7 7 14.6 5 24 5S41 7 41 11v7c0 4-7.6 6-17 6S7 22 7 18Z" fill="#1976D2" /><ellipse cx="24" cy="11" rx="17" ry="5.5" fill="#2B91E3" /><ellipse cx="24" cy="9.7" rx="14.5" ry="3.5" fill="#39A0EA" opacity=".35" /><animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="1.3s" repeatCount="indefinite" /></g>
<g fill="#35d0b5"><circle className="database-led database-led-a" cx="16" cy="35.5" r="1.6" /><circle className="database-led database-led-b" cx="21.3" cy="36.2" r="1.6" /><circle className="database-led database-led-c" cx="26.7" cy="36.2" r="1.6" /><circle className="database-led database-led-d" cx="32" cy="35.5" r="1.6" /></g>
</svg>
<div><strong>Creating {name || "database"}</strong><small>{description}</small></div>
</div>;
}
@@ -43,6 +43,7 @@ import { ServerlessInvokePanel } from "@/components/ServerlessInvokePanel";
import { dataExplorerPath } from "@/lib/dataExplorer"; import { dataExplorerPath } from "@/lib/dataExplorer";
import { DatabaseSnapshotsPanel } from "@/components/DatabaseSnapshotsPanel"; import { DatabaseSnapshotsPanel } from "@/components/DatabaseSnapshotsPanel";
import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm"; import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm";
import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
import { AppConfigPanel } from "@/components/AppConfigPanel"; import { AppConfigPanel } from "@/components/AppConfigPanel";
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel"; import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel"; import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
@@ -73,6 +74,8 @@ import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {KinesisPanel} from "@/components/KinesisPanel"; import {KinesisPanel} from "@/components/KinesisPanel";
import {SesConsolePanel} from '@/components/SesConsolePanel' import {SesConsolePanel} from '@/components/SesConsolePanel'
import {ElastiCacheManagementPanel} from '@/components/ElastiCacheManagementPanel' import {ElastiCacheManagementPanel} from '@/components/ElastiCacheManagementPanel'
import {LightsailLaunchPanel} from '@/components/LightsailLaunchPanel'
import {LightsailInstancePanel} from '@/components/LightsailInstancePanel'
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate"; import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
import {workflowStudioPath} from "@/lib/workflowStudio"; import {workflowStudioPath} from "@/lib/workflowStudio";
import {useNavigate, useSearchParams} from "react-router-dom"; import {useNavigate, useSearchParams} from "react-router-dom";
@@ -88,6 +91,8 @@ interface DynamicResourceViewProps {
onOpenInfo: () => void; onOpenInfo: () => void;
} }
const ELASTICACHE_CREATION_MINIMUM_MS = 5_000;
interface CreateMutationVariables { interface CreateMutationVariables {
values: Record<string, unknown>; values: Record<string, unknown>;
destination?: WorkflowCreationDestination; destination?: WorkflowCreationDestination;
@@ -220,6 +225,10 @@ export function DynamicResourceView({
delete lambdaValues.blueprintQueueName; delete lambdaValues.blueprintQueueName;
delete lambdaValues.blueprintSqsBatchSize; delete lambdaValues.blueprintSqsBatchSize;
const resource = await createCloudResource(cloud, service, lambdaValues); const resource = await createCloudResource(cloud, service, lambdaValues);
if (cloud === "aws" && service === "elasticache") {
await new Promise<void>((resolve) => setTimeout(resolve, ELASTICACHE_CREATION_MINIMUM_MS));
return resource;
}
if (blueprintBucketName) { if (blueprintBucketName) {
let bucketCreated = false; let bucketCreated = false;
try { try {
@@ -817,6 +826,13 @@ export function DynamicResourceView({
}} }}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
/> />
) : service === "lightsail" && cloud === "aws" ? (
<LightsailLaunchPanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})}
/>
) : service === "database" && cloud === "aws" ? ( ) : service === "database" && cloud === "aws" ? (
<CreateRdsInstanceForm <CreateRdsInstanceForm
cloud={cloud} cloud={cloud}
@@ -858,6 +874,7 @@ export function DynamicResourceView({
) : service === "codedeploy" && cloud === "aws" ? ( ) : service === "codedeploy" && cloud === "aws" ? (
<CodeDeployCreatePanel isSubmitting={createMut.isPending} submitError={createMut.error instanceof Error ? createMut.error.message : null} onCancel={() => setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})}/> <CodeDeployCreatePanel isSubmitting={createMut.isPending} submitError={createMut.error instanceof Error ? createMut.error.message : null} onCancel={() => setCreateOpen(false)} onSubmit={(values) => createMut.mutate({values})}/>
) : ( ) : (
<>
<DynamicFormRenderer <DynamicFormRenderer
key={`${cloud}:${service}:create`} key={`${cloud}:${service}:create`}
schema={schema} schema={schema}
@@ -872,6 +889,8 @@ export function DynamicResourceView({
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate({values})} onSubmit={(values) => createMut.mutate({values})}
/> />
{service === "elasticache" && cloud === "aws" && createMut.isPending && <DatabaseLaunchProgress name="cache cluster" description="Starting the cache container and preparing its local endpoint." />}
</>
)} )}
</div> </div>
)} )}
@@ -1043,6 +1062,7 @@ export function DynamicResourceView({
)} )}
{service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />} {service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />}
{service === "elasticache" && cloud === "aws" && <ElastiCacheManagementPanel runtimeReachable={canUseRuntime} />} {service === "elasticache" && cloud === "aws" && <ElastiCacheManagementPanel runtimeReachable={canUseRuntime} />}
{service === "lightsail" && cloud === "aws" && <LightsailInstancePanel resource={activeSelected} runtimeReachable={canUseRuntime} onDeleted={() => setSelected(undefined)} />}
{editingResource && schema.updateFields && ( {editingResource && schema.updateFields && (
<div <div
className="modal-overlay" className="modal-overlay"
@@ -0,0 +1,107 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {LightsailInstancePanel} from './LightsailInstancePanel'
import type {CloudResource} from '@/types/resource'
const apiMocks = vi.hoisted(() => ({
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(<QueryClientProvider client={queryClient}><LightsailInstancePanel resource={instance} runtimeReachable onDeleted={onDeleted}/></QueryClientProvider>)
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(<QueryClientProvider client={queryClient}><LightsailInstancePanel resource={wordpress} runtimeReachable onDeleted={vi.fn()}/></QueryClientProvider>)
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(<QueryClientProvider client={queryClient}><LightsailInstancePanel resource={nginx} runtimeReachable onDeleted={vi.fn()}/></QueryClientProvider>)
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(<QueryClientProvider client={queryClient}><LightsailInstancePanel resource={node} runtimeReachable onDeleted={vi.fn()}/></QueryClientProvider>)
expect(screen.getByRole('tab', {name: 'Deploy'})).toBeVisible()
})
@@ -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<Tab>('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<HTMLInputElement>(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 <section className="table-panel" style={{marginTop: 16}}><div className="empty compact"><h3>Select a Lightsail instance</h3><p>Manage its lifecycle, networking, storage metadata, and connection details.</p></div></section>
}
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 <section className="table-panel lightsail-instance-panel" style={{marginTop: 16}}>
<header className="lightsail-instance-header">
<div><p className="eyebrow">Amazon Lightsail instance</p><h2>{resource.name}</h2><p className="muted"><code>{resource.id}</code>{resource.region && <> · {resource.region}</>}</p></div>
<div className="lightsail-instance-controls"><span className={`runtime-state ${status === 'running' ? 'ready' : 'pending'}`}>{stateLabel}</span><details className="dynamodb-actions"><summary>Actions <ChevronDown size={14}/></summary><div className="dynamodb-actions-menu lightsail-actions-menu"><button type="button" onClick={() => setTab('overview')}><Settings size={14}/>Manage</button><button type="button" disabled={!runtimeReachable || busy || status !== 'stopped'} onClick={() => lifecycle.mutate('start')}><Play size={14}/>Start</button><button type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => lifecycle.mutate('stop')}><Square size={14}/>Stop</button><button type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => lifecycle.mutate('reboot')}><RotateCw size={14}/>Reboot</button><button className="dynamodb-action-delete" type="button" disabled={!runtimeReachable || busy} onClick={() => setConfirmDelete(true)}><Trash2 size={14}/>Delete</button></div></details></div>
</header>
<div className="drawer-tabs lightsail-instance-tabs" role="tablist" aria-label="Instance details">
<button className={`drawer-tab ${tab === 'overview' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'overview'} onClick={() => setTab('overview')}>Overview</button>
{(nginx || nodejs) && <button className={`drawer-tab ${tab === 'deploy' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'deploy'} onClick={() => setTab('deploy')}>Deploy</button>}
<button className={`drawer-tab ${tab === 'networking' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'networking'} onClick={() => setTab('networking')}>Networking</button>
<button className={`drawer-tab ${tab === 'storage' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'storage'} onClick={() => setTab('storage')}>Storage</button>
<button className={`drawer-tab ${tab === 'connect' ? 'active' : ''}`} type="button" role="tab" aria-selected={tab === 'connect'} onClick={() => setTab('connect')}>Connect</button>
</div>
{tab === 'overview' && <div className="lightsail-overview"><div className="lightsail-detail-grid"><Detail label="Blueprint" value={stringMetadata(resource.metadata.blueprintName) ?? stringMetadata(resource.metadata.blueprintId)}/><Detail label="Plan" value={stringMetadata(resource.metadata.bundleId)}/><Detail label="Public IP" value={publicIp}/><Detail label="Private IP" value={privateIp}/></div>{wordpress && wordpressPort && <p className="compact-text"><a className="button compact" href={browserUrl(wordpressPort)} target="_blank" rel="noreferrer">Open site <ExternalLink size={13}/></a> <a className="button compact" href={`${browserUrl(wordpressPort)}/wp-admin`} target="_blank" rel="noreferrer">Open WordPress admin <ExternalLink size={13}/></a></p>}<p className="muted compact-text">Use the Networking tab to manage public firewall rules and open mapped browser endpoints.</p></div>}
{tab === 'deploy' && nginx && <div className="lightsail-tab-content"><h3>Deploy static files</h3><p className="muted compact-text">Selected files are copied to <code>/usr/share/nginx/html</code>. Folder paths are preserved when your browser provides them.</p><input ref={fileInput} type="file" multiple hidden onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length) deployment.mutate(files); event.currentTarget.value = '' }}/><button className="button primary" type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => fileInput.current?.click()}>{deployment.isPending ? 'Deploying' : 'Choose files and deploy'}</button>{deployment.data && <p className="muted compact-text">Deployment complete: {deployment.data.files} {deployment.data.files === 1 ? 'file' : 'files'} copied.</p>}</div>}
{tab === 'deploy' && nodejs && <div className="lightsail-tab-content"><h3>Deploy Node.js application</h3><p className="muted compact-text">Upload a zip archive or source files. <code>package.json</code> is required; <code>package-lock.json</code> uses <code>npm ci</code>, otherwise <code>npm install</code>. Existing releases remain active if deployment fails.</p><div className="dynamic-form lightsail-port-form"><label className="dynamic-field"><span>Start command (optional)</span><input className="input" value={startCommand} placeholder="Uses scripts.start" onChange={(event) => setStartCommand(event.target.value)} disabled={busy}/></label><label className="dynamic-field"><span>PORT</span><input className="input" type="number" min="1" max="65535" value={nodePort} onChange={(event) => setNodePort(event.target.value)} disabled={busy}/></label></div><input ref={fileInput} type="file" multiple hidden onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length) nodeDeployment.mutate(files); event.currentTarget.value = '' }}/><button className="button primary" type="button" disabled={!runtimeReachable || busy || status !== 'running'} onClick={() => fileInput.current?.click()}>{nodeDeployment.isPending ? 'Installing and checking health' : 'Choose zip or files and deploy'}</button>{nodeDeployment.data && <p className="muted compact-text">Release <code>{nodeDeployment.data.releaseId}</code> is healthy on port {nodeDeployment.data.port}. The browser port was opened automatically.</p>}</div>}
{tab === 'networking' && <div className="lightsail-tab-content"><div className="lightsail-detail-grid"><Detail label="Public IPv4" value={publicIp}/><Detail label="Private IPv4" value={privateIp}/><Detail label="Static IP" value={staticIp}/></div><p className="muted compact-text">Static IP is metadata-only. Attach or detach operations are not available from this console.</p><h3>Firewall rules</h3><p className="muted compact-text">Public ports returned by the Lightsail instance networking API.</p><form className="dynamic-form lightsail-port-form" onSubmit={(event) => { event.preventDefault(); if (validPortRange) portMutation.mutate({action: 'openPort', fromPort: Number(fromPort), toPort: Number(toPort), protocol}) }}><label className="dynamic-field"><span>From port</span><input className="input" type="number" min="0" max="65535" value={fromPort} onChange={(event) => setFromPort(event.target.value)} disabled={busy}/></label><label className="dynamic-field"><span>To port</span><input className="input" type="number" min="0" max="65535" value={toPort} onChange={(event) => setToPort(event.target.value)} disabled={busy}/></label><label className="dynamic-field"><span>Protocol</span><select className="input" value={protocol} onChange={(event) => setProtocol(event.target.value)} disabled={busy}><option value="tcp">TCP</option><option value="udp">UDP</option><option value="all">All</option></select></label><button className="button primary" type="submit" disabled={!runtimeReachable || busy || !validPortRange}>{portMutation.isPending ? 'Adding' : 'Add rule'}</button></form>{ports.length > 0 ? <div className="cosmos-items-table"><table><thead><tr><th>Port range</th><th>Protocol</th><th/></tr></thead><tbody>{ports.map((port) => <tr key={`${port.protocol}-${port.fromPort}-${port.toPort}`}><td>{formatPortRange(port)}</td><td>{port.protocol.toUpperCase()}</td><td><button className="button compact danger" type="button" disabled={!runtimeReachable || busy} onClick={() => portMutation.mutate({action: 'closePort', ...port})}>Remove</button></td></tr>)}</tbody></table></div> : <p className="muted compact-text">No public firewall rules are configured.</p>}<h3>Browser links</h3>{hostMappings.length > 0 ? <div className="cosmos-items-table"><table><thead><tr><th>Instance port</th><th>Host mapping</th><th/></tr></thead><tbody>{hostMappings.map((mapping) => <tr key={`${mapping.protocol ?? 'tcp'}-${mapping.instancePort}`}><td>{mapping.instancePort}</td><td><code>{mapping.host}:{mapping.hostPort}</code></td><td><a className="button compact" href={`http://${mapping.host}:${mapping.hostPort}`} target="_blank" rel="noreferrer"><ExternalLink size={13}/>Open</a></td></tr>)}</tbody></table></div> : publicIp ? <a className="button compact" href={`http://${publicIp}`} target="_blank" rel="noreferrer"><ExternalLink size={13}/>Open HTTP endpoint</a> : <p className="muted compact-text">No browser endpoint was returned for this instance.</p>}</div>}
{tab === 'storage' && <div className="lightsail-tab-content"><h3><HardDrive size={17}/> Disks</h3><p className="muted compact-text">Disk details are metadata-only. Creating, attaching, detaching, or resizing disks is not available from this console.</p>{disks.length > 0 ? <div className="cosmos-items-table"><table><thead><tr><th>Name</th><th>Size</th><th>Type</th><th>State</th></tr></thead><tbody>{disks.map((disk) => <tr key={disk.name}><td>{disk.name}</td><td>{disk.sizeInGb} GB</td><td>{disk.isSystemDisk ? 'System disk' : 'Attached disk'}</td><td>{disk.state ?? 'Unknown'}</td></tr>)}</tbody></table></div> : <p className="muted compact-text">No disk metadata was returned by the runtime.</p>}</div>}
{tab === 'connect' && <div className="lightsail-tab-content"><h3><Terminal size={17}/> Connect to instance</h3><p className="muted compact-text">Browser SSH and key injection are unavailable in the local Lightsail runtime. This console does not generate credentials or claim a connection is available.</p><div className="lightsail-detail-grid"><Detail label="Default username" value={stringMetadata(resource.metadata.username)}/><Detail label="Public IPv4" value={publicIp}/></div></div>}
{confirmDelete && <div className="lightsail-delete-confirm"><span>Delete <strong>{resource.name}</strong>? This cannot be undone.</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate()}>{deleteMutation.isPending ? 'Deleting' : 'Delete instance'}</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDelete(false)}>Cancel</button></div>}
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
</section>
}
function Detail({label, value}: {label: string; value?: string}) { return <div className="lightsail-detail"><span>{label}</span><code>{value ?? 'Not assigned'}</code></div> }
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)}] : [] }) }
@@ -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<string, unknown>) => 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 <section className="lightsail-launch">
<div className="lightsail-launch-header">
<div>
<p className="eyebrow">Amazon Lightsail</p>
<h2>Create an instance</h2>
<p className="muted">Choose a blueprint, a plan, and a name. The instance launches locally through Floci.</p>
</div>
<button className="icon-btn" type="button" onClick={onCancel} aria-label="Close create instance">×</button>
</div>
<div className="lightsail-section">
<h3>Select a platform</h3>
<div className="lightsail-platforms">
<button className="lightsail-platform selected" type="button"><Terminal size={26}/><span><strong>Linux/Unix</strong><small>Supported blueprints</small></span><Check size={18}/></button>
<button className="lightsail-platform" type="button" disabled><Box size={26}/><span><strong>Microsoft Windows</strong><small>Not supported locally</small></span></button>
</div>
</div>
<div className="lightsail-section">
<h3>Select a blueprint</h3>
<div className="lightsail-tabs">
<button className={category === 'app' ? 'active' : ''} type="button" onClick={() => selectCategory('app')}>Apps + OS</button>
<button className={category === 'os' ? 'active' : ''} type="button" onClick={() => selectCategory('os')}>OS Only</button>
</div>
<div className="lightsail-blueprints">
{visibleBlueprints.map((blueprint) => <button key={blueprint.id} type="button" disabled={!blueprint.available} title={blueprint.unavailableReason} className={`lightsail-blueprint ${blueprintId === blueprint.id ? 'selected' : ''}`} onClick={() => setBlueprintId(blueprint.id)}>
{blueprint.icon ? <img src={blueprint.icon} alt=""/> : <span className="lightsail-wordpress">W</span>}
<span><strong>{blueprint.name}</strong><small>{blueprint.available ? blueprint.description : blueprint.unavailableReason}</small></span>
</button>)}
</div>
</div>
<div className="lightsail-section">
<h3>Choose your instance plan</h3>
<div className="lightsail-plans">{plans.map((plan) => <button key={plan.id} type="button" className={bundleId === plan.id ? 'selected' : ''} onClick={() => setBundleId(plan.id)}><strong>{plan.name}</strong><small>{plan.detail}</small></button>)}</div>
</div>
<div className="lightsail-config">
<label>Instance name<input className="input" value={name} onChange={(event) => setName(event.target.value)} placeholder="e.g. web-server" autoFocus/></label>
<label>Availability Zone<input className="input" value={availabilityZone} onChange={(event) => setAvailabilityZone(event.target.value)} /></label>
</div>
{isSubmitting && <ServerLaunchProgress name={name} description="Pulling the server image and preparing the local runtime."/>}
{submitError && <p className="error-text">{submitError}</p>}
<div className="form-actions"><button className="button" type="button" onClick={onCancel}>Cancel</button><button className="button primary" type="button" disabled={isSubmitting || !name.trim()} onClick={() => onSubmit({name, blueprintId, bundleId, availabilityZone})}>{isSubmitting ? 'Creating...' : 'Create instance'}</button></div>
</section>
}
@@ -3,6 +3,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient' import {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton' import {CopyValueButton} from '@/components/CopyValueButton'
import {LoadBalancingProgress} from '@/components/LoadBalancingProgress'
import {useAccountId} from '@/lib/accountStore' import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource' import type {CloudResource} from '@/types/resource'
@@ -33,7 +34,13 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
}, [resource?.id]) }, [resource?.id])
const createMutation = useMutation({ 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<void>((resolve) => setTimeout(resolve, remaining))
return created
},
onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}), onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}),
}) })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -62,6 +69,7 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
<label className="dynamic-field dynamic-field--span"><span>Default target group</span><select className="input" value={targetGroupArn} onChange={(event) => setTargetGroupArn(event.target.value)} disabled={targetGroupsQuery.isLoading || pending}><option value="">Select a target group</option>{targetGroups.map((group) => <option key={group.id} value={group.id}>{group.name} ({group.id})</option>)}</select></label> <label className="dynamic-field dynamic-field--span"><span>Default target group</span><select className="input" value={targetGroupArn} onChange={(event) => setTargetGroupArn(event.target.value)} disabled={targetGroupsQuery.isLoading || pending}><option value="">Select a target group</option>{targetGroups.map((group) => <option key={group.id} value={group.id}>{group.name} ({group.id})</option>)}</select></label>
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !targetGroupArn || !validPort || pending}>{createMutation.isPending ? 'Creating' : 'Create listener'}</button></div> <div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !targetGroupArn || !validPort || pending}>{createMutation.isPending ? 'Creating' : 'Create listener'}</button></div>
{targetGroups.length === 0 && targetGroupsQuery.isSuccess && <p className="muted">No target groups are available in this account.</p>} {targetGroups.length === 0 && targetGroupsQuery.isSuccess && <p className="muted">No target groups are available in this account.</p>}
{createMutation.isPending && <div className="dynamic-field--span"><LoadBalancingProgress kind="listener" description="Configuring the listener and its target-group route."/></div>}
</form> </form>
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>} {error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{listenersQuery.isLoading && <div className="muted padded">Loading listeners</div>} {listenersQuery.isLoading && <div className="muted padded">Loading listeners</div>}
@@ -0,0 +1,19 @@
interface LoadBalancingProgressProps {
kind: 'listener' | 'target'
description: string
}
export function LoadBalancingProgress({kind, description}: LoadBalancingProgressProps) {
return <div className="load-balancing-progress" role="status" aria-live="polite">
{kind === 'listener' ? <ListenerIcon/> : <TargetIcon/>}
<div><strong>{kind === 'listener' ? 'Creating listener' : 'Registering target'}</strong><small>{description}</small></div>
</div>
}
function ListenerIcon() {
return <svg viewBox="0 0 140 140" aria-hidden="true"><rect className="lb-device" x="20" y="28" width="96" height="29" rx="4"/><circle className="lb-yellow lb-blink-1" cx="35" cy="42.5" r="5.3"/><g className="lb-yellow"><circle className="lb-blink-1" cx="72" cy="37" r="2"/><circle className="lb-blink-2" cx="81" cy="37" r="2"/><circle className="lb-blink-3" cx="97" cy="37" r="2"/><circle className="lb-blink-2" cx="106" cy="37" r="2"/><circle className="lb-blink-3" cx="64" cy="47" r="2"/><circle className="lb-blink-1" cx="75" cy="47" r="2"/><circle className="lb-blink-2" cx="85" cy="47" r="2"/><circle className="lb-blink-3" cx="97" cy="47" r="2"/><circle className="lb-blink-1" cx="106" cy="47" r="2"/></g><path className="lb-wire" d="M58 64 L49 83 L42 97M79 64 L88 83 L94 97"/><circle className="lb-yellow" r="3.2"><animateMotion dur="1.55s" repeatCount="indefinite" path="M58 64 L49 83 L42 97"/></circle><circle className="lb-yellow" r="3.2"><animateMotion begin=".72s" dur="1.55s" repeatCount="indefinite" path="M79 64 L88 83 L94 97"/></circle><rect className="lb-device" x="24" y="88" width="36" height="19" rx="5"/><rect className="lb-device" x="77" y="88" width="36" height="19" rx="5"/></svg>
}
function TargetIcon() {
return <svg viewBox="0 0 362 356" aria-hidden="true"><rect className="lb-panel" x="49" y="68" width="270" height="68" rx="15"/><rect className="lb-panel" x="49" y="135" width="270" height="68" rx="15"/><g className="lb-ports"><rect className="lb-port" x="81" y="92" width="47" height="25" rx="13"/><rect className="lb-port" x="81" y="158" width="47" height="25" rx="13"/></g><g className="lb-green">{[178, 197, 216, 236, 256, 276, 295].map((cx, index) => <circle key={`top-${cx}`} className={`lb-blink-${index % 3 + 1}`} cx={cx} cy="95" r="6.5"/>)}{[178, 197, 216, 236, 256, 276].map((cx, index) => <circle key={`bottom-${cx}`} className={`lb-blink-${(index + 1) % 3 + 1}`} cx={cx} cy="162" r="6.5"/>)}</g><path className="lb-wire" d="M184 202v51M55 267H307"/><circle className="lb-green" r="7"><animateMotion dur="3.8s" repeatCount="indefinite" path="M66 267 H184 V181"/></circle><circle className="lb-green" r="7"><animateMotion begin="1.25s" dur="3.8s" repeatCount="indefinite" path="M302 267 H184 V162"/></circle><circle className="lb-green lb-hub" cx="184" cy="267" r="8"/></svg>
}
@@ -55,6 +55,7 @@ import {deleteFlatItem, listFlatItems, putFlatItem} from '@/api/cloudProxyClient
import type {CloudProvider} from '@/types/cloud' import type {CloudProvider} from '@/types/cloud'
import type {ChildItem, CloudResource} from '@/types/resource' import type {ChildItem, CloudResource} from '@/types/resource'
import {calculateSubnetCidrs, isValidCidr, isValidPort} from '@/lib/network' import {calculateSubnetCidrs, isValidCidr, isValidPort} from '@/lib/network'
import {clientId} from '@/lib/clientId'
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
@@ -193,7 +194,7 @@ function ConfirmDeleteModal({title, detail, onConfirm, onClose, isPending}: {
function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules: EditRule[]) => void}) { function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules: EditRule[]) => void}) {
function addRule() { 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) { function removeRule(rid: string) {
onChange(rules.filter((r) => r.rid !== rid)) onChange(rules.filter((r) => r.rid !== rid))
@@ -0,0 +1,19 @@
interface ServerLaunchProgressProps {
name: string
description: string
}
export function ServerLaunchProgress({name, description}: ServerLaunchProgressProps) {
return <div className="server-launch-progress" role="status" aria-live="polite">
<svg viewBox="0 0 256 256" aria-hidden="true">
<path fill="#45515D" d="M18 145 48 65c1.4-3.7 5-6 9-6h143c4 0 7.6 2.4 9 6l29 80H18Z"/>
<path fill="#68737F" d="M40 145 69 65c1.4-3.7 5-6 9-6h122c4 0 7.6 2.4 9 6l29 80H40Z"/>
<rect x="18" y="143" width="220" height="54" rx="13" fill="#3B334A"/>
<path fill="#2C2638" d="M18 156c0-7.2 5.8-13 13-13h20c-7.2 0-13 5.8-13 13v28c0 7.2 5.8 13 13 13H31c-7.2 0-13-5.8-13-13v-28Z"/>
<rect x="39.5" y="159.5" width="21" height="21" rx="10.5" fill="none" stroke="#FFC735" strokeWidth="4.5"><animate attributeName="width" values="21;35;57;35;21;35;57;35;21" dur="1.64s" repeatCount="indefinite"/></rect>
<g fill="#2FB6A2"><circle cx="112" cy="161" r="4"/><circle cx="130" cy="161" r="4"/><circle cx="148" cy="161" r="4"/><circle cx="166" cy="161" r="4"/><circle cx="184" cy="161" r="4"/><circle cx="202" cy="161" r="4"/><circle cx="220" cy="161" r="4"/><circle cx="112" cy="179" r="4"/><circle cx="130" cy="179" r="4"/><circle cx="148" cy="179" r="4"/><circle cx="166" cy="179" r="4"/><circle cx="184" cy="179" r="4"/><circle cx="202" cy="179" r="4"/><circle cx="220" cy="179" r="4"/></g>
<rect x="108" y="156" width="116" height="28" fill="#3B334A" opacity="0"><animate attributeName="width" values="0;72;92;72;0;72;92;72;0" dur="1.64s" repeatCount="indefinite"/><animate attributeName="x" values="224;152;132;152;224;152;132;152;224" dur="1.64s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;1;1;1;0;1;1;1;0" dur="1.64s" repeatCount="indefinite"/></rect>
</svg>
<div><strong>Launching {name || 'instance'}</strong><small>{description}</small></div>
</div>
}
@@ -3,6 +3,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {getLoadBalancingDependencies, listFlatItems, putFlatItem, deleteFlatItem} from '@/api/cloudProxyClient' import {getLoadBalancingDependencies, listFlatItems, putFlatItem, deleteFlatItem} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore' import {useAccountId} from '@/lib/accountStore'
import {LoadBalancingProgress} from '@/components/LoadBalancingProgress'
import type {CloudResource} from '@/types/resource' import type {CloudResource} from '@/types/resource'
type Props = { type Props = {
@@ -37,7 +38,21 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()]) void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
} }
const registerMutation = useMutation({ 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<void>((resolve) => setTimeout(resolve, remaining))
return
}
await new Promise<void>((resolve) => setTimeout(resolve, 500))
}
},
onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}), onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}),
}) })
const deregisterMutation = useMutation({ const deregisterMutation = useMutation({
@@ -63,6 +78,7 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
<label className="dynamic-field"><span>Target port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)} disabled={registerMutation.isPending}/></label> <label className="dynamic-field"><span>Target port</span><input className="input" type="number" min="1" max="65535" value={port} onChange={(event) => setPort(event.target.value)} disabled={registerMutation.isPending}/></label>
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !instanceId || !validPort || registerMutation.isPending}>{registerMutation.isPending ? 'Registering' : 'Register target'}</button></div> <div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !instanceId || !validPort || registerMutation.isPending}>{registerMutation.isPending ? 'Registering' : 'Register target'}</button></div>
{instances.length === 0 && dependenciesQuery.isSuccess && <p className="muted">No running instances are available in this account.</p>} {instances.length === 0 && dependenciesQuery.isSuccess && <p className="muted">No running instances are available in this account.</p>}
{registerMutation.isPending && <div className="dynamic-field--span"><LoadBalancingProgress kind="target" description="Waiting for the target health check to become healthy or unhealthy."/></div>}
</form> </form>
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>} {error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{healthQuery.isLoading && <div className="muted padded">Loading target health</div>} {healthQuery.isLoading && <div className="muted padded">Loading target health</div>}
@@ -41,6 +41,7 @@ import eventBridgeIcon from '@/assets/aws-services/eventbridge.svg'
import iamIcon from '@/assets/aws-services/iam.svg' import iamIcon from '@/assets/aws-services/iam.svg'
import kmsIcon from '@/assets/aws-services/kms.svg' import kmsIcon from '@/assets/aws-services/kms.svg'
import lambdaIcon from '@/assets/aws-services/lambda.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 parameterStoreIcon from '@/assets/aws-services/parameter-store.svg'
import rdsIcon from '@/assets/aws-services/rds.svg' import rdsIcon from '@/assets/aws-services/rds.svg'
import s3Icon from '@/assets/aws-services/s3.svg' import s3Icon from '@/assets/aws-services/s3.svg'
@@ -68,6 +69,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
database: Table2, database: Table2,
nosql: Database, nosql: Database,
k8s: Boxes, k8s: Boxes,
lightsail: Server,
compute: Server, compute: Server,
containers: Container, containers: Container,
networking: Network, networking: Network,
@@ -112,6 +114,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
kms: kmsIcon, kms: kmsIcon,
k8s: eksIcon, k8s: eksIcon,
loadbalancing: elasticLoadBalancingIcon, loadbalancing: elasticLoadBalancingIcon,
lightsail: lightsailIcon,
logs: cloudWatchLogsIcon, logs: cloudWatchLogsIcon,
messaging: sqsIcon, messaging: sqsIcon,
kinesis: kinesisIcon, kinesis: kinesisIcon,
@@ -1,9 +1,10 @@
import { isValidCidr, isValidPort } from "@/lib/network"; import { isValidCidr, isValidPort } from "@/lib/network";
import { clientId } from "@/lib/clientId";
import type { SgRule } from "./SgRuleTable"; import type { SgRule } from "./SgRuleTable";
export function newRule(): SgRule { export function newRule(): SgRule {
return { return {
id: crypto.randomUUID(), id: clientId(),
type: "custom-tcp", type: "custom-tcp",
protocol: "tcp", protocol: "tcp",
fromPort: "", fromPort: "",
@@ -30,6 +30,7 @@ import {
usePutSecretValueMutation, usePutSecretValueMutation,
} from '@/api/aws/secretsmanager.mutations' } from '@/api/aws/secretsmanager.mutations'
import { timeAgo } from '@/lib/utils' import { timeAgo } from '@/lib/utils'
import { clientId } from '@/lib/clientId'
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
type JsonValueKind = 'string' | 'number' | 'boolean' | 'null' | 'json' type JsonValueKind = 'string' | 'number' | 'boolean' | 'null' | 'json'
@@ -338,7 +339,7 @@ function SecretDrawer({
function addKeyValueEntry() { function addKeyValueEntry() {
setKeyValueEntries((entries) => [ setKeyValueEntries((entries) => [
...entries, ...entries,
{ id: crypto.randomUUID(), key: '', value: '', kind: 'string' }, { id: clientId(), key: '', value: '', kind: 'string' },
]) ])
setEditorError('') setEditorError('')
} }
+236
View File
@@ -4875,6 +4875,181 @@ button.console-service-card {
word-break: break-all; 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 object modal ──────────────────────────────────────────────────── */
.copy-modal-overlay { .copy-modal-overlay {
position: fixed; 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-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 { 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; } .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; } }
+7
View File
@@ -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)}`
}
+18
View File
@@ -76,6 +76,9 @@ importers:
'@aws-sdk/client-lambda': '@aws-sdk/client-lambda':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
'@aws-sdk/client-lightsail':
specifier: ^3.1076.0
version: 3.1148.0
'@aws-sdk/client-rds': '@aws-sdk/client-rds':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
@@ -368,6 +371,10 @@ packages:
resolution: {integrity: sha512-uueu0Do1dy3cm42AdjL3zivHoc5b55vOL3g6BjxUv2xMifSLKZi5Ilh5FhXVDdS/Uwxak/JH+vVQDDnEuQ52gA==} resolution: {integrity: sha512-uueu0Do1dy3cm42AdjL3zivHoc5b55vOL3g6BjxUv2xMifSLKZi5Ilh5FhXVDdS/Uwxak/JH+vVQDDnEuQ52gA==}
engines: {node: '>=20.0.0'} 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': '@aws-sdk/client-rds-data@3.1144.0':
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==} resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -3047,6 +3054,17 @@ snapshots:
'@smithy/types': 4.15.1 '@smithy/types': 4.15.1
tslib: 2.8.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': '@aws-sdk/client-rds-data@3.1144.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.1