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,