diff --git a/packages/api/src/routes/ec2.test.ts b/packages/api/src/routes/ec2.test.ts index a321e8a..3e4de8f 100644 --- a/packages/api/src/routes/ec2.test.ts +++ b/packages/api/src/routes/ec2.test.ts @@ -216,6 +216,17 @@ describe('PUT /instances/:id/security-groups', () => { expect(res.status).toBe(200) expect(receivedGroupIds).toEqual(['sg-123']) }) + + test('rejects an empty selection', async () => { + const res = await app.request('/instances/i-test1/security-groups', { + method: 'PUT', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({groupIds: []}), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({error: 'At least one security group is required'}) + }) }) describe('POST /amis/:imageId/deregister', () => { diff --git a/packages/frontend/index.html b/packages/frontend/index.html index a900f4b..d6e8c1d 100644 --- a/packages/frontend/index.html +++ b/packages/frontend/index.html @@ -3,8 +3,8 @@ - - Floci UI + + The Cloud - + { @@ -84,3 +85,29 @@ describe("EC2 security-group rule requests", () => { expect(new Headers(init?.headers).get("content-type")).toBe("application/json"); }); }); + +describe("updateEc2InstanceSecurityGroups", () => { + const fetchMock = vi.fn(); + + 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 the selected group IDs as a JSON object", async () => { + await updateEc2InstanceSecurityGroups("i-test", ["sg-default", "sg-web"]); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/api/ec2/instances/i-test/security-groups"); + expect(init?.method).toBe("PUT"); + expect(JSON.parse(String(init?.body))).toEqual({groupIds: ["sg-default", "sg-web"]}); + }); +}); diff --git a/packages/frontend/src/api/aws/ec2.api.ts b/packages/frontend/src/api/aws/ec2.api.ts index e3fe169..c1237d1 100644 --- a/packages/frontend/src/api/aws/ec2.api.ts +++ b/packages/frontend/src/api/aws/ec2.api.ts @@ -338,7 +338,7 @@ export async function updateEc2InstanceTags( export async function updateEc2InstanceSecurityGroups(instanceId: string, groupIds: string[]): Promise { await apiClient.call( apiEndpointKeys.aws.ec2.instances.securityGroups, - { body: JSON.stringify({ groupIds }), headers: { "content-type": "application/json" } }, + { body: { groupIds } }, { instanceId }, ); } diff --git a/packages/frontend/src/assets/TheCloud.png b/packages/frontend/src/assets/TheCloud.png new file mode 100644 index 0000000..909b7a5 Binary files /dev/null and b/packages/frontend/src/assets/TheCloud.png differ diff --git a/packages/frontend/src/components/ComputePanel.tsx b/packages/frontend/src/components/ComputePanel.tsx index db123f9..363c277 100644 --- a/packages/frontend/src/components/ComputePanel.tsx +++ b/packages/frontend/src/components/ComputePanel.tsx @@ -154,7 +154,7 @@ function InlineSecurityGroupEditor({ instanceId, vpcId, initialGroups }: { const [selected, setSelected] = useState(() => new Set(initialGroups.map((group) => group.id).filter(Boolean) as string[])) const groups = useQuery({queryKey: ['ec2', 'security-groups', vpcId], queryFn: ({signal}) => listEc2SecurityGroups(vpcId, signal)}) const mutation = useMutation({ - mutationFn: () => updateEc2InstanceSecurityGroups(instanceId, [...selected]), + mutationFn: (groupIds: string[]) => updateEc2InstanceSecurityGroups(instanceId, groupIds), onSuccess: () => { void qc.invalidateQueries({queryKey: ['ec2', 'instance', instanceId]}) void qc.invalidateQueries({queryKey: ['ec2', 'instances']}) @@ -177,7 +177,7 @@ function InlineSecurityGroupEditor({ instanceId, vpcId, initialGroups }: { )} {groups.isError &&

Unable to load security groups.

} {mutation.isError &&

{mutation.error instanceof Error ? mutation.error.message : 'Save failed'}

} - diff --git a/packages/frontend/src/components/ElastiCacheManagementPanel.tsx b/packages/frontend/src/components/ElastiCacheManagementPanel.tsx index 8e3f14e..af6d0c0 100644 --- a/packages/frontend/src/components/ElastiCacheManagementPanel.tsx +++ b/packages/frontend/src/components/ElastiCacheManagementPanel.tsx @@ -40,7 +40,7 @@ function SubnetGroups({runtimeReachable}: {runtimeReachable: boolean}) { const subnetResources = cloudSubnets.data?.subnets ?? []; const refresh = () => void qc.invalidateQueries({queryKey: ["elasticache", "subnet-groups"]}); const create = useMutation({mutationFn: () => createCacheSubnetGroup({cacheSubnetGroupName: name.trim(), cacheSubnetGroupDescription: description.trim(), subnetIds: subnets}), onSuccess: () => {setName(""); setDescription(""); setSubnets([]); refresh();}}); const remove = useMutation({mutationFn: deleteCacheSubnetGroup, onSuccess: refresh}); const modify = useMutation({mutationFn: (group: string) => modifyCacheSubnetGroup(group, {cacheSubnetGroupDescription: description.trim(), subnetIds: subnets}), onSuccess: refresh}); - return <>
{event.preventDefault(); create.mutate();}}>
groups.refetch()} loading={groups.isFetching}/>{groups.data?.map((group) =>
{group.CacheSubnetGroupName}{group.CacheSubnetGroupDescription} · {group.Subnets?.map((subnet) => subnet.SubnetIdentifier).join(", ")}
)}; + return <>
{event.preventDefault(); create.mutate();}}>
groups.refetch()} loading={groups.isFetching}/>{groups.data?.map((group) =>
{group.CacheSubnetGroupName}{group.CacheSubnetGroupDescription} · {group.Subnets?.map((subnet) => subnet.SubnetIdentifier).join(", ")}
)}; } function ParameterGroups({runtimeReachable}: {runtimeReachable: boolean}) { diff --git a/packages/frontend/src/components/MskPanel.tsx b/packages/frontend/src/components/MskPanel.tsx index 4f4c041..e5267d9 100644 --- a/packages/frontend/src/components/MskPanel.tsx +++ b/packages/frontend/src/components/MskPanel.tsx @@ -41,7 +41,7 @@ export function MskPanel({runtimeReachable}: {runtimeReachable: boolean}) {

Amazon MSK

Kafka clusters

Broker access is open: no IAM, SASL, or TLS authentication is configured.

{ event.preventDefault(); if (clusterName.trim() && Number(brokers) > 0 && version.trim() && subnets.length > 0) createCluster.mutate(); }}> - +
{clusterArn && (deleteClusterConfirmation ?
Delete this cluster and all topics?
: )}
diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index f8e9799..a4353b0 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -1419,6 +1419,11 @@ pre, font-size: 13px; } +.input.subnet-multiselect { + height: 78px; + padding: 4px 10px; +} + .input:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent);