feat: Lightsail + svg animation
This commit is contained in:
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user