feat: Lightsail + svg animation
This commit is contained in:
@@ -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 |
@@ -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 |
@@ -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 |
@@ -29,6 +29,7 @@ services:
|
||||
- "4501:4501"
|
||||
environment:
|
||||
FLOCI_ENDPOINT: http://floci:4566
|
||||
FLOCI_EC2_SSH_HOST: host.docker.internal
|
||||
FLOCI_AZURE_ENDPOINT: http://floci-az:4577
|
||||
AWS_REGION: us-east-1
|
||||
AWS_ACCESS_KEY_ID: test
|
||||
@@ -36,6 +37,8 @@ services:
|
||||
FLOCI_GCP_ENDPOINT: http://floci-gcp:4588
|
||||
FLOCI_GCP_PROJECT: floci-local
|
||||
PORT: "4501"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
- floci
|
||||
networks:
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@aws-sdk/client-kinesis": "^3.1076.0",
|
||||
"@aws-sdk/client-kms": "^3.1096.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-data": "^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'
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { KinesisClient } from "@aws-sdk/client-kinesis";
|
||||
import { SESv2Client } from "@aws-sdk/client-sesv2";
|
||||
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
|
||||
import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
|
||||
import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -89,6 +90,7 @@ export type AwsClients = {
|
||||
sesv2: SESv2Client;
|
||||
cloudwatch: CloudWatchClient;
|
||||
elasticache: ElastiCacheClient;
|
||||
lightsail: LightsailClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -133,6 +135,7 @@ function buildClients(accountId: string): AwsClients {
|
||||
sesv2: new SESv2Client(base),
|
||||
cloudwatch: new CloudWatchClient(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',
|
||||
order: 51,
|
||||
},
|
||||
lightsail: {
|
||||
displayName: 'Lightsail',
|
||||
iconKey: 'lightsail',
|
||||
group: 'Compute',
|
||||
order: 60,
|
||||
},
|
||||
codebuild: {
|
||||
displayName: 'CodeBuild',
|
||||
iconKey: 'codebuild',
|
||||
|
||||
@@ -46,6 +46,7 @@ import {AwsEcsAdapter} from './adapter-aws/AwsEcsAdapter'
|
||||
import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
|
||||
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
|
||||
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
|
||||
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
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 AwsKinesisAdapter(clients.kinesis),
|
||||
new AwsElastiCacheAdapter(clients.elasticache),
|
||||
new AwsLightsailAdapter(clients.lightsail),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
|
||||
import {mapAwsSdkError} from '../adapter-aws/awsErrors'
|
||||
import {serviceForAccount} from '../cloudProxy'
|
||||
import {CloudProxyService} from '../service/CloudProxyService'
|
||||
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
|
||||
|
||||
// Header (and query-param fallback for direct links such as object downloads)
|
||||
// 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) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {createEc2Router} from './ec2'
|
||||
import {SshCheckError, type SshCheckResult} from '../services/sshCheck'
|
||||
import type {
|
||||
CreateAmiInput,
|
||||
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', () => {
|
||||
test('replaces security groups', async () => {
|
||||
let receivedGroupIds: string[] | undefined
|
||||
|
||||
@@ -11,8 +11,10 @@ import type {
|
||||
VpcWizardInput,
|
||||
} from '../services/ec2'
|
||||
import {ec2Service} from '../services/ec2'
|
||||
import {SshCheckError, sshCheckService} from '../services/sshCheck'
|
||||
|
||||
type Ec2Service = typeof ec2Service
|
||||
type SshCheckService = typeof sshCheckService
|
||||
type CreateKeyPairInput = {name: string; keyType?: Ec2KeyPairType}
|
||||
|
||||
type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
|
||||
@@ -21,6 +23,21 @@ type Ec2DiagnosticResponse = {ec2?: {instances?: Array<{
|
||||
sshPort?: number
|
||||
containerId?: string
|
||||
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) {
|
||||
@@ -49,7 +66,7 @@ function handleError(c: Context, error: unknown) {
|
||||
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()
|
||||
|
||||
// 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) => {
|
||||
try {
|
||||
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)
|
||||
return c.json({ok: true})
|
||||
} 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}
|
||||
}
|
||||
@@ -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'})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -26,6 +26,8 @@ export const apiEndpointKeys = {
|
||||
start: "clouds.services.resources.start",
|
||||
stop: "clouds.services.resources.stop",
|
||||
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",
|
||||
createSnsSubscription: "clouds.services.resources.notifications.subscription.create",
|
||||
listSnsSubscriptions: "clouds.services.resources.notifications.subscriptions.list",
|
||||
@@ -228,6 +230,7 @@ export const apiEndpointKeys = {
|
||||
securityGroups: "aws.ec2.instances.securityGroups",
|
||||
createImage: "aws.ec2.instances.create-image",
|
||||
console: "aws.ec2.instances.console",
|
||||
checkSsh: "aws.ec2.instances.ssh.check",
|
||||
},
|
||||
amis: {
|
||||
list: "aws.ec2.amis.list",
|
||||
@@ -643,6 +646,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
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,
|
||||
{
|
||||
@@ -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.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST" }],
|
||||
[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.deregister, { path: "/ec2/amis/:imageId/deregister", method: "POST" }],
|
||||
[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 {
|
||||
authorizeEc2SecurityGroupEgress,
|
||||
authorizeEc2SecurityGroupIngress,
|
||||
checkEc2InstanceSsh,
|
||||
createEc2SecurityGroup,
|
||||
revokeEc2SecurityGroupEgress,
|
||||
revokeEc2SecurityGroupIngress,
|
||||
updateEc2InstanceTags,
|
||||
updateEc2InstanceSecurityGroups,
|
||||
} 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", () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
@@ -111,3 +144,32 @@ describe("updateEc2InstanceSecurityGroups", () => {
|
||||
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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +83,35 @@ export type Ec2InstanceConnection = {
|
||||
sshPort?: number;
|
||||
containerId?: string;
|
||||
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 = {
|
||||
@@ -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(
|
||||
instanceId: string,
|
||||
toAdd: Ec2Tag[],
|
||||
@@ -330,7 +368,7 @@ export async function updateEc2InstanceTags(
|
||||
): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.instances.tags,
|
||||
{ body: JSON.stringify({ toAdd, toRemove }), headers: { "content-type": "application/json" } },
|
||||
{ body: { toAdd, toRemove } },
|
||||
{ instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import { getAccountId } from "@/lib/accountStore";
|
||||
type CloudPathParams = Record<string, string>;
|
||||
|
||||
const DATABASE_MUTATION_TIMEOUT_MS = 5 * 60_000;
|
||||
const LIGHTSAIL_CREATION_TIMEOUT_MS = 3 * 60_000;
|
||||
const SQL_DATA_TIMEOUT_MS = 45_000;
|
||||
/** Floci completes Insights queries instantly by default, but bounded server-side polling can take longer. */
|
||||
const LOGS_QUERY_TIMEOUT_MS = 20_000;
|
||||
@@ -167,6 +168,8 @@ export async function createCloudResource(
|
||||
const timeout =
|
||||
(cloud === "azure" || cloud === "aws") && service === "database"
|
||||
? DATABASE_MUTATION_TIMEOUT_MS
|
||||
: cloud === "aws" && service === "lightsail"
|
||||
? LIGHTSAIL_CREATION_TIMEOUT_MS
|
||||
: undefined;
|
||||
const res = await apiClient.call<CloudResource, Record<string, unknown>>(
|
||||
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(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { CopyValueButton } from './CopyValueButton'
|
||||
import { ServerLaunchProgress } from './ServerLaunchProgress'
|
||||
import {
|
||||
AlertTriangle,
|
||||
HardDrive,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
authorizeEc2SecurityGroupIngress,
|
||||
checkEc2InstanceSsh,
|
||||
createEc2Ami,
|
||||
createEc2KeyPair,
|
||||
createEc2SecurityGroup,
|
||||
@@ -53,6 +55,8 @@ import debianImage from '../../../../OS_img/debian.jpg'
|
||||
import redHatImage from '../../../../OS_img/RedHat.png'
|
||||
import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png'
|
||||
|
||||
const EC2_LAUNCH_MINIMUM_MS = 5_000
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ComputePanelProps {
|
||||
@@ -420,6 +424,9 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
const sshCheck = useMutation({
|
||||
mutationFn: () => checkEc2InstanceSsh(instanceId),
|
||||
})
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
@@ -656,25 +663,82 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
|
||||
|
||||
{tab === 'connection' && (() => {
|
||||
const connection = inst.connection
|
||||
const sshUser = connection?.sshUser || 'root'
|
||||
const command = connection?.sshHost && connection.sshPort && inst.keyName
|
||||
? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} root@${connection.sshHost}`
|
||||
? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} ${sshUser}@${connection.sshHost}`
|
||||
: undefined
|
||||
if (state === 'stopped') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Start this instance before connecting over SSH.</p>
|
||||
if (!connection) {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Connection diagnostics are unavailable.</p>
|
||||
}
|
||||
if (state === 'terminated') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Terminated instances cannot accept connections.</p>
|
||||
}
|
||||
if (state !== 'running') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable while this instance is {state}.</p>
|
||||
}
|
||||
if (!command || connection?.containerStatus !== 'running') {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable. Backing container status: {connection?.containerStatus ?? 'unavailable'}.</p>
|
||||
}
|
||||
return <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className="meta-row"><span className="meta-label">SSH port</span><span className="meta-value">{connection.sshPort}</span></div>
|
||||
const canCheckSsh = state === 'running' && connection.containerStatus === 'running'
|
||||
&& Boolean(connection.sshPort)
|
||||
const failure = typeof connection.failure === 'string'
|
||||
? connection.failure
|
||||
: connection.failure?.message
|
||||
return <div className="ec2-connect-panel">
|
||||
{!canCheckSsh && <div className="ec2-connection-notice">
|
||||
{state === 'terminated'
|
||||
? 'This instance is terminated. Launch diagnostics remain available below.'
|
||||
: state === 'stopped'
|
||||
? 'Start this instance before connecting over SSH.'
|
||||
: `SSH is unavailable while this instance is ${state}.`}
|
||||
</div>}
|
||||
<div className="ec2-readiness-grid">
|
||||
<div className="ec2-readiness-card">
|
||||
<span>Launch</span>
|
||||
<strong className={connection.launchStatus === 'failed' ? 'error' : ''}>{connection.launchStatus ?? 'unknown'}</strong>
|
||||
<small>{connection.selectedImageReference ?? 'No selected image recorded'}</small>
|
||||
</div>
|
||||
<div className="ec2-readiness-card">
|
||||
<span>SSH readiness</span>
|
||||
<strong className={connection.sshStatus === 'impaired' ? 'error' : ''}>{connection.sshStatus ?? 'unknown'}</strong>
|
||||
<small>{connection.sshStatusMessage ?? 'No readiness result recorded'}</small>
|
||||
</div>
|
||||
<div className="ec2-readiness-card">
|
||||
<span>Key injection</span>
|
||||
<strong>{connection.keyInjectionStatus ?? (inst.keyName ? 'unknown' : 'not requested')}</strong>
|
||||
<small>{inst.keyName ?? 'No key pair selected'}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{failure && <div className="ec2-connect-error"><AlertTriangle size={14}/><span>{failure}</span></div>}
|
||||
|
||||
<div className="meta-grid ec2-connect-details">
|
||||
<div className="meta-row"><span className="meta-label">SSH target</span><span className="meta-value">{connection.sshPort ? `${connection.sshHost}:${connection.sshPort}` : 'Not published'}</span></div>
|
||||
<div className="meta-row"><span className="meta-label">Backing container</span><span className="meta-value">{connection.containerStatus}</span></div>
|
||||
<div className="meta-row" 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>
|
||||
})()}
|
||||
|
||||
@@ -810,7 +874,7 @@ const QUICK_START_AMIS: Array<{ family: Exclude<AmiFamily, 'other'>; label: stri
|
||||
{ family: 'ubuntu', label: 'Ubuntu', image: ubuntuImage },
|
||||
{ family: 'suse', label: 'openSUSE', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2330ba78"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="30" font-weight="bold" fill="white"%3ES%3C/text%3E%3C/svg%3E' },
|
||||
{ family: 'red-hat', label: 'Red Hat', image: redHatImage },
|
||||
{ family: 'debian', label: 'Debian 13', image: debianImage, preferredImageId: 'ami-debian13' },
|
||||
{ family: 'debian', label: 'Debian 12', image: debianImage, preferredImageId: 'ami-debian12' },
|
||||
]
|
||||
|
||||
const SG_PRESETS: Array<{ label: string; protocol: string; portInput: string; cidr: string }> = [
|
||||
@@ -830,12 +894,13 @@ function parseRule(rule: InboundRule): IpPermissionInput {
|
||||
return { protocol: rule.protocol, fromPort, toPort, cidr: rule.cidr }
|
||||
}
|
||||
|
||||
const INSTANCE_TYPES = [
|
||||
const X86_INSTANCE_TYPES = [
|
||||
't2.micro', 't2.small', 't2.medium',
|
||||
't3.micro', 't3.small', 't3.medium', 't3.large', 't3.xlarge',
|
||||
'm5.large', 'm5.xlarge',
|
||||
'c5.large', 'c5.xlarge',
|
||||
]
|
||||
const ARM_INSTANCE_TYPES = ['t4g.micro', 't4g.small', 't4g.medium', 'm6gd.large', 'm6gd.2xlarge', 'm7gd.large', 'm7gd.2xlarge', 'm8gd.medium', 'm8gd.large', 'm8gd.2xlarge']
|
||||
|
||||
export function LaunchInstanceForm({
|
||||
cloud,
|
||||
@@ -906,7 +971,15 @@ export function LaunchInstanceForm({
|
||||
}, [selectedSubnet, sgsQ.data])
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => createCloudResource(cloud, 'compute', values),
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const startedAt = Date.now()
|
||||
const created = await createCloudResource(cloud, 'compute', values)
|
||||
const remaining = EC2_LAUNCH_MINIMUM_MS - (Date.now() - startedAt)
|
||||
if (remaining > 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, remaining))
|
||||
}
|
||||
return created
|
||||
},
|
||||
onSuccess: (created) => {
|
||||
void qc.invalidateQueries({ queryKey: ['cloud-resources', cloud, 'compute'] })
|
||||
onSuccess(created)
|
||||
@@ -1030,8 +1103,15 @@ export function LaunchInstanceForm({
|
||||
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
|
||||
})
|
||||
const selectedAmi = amis.find((ami) => ami.imageId === imageId)
|
||||
const compatibleInstanceTypes = selectedAmi?.architecture === 'arm64' ? ARM_INSTANCE_TYPES : X86_INSTANCE_TYPES
|
||||
const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
|
||||
const availableSecurityGroups = (sgsQ.data ?? []).filter((group) => !selectedSubnet || group.vpcId === selectedSubnet.vpcId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!compatibleInstanceTypes.includes(instanceType)) {
|
||||
setInstanceType(compatibleInstanceTypes[0])
|
||||
}
|
||||
}, [compatibleInstanceTypes, instanceType])
|
||||
const steps: Array<{ number: LaunchStep; label: string }> = [
|
||||
{ number: 1, label: 'Name and AMI' },
|
||||
{ number: 2, label: 'Instance and key pair' },
|
||||
@@ -1115,8 +1195,9 @@ export function LaunchInstanceForm({
|
||||
<label className="launch-field">
|
||||
<span>Instance type <em className="field-required">*</em></span>
|
||||
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}>
|
||||
{INSTANCE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
{compatibleInstanceTypes.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
{selectedAmi?.architecture === 'arm64' && <small>ARM64 AMIs require Graviton instance types.</small>}
|
||||
</label>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span>Key pair</span>
|
||||
@@ -1314,6 +1395,7 @@ export function LaunchInstanceForm({
|
||||
|
||||
{stepError && <div className="form-error launch-field--span">{stepError}</div>}
|
||||
{createMut.error instanceof Error && <div className="form-error launch-field--span">{createMut.error.message}</div>}
|
||||
{createMut.isPending && <div className="launch-field--span"><ServerLaunchProgress name={name} description="Creating the instance and preparing its local runtime."/></div>}
|
||||
<div className="launch-form-actions" style={{ gridColumn: '1 / -1' }}>
|
||||
<button type="button" className="button" onClick={step === 1 ? onCancel : () => { setStep((current) => (current - 1) as LaunchStep); setStepError('') }}>{step === 1 ? 'Cancel' : 'Back'}</button>
|
||||
{step < 4 ? <button type="button" className="button primary" onClick={nextStep}>Next</button> : <button type="button" className="button primary" disabled={createMut.isPending} onClick={launch}><Plus size={14}/>{createMut.isPending ? 'Launching…' : 'Launch instance'}</button>}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
} from "@/api/aws/ec2.api";
|
||||
import type { CloudProvider } from "@/types/cloud";
|
||||
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 DATABASE_CREATION_MINIMUM_MS = 5_000;
|
||||
|
||||
interface CreateRdsInstanceFormProps {
|
||||
cloud: CloudProvider;
|
||||
@@ -70,8 +72,15 @@ export function CreateRdsInstanceForm({
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) =>
|
||||
createCloudResource(cloud, "database", values),
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
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) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["cloud-resources", cloud, "database"],
|
||||
@@ -300,6 +309,7 @@ export function CreateRdsInstanceForm({
|
||||
</label>
|
||||
|
||||
{/* ── 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">
|
||||
<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 { DatabaseSnapshotsPanel } from "@/components/DatabaseSnapshotsPanel";
|
||||
import { CreateRdsInstanceForm } from "@/components/CreateRdsInstanceForm";
|
||||
import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
|
||||
import { AppConfigPanel } from "@/components/AppConfigPanel";
|
||||
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
|
||||
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
||||
@@ -73,6 +74,8 @@ import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
|
||||
import {KinesisPanel} from "@/components/KinesisPanel";
|
||||
import {SesConsolePanel} from '@/components/SesConsolePanel'
|
||||
import {ElastiCacheManagementPanel} from '@/components/ElastiCacheManagementPanel'
|
||||
import {LightsailLaunchPanel} from '@/components/LightsailLaunchPanel'
|
||||
import {LightsailInstancePanel} from '@/components/LightsailInstancePanel'
|
||||
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
|
||||
import {workflowStudioPath} from "@/lib/workflowStudio";
|
||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
@@ -88,6 +91,8 @@ interface DynamicResourceViewProps {
|
||||
onOpenInfo: () => void;
|
||||
}
|
||||
|
||||
const ELASTICACHE_CREATION_MINIMUM_MS = 5_000;
|
||||
|
||||
interface CreateMutationVariables {
|
||||
values: Record<string, unknown>;
|
||||
destination?: WorkflowCreationDestination;
|
||||
@@ -220,6 +225,10 @@ export function DynamicResourceView({
|
||||
delete lambdaValues.blueprintQueueName;
|
||||
delete lambdaValues.blueprintSqsBatchSize;
|
||||
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) {
|
||||
let bucketCreated = false;
|
||||
try {
|
||||
@@ -817,6 +826,13 @@ export function DynamicResourceView({
|
||||
}}
|
||||
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" ? (
|
||||
<CreateRdsInstanceForm
|
||||
cloud={cloud}
|
||||
@@ -858,6 +874,7 @@ export function DynamicResourceView({
|
||||
) : 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})}/>
|
||||
) : (
|
||||
<>
|
||||
<DynamicFormRenderer
|
||||
key={`${cloud}:${service}:create`}
|
||||
schema={schema}
|
||||
@@ -872,6 +889,8 @@ export function DynamicResourceView({
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
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>
|
||||
)}
|
||||
@@ -1043,6 +1062,7 @@ export function DynamicResourceView({
|
||||
)}
|
||||
{service === "kinesis" && cloud === "aws" && <KinesisPanel cloud={cloud} resource={activeSelected} runtimeReachable={canUseRuntime} mode="stream" />}
|
||||
{service === "elasticache" && cloud === "aws" && <ElastiCacheManagementPanel runtimeReachable={canUseRuntime} />}
|
||||
{service === "lightsail" && cloud === "aws" && <LightsailInstancePanel resource={activeSelected} runtimeReachable={canUseRuntime} onDeleted={() => setSelected(undefined)} />}
|
||||
{editingResource && schema.updateFields && (
|
||||
<div
|
||||
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 {deleteFlatItem, listCloudResources, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
||||
import {CopyValueButton} from '@/components/CopyValueButton'
|
||||
import {LoadBalancingProgress} from '@/components/LoadBalancingProgress'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
@@ -33,7 +34,13 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
|
||||
}, [resource?.id])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn}),
|
||||
mutationFn: async () => {
|
||||
const startedAt = Date.now()
|
||||
const created = await putFlatItem('aws', 'loadbalancing', resource?.id ?? '', {port: Number(port), targetGroupArn})
|
||||
const remaining = 5_000 - (Date.now() - startedAt)
|
||||
if (remaining > 0) await new Promise<void>((resolve) => setTimeout(resolve, remaining))
|
||||
return created
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({queryKey: ['load-balancer-listeners', accountId, resource?.id]}),
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
@@ -62,6 +69,7 @@ export function LoadBalancerListenerPanel({resource, runtimeReachable}: Props) {
|
||||
<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>
|
||||
{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>
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{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 {ChildItem, CloudResource} from '@/types/resource'
|
||||
import {calculateSubnetCidrs, isValidCidr, isValidPort} from '@/lib/network'
|
||||
import {clientId} from '@/lib/clientId'
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -193,7 +194,7 @@ function ConfirmDeleteModal({title, detail, onConfirm, onClose, isPending}: {
|
||||
|
||||
function EditRuleTable({rules, onChange}: {rules: EditRule[]; onChange: (rules: EditRule[]) => void}) {
|
||||
function addRule() {
|
||||
onChange([...rules, {rid: crypto.randomUUID(), protocol: 'tcp', fromPort: '', toPort: '', cidr: '0.0.0.0/0'}])
|
||||
onChange([...rules, {rid: clientId(), protocol: 'tcp', fromPort: '', toPort: '', cidr: '0.0.0.0/0'}])
|
||||
}
|
||||
function removeRule(rid: string) {
|
||||
onChange(rules.filter((r) => r.rid !== rid))
|
||||
|
||||
@@ -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 {getLoadBalancingDependencies, listFlatItems, putFlatItem, deleteFlatItem} from '@/api/cloudProxyClient'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {LoadBalancingProgress} from '@/components/LoadBalancingProgress'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
@@ -37,7 +38,21 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
||||
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
|
||||
}
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: () => putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)}),
|
||||
mutationFn: async () => {
|
||||
const startedAt = Date.now()
|
||||
await putFlatItem('aws', 'targetgroups', resource?.id ?? '', {id: instanceId, port: Number(port)})
|
||||
while (true) {
|
||||
const health = await listFlatItems('aws', 'targetgroups', resource?.id ?? '')
|
||||
const target = health.items.find((item) => stringValue(item.body.id) === instanceId && Number(item.body.port) === Number(port))
|
||||
const state = stringValue(target?.body.state).toLowerCase()
|
||||
if (state === 'healthy' || state === 'unhealthy') {
|
||||
const remaining = 5_000 - (Date.now() - startedAt)
|
||||
if (remaining > 0) await new Promise<void>((resolve) => setTimeout(resolve, remaining))
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({queryKey: ['target-group-health', accountId, resource?.id]}),
|
||||
})
|
||||
const deregisterMutation = useMutation({
|
||||
@@ -63,6 +78,7 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
|
||||
<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>
|
||||
{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>
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{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 kmsIcon from '@/assets/aws-services/kms.svg'
|
||||
import lambdaIcon from '@/assets/aws-services/lambda.svg'
|
||||
import lightsailIcon from '../../../../aws-icons-svg/Compute/Lightsail.svg'
|
||||
import parameterStoreIcon from '@/assets/aws-services/parameter-store.svg'
|
||||
import rdsIcon from '@/assets/aws-services/rds.svg'
|
||||
import s3Icon from '@/assets/aws-services/s3.svg'
|
||||
@@ -68,6 +69,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
database: Table2,
|
||||
nosql: Database,
|
||||
k8s: Boxes,
|
||||
lightsail: Server,
|
||||
compute: Server,
|
||||
containers: Container,
|
||||
networking: Network,
|
||||
@@ -112,6 +114,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
kms: kmsIcon,
|
||||
k8s: eksIcon,
|
||||
loadbalancing: elasticLoadBalancingIcon,
|
||||
lightsail: lightsailIcon,
|
||||
logs: cloudWatchLogsIcon,
|
||||
messaging: sqsIcon,
|
||||
kinesis: kinesisIcon,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isValidCidr, isValidPort } from "@/lib/network";
|
||||
import { clientId } from "@/lib/clientId";
|
||||
import type { SgRule } from "./SgRuleTable";
|
||||
|
||||
export function newRule(): SgRule {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
id: clientId(),
|
||||
type: "custom-tcp",
|
||||
protocol: "tcp",
|
||||
fromPort: "",
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
usePutSecretValueMutation,
|
||||
} from '@/api/aws/secretsmanager.mutations'
|
||||
import { timeAgo } from '@/lib/utils'
|
||||
import { clientId } from '@/lib/clientId'
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
||||
type JsonValueKind = 'string' | 'number' | 'boolean' | 'null' | 'json'
|
||||
@@ -338,7 +339,7 @@ function SecretDrawer({
|
||||
function addKeyValueEntry() {
|
||||
setKeyValueEntries((entries) => [
|
||||
...entries,
|
||||
{ id: crypto.randomUUID(), key: '', value: '', kind: 'string' },
|
||||
{ id: clientId(), key: '', value: '', kind: 'string' },
|
||||
])
|
||||
setEditorError('')
|
||||
}
|
||||
|
||||
@@ -4875,6 +4875,181 @@ button.console-service-card {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ec2-connect-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ec2-readiness-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ec2-readiness-card {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--surface) 82%, var(--bg));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ec2-readiness-card > span,
|
||||
.ec2-readiness-card small {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ec2-readiness-card strong {
|
||||
color: var(--status-success);
|
||||
font: 600 13px "Space Mono", monospace;
|
||||
}
|
||||
|
||||
.ec2-readiness-card strong.error,
|
||||
.ec2-check-result.error strong {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.ec2-connect-error,
|
||||
.ec2-check-result {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ec2-connect-error {
|
||||
color: var(--status-error);
|
||||
border: 1px solid color-mix(in srgb, var(--status-error) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--status-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.ec2-connection-notice {
|
||||
padding: 9px 11px;
|
||||
border-left: 2px solid var(--text-3);
|
||||
background: color-mix(in srgb, var(--surface) 82%, var(--bg));
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ec2-connect-details {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ec2-command-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ec2-command-row code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ec2-ssh-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ec2-ssh-check strong,
|
||||
.ec2-launch-timeline h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ec2-ssh-check p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ec2-check-result {
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.ec2-check-result.success strong {
|
||||
color: var(--status-success);
|
||||
font-family: "Space Mono", monospace;
|
||||
}
|
||||
|
||||
.ec2-check-result span {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.ec2-launch-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ec2-launch-event {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.ec2-launch-event div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ec2-launch-event strong {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ec2-launch-event small,
|
||||
.ec2-launch-event time {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ec2-event-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
}
|
||||
|
||||
.ec2-event-dot.ok,
|
||||
.ec2-event-dot.ready,
|
||||
.ec2-event-dot.completed,
|
||||
.ec2-event-dot.success {
|
||||
background: var(--status-success);
|
||||
}
|
||||
|
||||
.ec2-event-dot.failed,
|
||||
.ec2-event-dot.error {
|
||||
background: var(--status-error);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ec2-readiness-grid,
|
||||
.ec2-connect-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ec2-ssh-check {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Copy object modal ──────────────────────────────────────────────────── */
|
||||
.copy-modal-overlay {
|
||||
position: fixed;
|
||||
@@ -5709,3 +5884,64 @@ button.console-service-card {
|
||||
.redis-value { flex: 1; min-height: 110px; max-height: 310px; overflow: auto; margin: 0; padding: 12px; color: var(--text); font-size: 11px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.redis-set-form { border-top: 1px solid var(--border); background: var(--raised); }
|
||||
.redis-set-form strong { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 12px; }
|
||||
|
||||
.lightsail-launch { max-width: 980px; padding: 28px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
|
||||
.lightsail-launch-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; }
|
||||
.lightsail-launch h2, .lightsail-launch h3 { margin: 0; }
|
||||
.lightsail-launch h2 { font-size: 25px; }
|
||||
.lightsail-launch h3 { font-size: 17px; margin-bottom: 13px; }
|
||||
.lightsail-section { margin-top: 30px; }
|
||||
.lightsail-platforms, .lightsail-blueprints, .lightsail-plans { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.lightsail-platform, .lightsail-blueprint, .lightsail-plans button { border: 1px solid var(--border); border-radius: 9px; background: var(--surface); color: var(--text); text-align: left; }
|
||||
.lightsail-platform { display: flex; align-items: center; gap: 11px; min-width: 250px; padding: 16px; }
|
||||
.lightsail-platform.selected, .lightsail-blueprint.selected, .lightsail-plans button.selected { border: 2px solid #ec7211; box-shadow: 0 0 0 1px color-mix(in srgb, #ec7211 18%, transparent); }
|
||||
.lightsail-platform svg:last-child { margin-left: auto; color: #ec7211; }
|
||||
.lightsail-platform span, .lightsail-blueprint span, .lightsail-plans button { display: flex; flex-direction: column; gap: 3px; }
|
||||
.lightsail-launch small { color: var(--text-muted); }
|
||||
.lightsail-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.lightsail-tabs button { border: 1px solid #ec7211; border-radius: 6px; padding: 8px 14px; background: transparent; color: #b7550a; }
|
||||
.lightsail-tabs button.active { background: #ec7211; color: white; }
|
||||
.lightsail-blueprint { width: 210px; min-height: 84px; padding: 13px; display: flex; align-items: center; gap: 13px; }
|
||||
.lightsail-blueprint img { width: 42px; height: 42px; object-fit: contain; }
|
||||
.lightsail-wordpress { display: grid; place-items: center; width: 42px; height: 42px; border: 2px solid #21759b; border-radius: 50%; color: #21759b; font-family: Georgia, serif; font-size: 29px; }
|
||||
.lightsail-plans button { width: 205px; padding: 13px; }
|
||||
.lightsail-config { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 28px; }
|
||||
.lightsail-config label { display: grid; gap: 6px; font-weight: 600; }
|
||||
.server-launch-progress { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #ec7211 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #ec7211 8%, var(--surface)); }
|
||||
.server-launch-progress svg { width: 70px; height: 56px; flex: 0 0 auto; }
|
||||
.server-launch-progress div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.server-launch-progress small { color: var(--text-muted); }
|
||||
.database-launch-progress { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #1769c2 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #1769c2 8%, var(--surface)); }
|
||||
.database-launch-progress svg { width: 56px; height: 56px; flex: 0 0 auto; }
|
||||
.database-launch-progress div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.database-launch-progress small { color: var(--text-muted); }
|
||||
.database-led { animation: database-led-blink 1.4s ease-in-out infinite; }
|
||||
.database-led-b { animation-delay: .22s; }
|
||||
.database-led-c { animation-delay: .44s; }
|
||||
.database-led-d { animation-delay: .66s; }
|
||||
@keyframes database-led-blink { 0%, 25%, 100% { opacity: .28; } 45%, 72% { opacity: 1; } }
|
||||
.load-balancing-progress { display: flex; align-items: center; gap: 14px; margin-top: 16px; padding: 13px 16px; border: 1px solid color-mix(in srgb, #ffc52e 45%, var(--border)); border-radius: 9px; background: color-mix(in srgb, #ffc52e 8%, var(--surface)); }
|
||||
.load-balancing-progress svg { width: 72px; height: 64px; flex: 0 0 auto; }
|
||||
.load-balancing-progress div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.load-balancing-progress small { color: var(--text-muted); }
|
||||
.lb-device { fill: #30283d; }.lb-panel { fill: #2b2440; stroke: #17132f; stroke-width: 5; }.lb-wire { fill: none; stroke: #17132f; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }.lb-yellow { fill: #ffc12d; }.lb-green { fill: #31aa98; }.lb-port { fill: none; stroke: #ffc52e; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }.lb-ports { animation: lb-port-morph 4.5s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }.lb-hub { animation: lb-hub-pulse 4.5s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }.lb-blink-1 { animation: lb-blink-one 4.5s steps(1, end) infinite; }.lb-blink-2 { animation: lb-blink-two 4.5s steps(1, end) infinite; }.lb-blink-3 { animation: lb-blink-three 4.5s steps(1, end) infinite; }
|
||||
@keyframes lb-blink-one { 0%, 18%, 42%, 68%, 100% { opacity: 1; } 19%, 41%, 69%, 84% { opacity: .22; } } @keyframes lb-blink-two { 0%, 28%, 58%, 88%, 100% { opacity: .28; } 29%, 57%, 89%, 99% { opacity: 1; } } @keyframes lb-blink-three { 0%, 12%, 48%, 76%, 100% { opacity: 1; } 13%, 47%, 77%, 92% { opacity: .2; } } @keyframes lb-port-morph { 0%, 18%, 82%, 100% { transform: scaleX(.62); } 42%, 62% { transform: scaleX(1.18); } } @keyframes lb-hub-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.13); } }
|
||||
@media (max-width: 640px) { .lightsail-launch { padding: 18px; } .lightsail-config { grid-template-columns: 1fr; } .lightsail-platform, .lightsail-blueprint, .lightsail-plans button { width: 100%; } }
|
||||
|
||||
.lightsail-instance-panel { overflow: visible; }
|
||||
.lightsail-instance-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 18px 18px 8px; }
|
||||
.lightsail-instance-header h2 { margin: 3px 0; font-size: 22px; }
|
||||
.lightsail-instance-controls { display: flex; align-items: center; gap: 10px; }
|
||||
.lightsail-instance-controls summary { display: flex; align-items: center; gap: 4px; }
|
||||
.lightsail-actions-menu { min-width: 154px; right: 0; left: auto; }
|
||||
.lightsail-instance-tabs { margin: 0 18px; overflow-x: auto; }
|
||||
.lightsail-overview, .lightsail-tab-content { padding: 18px; }
|
||||
.lightsail-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 18px; }
|
||||
.lightsail-detail { display: grid; gap: 5px; padding: 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-raised, var(--surface)); min-width: 0; }
|
||||
.lightsail-detail span { color: var(--text-muted); font-size: 12px; }
|
||||
.lightsail-detail code { overflow-wrap: anywhere; }
|
||||
.lightsail-tab-content h3 { display: flex; align-items: center; gap: 7px; margin: 20px 0 5px; font-size: 16px; }
|
||||
.lightsail-tab-content h3:first-child { margin-top: 0; }
|
||||
.lightsail-port-form { align-items: end; margin: 16px 0; }
|
||||
.lightsail-delete-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 18px 18px; padding: 10px; border: 1px solid color-mix(in srgb, var(--status-error, #dc2626) 45%, var(--border)); border-radius: 7px; }
|
||||
@media (max-width: 640px) { .lightsail-instance-header { padding: 14px; flex-direction: column; } .lightsail-instance-controls { width: 100%; justify-content: space-between; } .lightsail-instance-tabs { margin: 0 14px; } .lightsail-overview, .lightsail-tab-content { padding: 14px; } .lightsail-detail-grid { grid-template-columns: 1fr; } .lightsail-delete-confirm { margin: 0 14px 14px; } }
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
Generated
+18
@@ -76,6 +76,9 @@ importers:
|
||||
'@aws-sdk/client-lambda':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
'@aws-sdk/client-lightsail':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1148.0
|
||||
'@aws-sdk/client-rds':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1076.0
|
||||
@@ -368,6 +371,10 @@ packages:
|
||||
resolution: {integrity: sha512-uueu0Do1dy3cm42AdjL3zivHoc5b55vOL3g6BjxUv2xMifSLKZi5Ilh5FhXVDdS/Uwxak/JH+vVQDDnEuQ52gA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-lightsail@3.1148.0':
|
||||
resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-rds-data@3.1144.0':
|
||||
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -3047,6 +3054,17 @@ snapshots:
|
||||
'@smithy/types': 4.15.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-lightsail@3.1148.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
'@aws-sdk/credential-provider-node': 3.972.84
|
||||
'@aws-sdk/types': 3.974.6
|
||||
'@smithy/core': 3.35.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-rds-data@3.1144.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.1
|
||||
|
||||
Reference in New Issue
Block a user