feat: Lightsail + svg animation

This commit is contained in:
Zakaria
2026-10-11 14:19:00 -04:00
parent 0036ef7e51
commit ca1e845cb6
40 changed files with 1887 additions and 34 deletions
+19 -1
View File
@@ -25,7 +25,9 @@ export const apiEndpointKeys = {
invoke: "clouds.services.resources.invoke",
start: "clouds.services.resources.start",
stop: "clouds.services.resources.stop",
reboot: "clouds.services.resources.reboot",
reboot: "clouds.services.resources.reboot",
deployLightsailStaticFiles: "clouds.services.resources.lightsail.static-files.deploy",
deployLightsailNodeApplication: "clouds.services.resources.lightsail.node-application.deploy",
publish: "clouds.services.resources.notifications.publish",
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"],
});
});
});
+39 -1
View File
@@ -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 },
);
}
+37 -4
View File
@@ -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;
@@ -164,10 +165,12 @@ export async function createCloudResource(
values: Record<string, unknown>,
signal?: AbortSignal,
): Promise<CloudResource> {
const timeout =
(cloud === "azure" || cloud === "aws") && service === "database"
? DATABASE_MUTATION_TIMEOUT_MS
: undefined;
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,
requestOptions(cloud, service, { signal, body: values, timeout }),
@@ -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,
+102 -20
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CopyValueButton } from './CopyValueButton'
import { ServerLaunchProgress } from './ServerLaunchProgress'
import {
AlertTriangle,
HardDrive,
@@ -18,6 +19,7 @@ import {
} from 'lucide-react'
import {
authorizeEc2SecurityGroupIngress,
checkEc2InstanceSsh,
createEc2Ami,
createEc2KeyPair,
createEc2SecurityGroup,
@@ -53,6 +55,8 @@ import debianImage from '../../../../OS_img/debian.jpg'
import redHatImage from '../../../../OS_img/RedHat.png'
import ubuntuImage from '../../../../OS_img/Ubuntu_Linux.png'
const EC2_LAUNCH_MINIMUM_MS = 5_000
// ─── Props ────────────────────────────────────────────────────────────────────
interface ComputePanelProps {
@@ -420,6 +424,9 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
invalidate()
},
})
const sshCheck = useMutation({
mutationFn: () => checkEc2InstanceSsh(instanceId),
})
if (query.isLoading) {
return (
@@ -656,25 +663,82 @@ function InstancePanel({ instanceId }: { instanceId: string }) {
{tab === 'connection' && (() => {
const connection = inst.connection
const sshUser = connection?.sshUser || 'root'
const command = connection?.sshHost && connection.sshPort && inst.keyName
? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} root@${connection.sshHost}`
? `ssh -i ${inst.keyName}.pem -p ${connection.sshPort} ${sshUser}@${connection.sshHost}`
: undefined
if (state === 'stopped') {
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Start this instance before connecting over SSH.</p>
if (!connection) {
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Connection diagnostics are unavailable.</p>
}
if (state === 'terminated') {
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>Terminated instances cannot accept connections.</p>
}
if (state !== 'running') {
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable while this instance is {state}.</p>
}
if (!command || connection?.containerStatus !== 'running') {
return <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>SSH is unavailable. Backing container status: {connection?.containerStatus ?? 'unavailable'}.</p>
}
return <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="meta-row"><span className="meta-label">SSH port</span><span className="meta-value">{connection.sshPort}</span></div>
<div className="meta-row"><span className="meta-label">Backing container</span><span className="meta-value">{connection.containerStatus}</span></div>
<div className="meta-row" style={{ gridColumn: '1 / -1' }}><span className="meta-label">Command</span><code style={{ overflowWrap: 'anywhere' }}>{command}</code><CopyValueButton value={command} label="SSH command" /></div>
const canCheckSsh = state === 'running' && connection.containerStatus === 'running'
&& Boolean(connection.sshPort)
const failure = typeof connection.failure === 'string'
? connection.failure
: connection.failure?.message
return <div className="ec2-connect-panel">
{!canCheckSsh && <div className="ec2-connection-notice">
{state === 'terminated'
? 'This instance is terminated. Launch diagnostics remain available below.'
: state === 'stopped'
? 'Start this instance before connecting over SSH.'
: `SSH is unavailable while this instance is ${state}.`}
</div>}
<div className="ec2-readiness-grid">
<div className="ec2-readiness-card">
<span>Launch</span>
<strong className={connection.launchStatus === 'failed' ? 'error' : ''}>{connection.launchStatus ?? 'unknown'}</strong>
<small>{connection.selectedImageReference ?? 'No selected image recorded'}</small>
</div>
<div className="ec2-readiness-card">
<span>SSH readiness</span>
<strong className={connection.sshStatus === 'impaired' ? 'error' : ''}>{connection.sshStatus ?? 'unknown'}</strong>
<small>{connection.sshStatusMessage ?? 'No readiness result recorded'}</small>
</div>
<div className="ec2-readiness-card">
<span>Key injection</span>
<strong>{connection.keyInjectionStatus ?? (inst.keyName ? 'unknown' : 'not requested')}</strong>
<small>{inst.keyName ?? 'No key pair selected'}</small>
</div>
</div>
{failure && <div className="ec2-connect-error"><AlertTriangle size={14}/><span>{failure}</span></div>}
<div className="meta-grid ec2-connect-details">
<div className="meta-row"><span className="meta-label">SSH target</span><span className="meta-value">{connection.sshPort ? `${connection.sshHost}:${connection.sshPort}` : 'Not published'}</span></div>
<div className="meta-row"><span className="meta-label">Backing container</span><span className="meta-value">{connection.containerStatus}</span></div>
<div className="meta-row"><span className="meta-label">Image source</span><span className="meta-value">{connection.selectedImageType ?? 'unknown'}</span></div>
<div className="meta-row"><span className="meta-label">Init / SSH service</span><span className="meta-value">{connection.initType ?? 'unknown'} / {connection.sshServiceName ?? 'unknown'}</span></div>
{command && <div className="meta-row ec2-command-row"><span className="meta-label">Command</span><code>{command}</code><CopyValueButton value={command} label="SSH command" /></div>}
</div>
{canCheckSsh && <div className="ec2-ssh-check">
<div>
<strong>Live SSH banner check</strong>
<p>Runs from the UI backend, using the published diagnostic port.</p>
</div>
<button className="button compact primary" disabled={sshCheck.isPending} onClick={() => sshCheck.mutate()}>
{sshCheck.isPending ? <Loader2 size={12} className="spin"/> : <Terminal size={12}/>}
{sshCheck.isPending ? 'Checking' : 'Check SSH'}
</button>
</div>}
{sshCheck.isSuccess && <div className="ec2-check-result success">
<strong>{sshCheck.data.banner}</strong>
<span>Checked from {sshCheck.data.checkedFrom} at {new Date(sshCheck.data.checkedAt).toLocaleString()}</span>
<span>{sshCheck.data.target.host}:{sshCheck.data.target.port}</span>
</div>}
{sshCheck.isError && <div className="ec2-check-result error">
<strong>SSH check failed</strong>
<span>{sshCheck.error instanceof Error ? sshCheck.error.message : 'Unable to verify SSH'}</span>
</div>}
{(connection.launchEvents?.length ?? 0) > 0 && <div className="ec2-launch-timeline">
<h4>Launch timeline</h4>
{connection.launchEvents?.map((event, index) => <div className="ec2-launch-event" key={`${event.timestamp ?? 'event'}-${index}`}>
<span className={`ec2-event-dot ${event.status ?? 'unknown'}`}/>
<div><strong>{event.stage ?? 'Launch event'}</strong><small>{event.message ?? event.status ?? 'Recorded'}</small></div>
<time>{event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : ''}</time>
</div>)}
</div>}
</div>
})()}
@@ -810,7 +874,7 @@ const QUICK_START_AMIS: Array<{ family: Exclude<AmiFamily, 'other'>; label: stri
{ family: 'ubuntu', label: 'Ubuntu', image: ubuntuImage },
{ family: 'suse', label: 'openSUSE', image: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" rx="8" fill="%2330ba78"/%3E%3Ctext x="32" y="43" text-anchor="middle" font-family="Arial" font-size="30" font-weight="bold" fill="white"%3ES%3C/text%3E%3C/svg%3E' },
{ family: 'red-hat', label: 'Red Hat', image: redHatImage },
{ family: 'debian', label: 'Debian 13', image: debianImage, preferredImageId: 'ami-debian13' },
{ family: 'debian', label: 'Debian 12', image: debianImage, preferredImageId: 'ami-debian12' },
]
const SG_PRESETS: Array<{ label: string; protocol: string; portInput: string; cidr: string }> = [
@@ -830,12 +894,13 @@ function parseRule(rule: InboundRule): IpPermissionInput {
return { protocol: rule.protocol, fromPort, toPort, cidr: rule.cidr }
}
const INSTANCE_TYPES = [
const X86_INSTANCE_TYPES = [
't2.micro', 't2.small', 't2.medium',
't3.micro', 't3.small', 't3.medium', 't3.large', 't3.xlarge',
'm5.large', 'm5.xlarge',
'c5.large', 'c5.xlarge',
]
const ARM_INSTANCE_TYPES = ['t4g.micro', 't4g.small', 't4g.medium', 'm6gd.large', 'm6gd.2xlarge', 'm7gd.large', 'm7gd.2xlarge', 'm8gd.medium', 'm8gd.large', 'm8gd.2xlarge']
export function LaunchInstanceForm({
cloud,
@@ -906,7 +971,15 @@ export function LaunchInstanceForm({
}, [selectedSubnet, sgsQ.data])
const createMut = useMutation({
mutationFn: (values: Record<string, unknown>) => createCloudResource(cloud, 'compute', values),
mutationFn: async (values: Record<string, unknown>) => {
const startedAt = Date.now()
const created = await createCloudResource(cloud, 'compute', values)
const remaining = EC2_LAUNCH_MINIMUM_MS - (Date.now() - startedAt)
if (remaining > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, remaining))
}
return created
},
onSuccess: (created) => {
void qc.invalidateQueries({ queryKey: ['cloud-resources', cloud, 'compute'] })
onSuccess(created)
@@ -1030,8 +1103,15 @@ export function LaunchInstanceForm({
return !quickStartImageIds.has(ami.imageId) && (!normalizedAmiSearch || searchable.includes(normalizedAmiSearch))
})
const selectedAmi = amis.find((ami) => ami.imageId === imageId)
const compatibleInstanceTypes = selectedAmi?.architecture === 'arm64' ? ARM_INSTANCE_TYPES : X86_INSTANCE_TYPES
const selectedGroups = (sgsQ.data ?? []).filter((group) => selectedSgs.includes(group.groupId))
const availableSecurityGroups = (sgsQ.data ?? []).filter((group) => !selectedSubnet || group.vpcId === selectedSubnet.vpcId)
useEffect(() => {
if (!compatibleInstanceTypes.includes(instanceType)) {
setInstanceType(compatibleInstanceTypes[0])
}
}, [compatibleInstanceTypes, instanceType])
const steps: Array<{ number: LaunchStep; label: string }> = [
{ number: 1, label: 'Name and AMI' },
{ number: 2, label: 'Instance and key pair' },
@@ -1115,8 +1195,9 @@ export function LaunchInstanceForm({
<label className="launch-field">
<span>Instance type <em className="field-required">*</em></span>
<select className="input" value={instanceType} onChange={(e) => setInstanceType(e.target.value)}>
{INSTANCE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
{compatibleInstanceTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
{selectedAmi?.architecture === 'arm64' && <small>ARM64 AMIs require Graviton instance types.</small>}
</label>
<div className="launch-field launch-field--span">
<span>Key pair</span>
@@ -1314,6 +1395,7 @@ export function LaunchInstanceForm({
{stepError && <div className="form-error launch-field--span">{stepError}</div>}
{createMut.error instanceof Error && <div className="form-error launch-field--span">{createMut.error.message}</div>}
{createMut.isPending && <div className="launch-field--span"><ServerLaunchProgress name={name} description="Creating the instance and preparing its local runtime."/></div>}
<div className="launch-form-actions" style={{ gridColumn: '1 / -1' }}>
<button type="button" className="button" onClick={step === 1 ? onCancel : () => { setStep((current) => (current - 1) as LaunchStep); setStepError('') }}>{step === 1 ? 'Cancel' : 'Back'}</button>
{step < 4 ? <button type="button" className="button primary" onClick={nextStep}>Next</button> : <button type="button" className="button primary" disabled={createMut.isPending} onClick={launch}><Plus size={14}/>{createMut.isPending ? 'Launching…' : 'Launch instance'}</button>}
@@ -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('')
}
+236
View File
@@ -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; } }
+7
View File
@@ -0,0 +1,7 @@
export function clientId(): string {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
}