diff --git a/spinifex/services/spinifexui/frontend/src/layouts/sidebar-layout.tsx b/spinifex/services/spinifexui/frontend/src/layouts/sidebar-layout.tsx index 0adb8e0cd..c4655d5c1 100644 --- a/spinifex/services/spinifexui/frontend/src/layouts/sidebar-layout.tsx +++ b/spinifex/services/spinifexui/frontend/src/layouts/sidebar-layout.tsx @@ -12,6 +12,7 @@ import { Network, Server, Shield, + ShieldCheck, Users, } from "lucide-react" @@ -197,6 +198,21 @@ export function SidebarLayout() { + + + + + Security Groups + + + + {/* uncomment when route pages are created diff --git a/spinifex/services/spinifexui/frontend/src/lib/subnet-calculator.test.ts b/spinifex/services/spinifexui/frontend/src/lib/subnet-calculator.test.ts index ea5b71c7e..c0fc73e55 100644 --- a/spinifex/services/spinifexui/frontend/src/lib/subnet-calculator.test.ts +++ b/spinifex/services/spinifexui/frontend/src/lib/subnet-calculator.test.ts @@ -81,8 +81,8 @@ describe("calculateSubnetCidrs", () => { it("sets correct labels", () => { const result = calculateSubnetCidrs("10.0.0.0/16", 1, 1) - expect(result.publicSubnets[0]?.label).toBe("Public subnet 1") - expect(result.privateSubnets[0]?.label).toBe("Private subnet 1") + expect(result.publicSubnets[0]?.label).toBe("Public Subnet 1") + expect(result.privateSubnets[0]?.label).toBe("Private Subnet 1") }) }) diff --git a/spinifex/services/spinifexui/frontend/src/mutations/ec2.test.tsx b/spinifex/services/spinifexui/frontend/src/mutations/ec2.test.tsx index 9b3cbab28..8fbbe3e59 100644 --- a/spinifex/services/spinifexui/frontend/src/mutations/ec2.test.tsx +++ b/spinifex/services/spinifexui/frontend/src/mutations/ec2.test.tsx @@ -13,11 +13,14 @@ import type { CreateVpcWizardFormData } from "@/types/ec2" import { useAttachVolume, + useAuthorizeSecurityGroupEgress, + useAuthorizeSecurityGroupIngress, useCopySnapshot, useCreateImage, useCreateInstance, useCreateKeyPair, useCreatePlacementGroup, + useCreateSecurityGroup, useCreateSnapshot, useCreateSubnet, useCreateVolume, @@ -25,6 +28,7 @@ import { useCreateVpcWizard, useDeleteKeyPair, useDeletePlacementGroup, + useDeleteSecurityGroup, useDeleteSnapshot, useDeleteSubnet, useDeleteVolume, @@ -35,6 +39,8 @@ import { useModifyInstanceAttribute, useModifyVolume, useRebootInstance, + useRevokeSecurityGroupEgress, + useRevokeSecurityGroupIngress, useStartInstance, useStopInstance, useTerminateInstance, @@ -617,6 +623,234 @@ describe("useDeletePlacementGroup", () => { }) }) +describe("useCreateSecurityGroup", () => { + it("sends CreateSecurityGroupCommand with form data", async () => { + createQueryClient() + const { result } = renderHook(() => useCreateSecurityGroup(), { wrapper }) + + result.current.mutate({ + groupName: "web-sg", + description: "Allow web traffic", + vpcId: "vpc-123", + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + GroupName: "web-sg", + Description: "Allow web traffic", + VpcId: "vpc-123", + }) + }) + + it("invalidates securityGroups query on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useCreateSecurityGroup(), { wrapper }) + + result.current.mutate({ + groupName: "web-sg", + description: "Allow web traffic", + vpcId: "vpc-123", + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["ec2", "securityGroups"], + }) + }) +}) + +describe("useDeleteSecurityGroup", () => { + it("sends DeleteSecurityGroupCommand with group ID", async () => { + createQueryClient() + const { result } = renderHook(() => useDeleteSecurityGroup(), { wrapper }) + + result.current.mutate("sg-123") + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + GroupId: "sg-123", + }) + }) + + it("invalidates securityGroups query on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useDeleteSecurityGroup(), { wrapper }) + + result.current.mutate("sg-123") + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["ec2", "securityGroups"], + }) + }) +}) + +describe("useAuthorizeSecurityGroupIngress", () => { + const ruleParams = { + groupId: "sg-123", + ipProtocol: "tcp", + fromPort: 443, + toPort: 443, + cidrIp: "0.0.0.0/0", + } + + it("sends AuthorizeSecurityGroupIngressCommand with IpPermissions", async () => { + createQueryClient() + const { result } = renderHook(() => useAuthorizeSecurityGroupIngress(), { + wrapper, + }) + + result.current.mutate(ruleParams) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + GroupId: "sg-123", + IpPermissions: [ + { + IpProtocol: "tcp", + FromPort: 443, + ToPort: 443, + IpRanges: [{ CidrIp: "0.0.0.0/0" }], + }, + ], + }) + }) + + it("invalidates securityGroups queries on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useAuthorizeSecurityGroupIngress(), { + wrapper, + }) + + result.current.mutate(ruleParams) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["ec2", "securityGroups"], + }) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["ec2", "securityGroups", "sg-123"], + }) + }) +}) + +describe("useAuthorizeSecurityGroupEgress", () => { + it("sends AuthorizeSecurityGroupEgressCommand with IpPermissions", async () => { + createQueryClient() + const { result } = renderHook(() => useAuthorizeSecurityGroupEgress(), { + wrapper, + }) + + result.current.mutate({ + groupId: "sg-456", + ipProtocol: "-1", + fromPort: -1, + toPort: -1, + cidrIp: "0.0.0.0/0", + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + GroupId: "sg-456", + IpPermissions: [ + { + IpProtocol: "-1", + FromPort: -1, + ToPort: -1, + IpRanges: [{ CidrIp: "0.0.0.0/0" }], + }, + ], + }) + }) +}) + +describe("useRevokeSecurityGroupIngress", () => { + it("sends RevokeSecurityGroupIngressCommand with IpPermissions", async () => { + createQueryClient() + const { result } = renderHook(() => useRevokeSecurityGroupIngress(), { + wrapper, + }) + + result.current.mutate({ + groupId: "sg-123", + ipProtocol: "tcp", + fromPort: 22, + toPort: 22, + cidrIp: "10.0.0.0/16", + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + GroupId: "sg-123", + IpPermissions: [ + { + IpProtocol: "tcp", + FromPort: 22, + ToPort: 22, + IpRanges: [{ CidrIp: "10.0.0.0/16" }], + }, + ], + }) + }) + + it("invalidates securityGroups queries on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useRevokeSecurityGroupIngress(), { + wrapper, + }) + + result.current.mutate({ + groupId: "sg-123", + ipProtocol: "tcp", + fromPort: 22, + toPort: 22, + cidrIp: "10.0.0.0/16", + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["ec2", "securityGroups"], + }) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["ec2", "securityGroups", "sg-123"], + }) + }) +}) + +describe("useRevokeSecurityGroupEgress", () => { + it("sends RevokeSecurityGroupEgressCommand with IpPermissions", async () => { + createQueryClient() + const { result } = renderHook(() => useRevokeSecurityGroupEgress(), { + wrapper, + }) + + result.current.mutate({ + groupId: "sg-456", + ipProtocol: "udp", + fromPort: 53, + toPort: 53, + cidrIp: "0.0.0.0/0", + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + GroupId: "sg-456", + IpPermissions: [ + { + IpProtocol: "udp", + FromPort: 53, + ToPort: 53, + IpRanges: [{ CidrIp: "0.0.0.0/0" }], + }, + ], + }) + }) +}) + describe("useCreateVpcWizard", () => { const baseParams: CreateVpcWizardFormData = { mode: "vpc-only", diff --git a/spinifex/services/spinifexui/frontend/src/mutations/ec2.ts b/spinifex/services/spinifexui/frontend/src/mutations/ec2.ts index 13476d7e7..4789f5108 100644 --- a/spinifex/services/spinifexui/frontend/src/mutations/ec2.ts +++ b/spinifex/services/spinifexui/frontend/src/mutations/ec2.ts @@ -7,6 +7,8 @@ import { AssociateRouteTableCommand, AttachInternetGatewayCommand, AttachVolumeCommand, + AuthorizeSecurityGroupEgressCommand, + AuthorizeSecurityGroupIngressCommand, CopySnapshotCommand, CreateImageCommand, CreateInternetGatewayCommand, @@ -14,12 +16,14 @@ import { CreatePlacementGroupCommand, CreateRouteCommand, CreateRouteTableCommand, + CreateSecurityGroupCommand, CreateSnapshotCommand, CreateSubnetCommand, CreateVolumeCommand, CreateVpcCommand, DeleteKeyPairCommand, DeletePlacementGroupCommand, + DeleteSecurityGroupCommand, DeleteSnapshotCommand, DeleteSubnetCommand, DeleteVolumeCommand, @@ -31,6 +35,8 @@ import { ModifyVolumeCommand, RebootInstancesCommand, ResourceType, + RevokeSecurityGroupEgressCommand, + RevokeSecurityGroupIngressCommand, RunInstancesCommand, StartInstancesCommand, StopInstancesCommand, @@ -48,6 +54,7 @@ import type { CreateInstanceParams, CreateKeyPairData, CreatePlacementGroupFormData, + CreateSecurityGroupFormData, CreateSnapshotFormData, CreateSubnetFormData, CreateVolumeFormData, @@ -57,6 +64,7 @@ import type { ImportKeyPairData, ModifyInstanceTypeFormData, ModifyVolumeParams, + SecurityGroupRuleFormData, } from "@/types/ec2" const WHITESPACE_REGEX = /\s+/ @@ -484,6 +492,142 @@ export function useDeletePlacementGroup() { }) } +export function useCreateSecurityGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: CreateSecurityGroupFormData) => { + const command = new CreateSecurityGroupCommand({ + GroupName: params.groupName, + Description: params.description, + VpcId: params.vpcId, + }) + return getEc2Client().send(command) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ec2", "securityGroups"] }) + }, + }) +} + +export function useDeleteSecurityGroup() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (groupId: string) => { + const command = new DeleteSecurityGroupCommand({ + GroupId: groupId, + }) + return getEc2Client().send(command) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ec2", "securityGroups"] }) + }, + }) +} + +export function useAuthorizeSecurityGroupIngress() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: SecurityGroupRuleFormData & { groupId: string }) => { + const command = new AuthorizeSecurityGroupIngressCommand({ + GroupId: params.groupId, + IpPermissions: [ + { + IpProtocol: params.ipProtocol, + FromPort: params.fromPort, + ToPort: params.toPort, + IpRanges: [{ CidrIp: params.cidrIp }], + }, + ], + }) + return getEc2Client().send(command) + }, + onSuccess: (_data, params) => { + queryClient.invalidateQueries({ queryKey: ["ec2", "securityGroups"] }) + queryClient.invalidateQueries({ + queryKey: ["ec2", "securityGroups", params.groupId], + }) + }, + }) +} + +export function useAuthorizeSecurityGroupEgress() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: SecurityGroupRuleFormData & { groupId: string }) => { + const command = new AuthorizeSecurityGroupEgressCommand({ + GroupId: params.groupId, + IpPermissions: [ + { + IpProtocol: params.ipProtocol, + FromPort: params.fromPort, + ToPort: params.toPort, + IpRanges: [{ CidrIp: params.cidrIp }], + }, + ], + }) + return getEc2Client().send(command) + }, + onSuccess: (_data, params) => { + queryClient.invalidateQueries({ queryKey: ["ec2", "securityGroups"] }) + queryClient.invalidateQueries({ + queryKey: ["ec2", "securityGroups", params.groupId], + }) + }, + }) +} + +export function useRevokeSecurityGroupIngress() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: SecurityGroupRuleFormData & { groupId: string }) => { + const command = new RevokeSecurityGroupIngressCommand({ + GroupId: params.groupId, + IpPermissions: [ + { + IpProtocol: params.ipProtocol, + FromPort: params.fromPort, + ToPort: params.toPort, + IpRanges: [{ CidrIp: params.cidrIp }], + }, + ], + }) + return getEc2Client().send(command) + }, + onSuccess: (_data, params) => { + queryClient.invalidateQueries({ queryKey: ["ec2", "securityGroups"] }) + queryClient.invalidateQueries({ + queryKey: ["ec2", "securityGroups", params.groupId], + }) + }, + }) +} + +export function useRevokeSecurityGroupEgress() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: SecurityGroupRuleFormData & { groupId: string }) => { + const command = new RevokeSecurityGroupEgressCommand({ + GroupId: params.groupId, + IpPermissions: [ + { + IpProtocol: params.ipProtocol, + FromPort: params.fromPort, + ToPort: params.toPort, + IpRanges: [{ CidrIp: params.cidrIp }], + }, + ], + }) + return getEc2Client().send(command) + }, + onSuccess: (_data, params) => { + queryClient.invalidateQueries({ queryKey: ["ec2", "securityGroups"] }) + queryClient.invalidateQueries({ + queryKey: ["ec2", "securityGroups", params.groupId], + }) + }, + }) +} + function buildTags(name: string | undefined, extraTags: Tag[]): Tag[] { const tags: Tag[] = [] if (name) { diff --git a/spinifex/services/spinifexui/frontend/src/queries/ec2.test.ts b/spinifex/services/spinifexui/frontend/src/queries/ec2.test.ts index 5cc6698ee..e075d62cf 100644 --- a/spinifex/services/spinifexui/frontend/src/queries/ec2.test.ts +++ b/spinifex/services/spinifexui/frontend/src/queries/ec2.test.ts @@ -18,6 +18,8 @@ import { ec2PlacementGroupQueryOptions, ec2PlacementGroupsQueryOptions, ec2RegionsQueryOptions, + ec2SecurityGroupQueryOptions, + ec2SecurityGroupsQueryOptions, ec2SnapshotQueryOptions, ec2SnapshotsQueryOptions, ec2SubnetQueryOptions, @@ -135,6 +137,21 @@ describe("query keys", () => { "pg-123", ]) }) + + it("ec2SecurityGroupsQueryOptions has correct key", () => { + expect(ec2SecurityGroupsQueryOptions.queryKey).toEqual([ + "ec2", + "securityGroups", + ]) + }) + + it("ec2SecurityGroupQueryOptions includes groupId in key", () => { + expect(ec2SecurityGroupQueryOptions("sg-123").queryKey).toEqual([ + "ec2", + "securityGroups", + "sg-123", + ]) + }) }) describe("staleTime and refetchInterval", () => { @@ -349,4 +366,23 @@ describe("queryFn", () => { GroupIds: ["pg-123"], }) }) + + it("ec2SecurityGroupsQueryOptions sends DescribeSecurityGroupsCommand", async () => { + const queryFn = ec2SecurityGroupsQueryOptions.queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend).toHaveBeenCalledOnce() + expect(mockSend.mock.calls[0]?.[0].input).toEqual({}) + }) + + it("ec2SecurityGroupQueryOptions sends DescribeSecurityGroupsCommand with ID", async () => { + const queryFn = ec2SecurityGroupQueryOptions("sg-123").queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + GroupIds: ["sg-123"], + }) + }) }) diff --git a/spinifex/services/spinifexui/frontend/src/queries/ec2.ts b/spinifex/services/spinifexui/frontend/src/queries/ec2.ts index 229cf3b2a..7e854833c 100644 --- a/spinifex/services/spinifexui/frontend/src/queries/ec2.ts +++ b/spinifex/services/spinifexui/frontend/src/queries/ec2.ts @@ -8,6 +8,7 @@ import { DescribePlacementGroupsCommand, DescribeRegionsCommand, DescribeRouteTablesCommand, + DescribeSecurityGroupsCommand, DescribeSnapshotsCommand, DescribeSubnetsCommand, DescribeVolumesCommand, @@ -207,6 +208,25 @@ export const ec2PlacementGroupQueryOptions = (groupId: string) => }, }) +export const ec2SecurityGroupsQueryOptions = queryOptions({ + queryKey: ["ec2", "securityGroups"], + queryFn: () => { + const command = new DescribeSecurityGroupsCommand({}) + return getEc2Client().send(command) + }, +}) + +export const ec2SecurityGroupQueryOptions = (groupId: string) => + queryOptions({ + queryKey: ["ec2", "securityGroups", groupId], + queryFn: () => { + const command = new DescribeSecurityGroupsCommand({ + GroupIds: [groupId], + }) + return getEc2Client().send(command) + }, + }) + export const ec2VolumesQueryOptions = queryOptions({ queryKey: ["ec2", "volumes"], queryFn: () => { diff --git a/spinifex/services/spinifexui/frontend/src/routeTree.gen.ts b/spinifex/services/spinifexui/frontend/src/routeTree.gen.ts index f005ae0a1..257852857 100644 --- a/spinifex/services/spinifexui/frontend/src/routeTree.gen.ts +++ b/spinifex/services/spinifexui/frontend/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as AuthEc2vpcCreateVpcRouteImport } from './routes/_auth/ec2/(vpc import { Route as AuthEc2volumesCreateVolumeRouteImport } from './routes/_auth/ec2/(volumes)/create-volume' import { Route as AuthEc2subnetCreateSubnetRouteImport } from './routes/_auth/ec2/(subnet)/create-subnet' import { Route as AuthEc2snapshotsCreateSnapshotRouteImport } from './routes/_auth/ec2/(snapshots)/create-snapshot' +import { Route as AuthEc2securityGroupsCreateSecurityGroupRouteImport } from './routes/_auth/ec2/(security-groups)/create-security-group' import { Route as AuthEc2placementGroupsCreatePlacementGroupRouteImport } from './routes/_auth/ec2/(placement-groups)/create-placement-group' import { Route as AuthEc2keyImportKeyPairRouteImport } from './routes/_auth/ec2/(key)/import-key-pair' import { Route as AuthEc2keyCreateKeyPairRouteImport } from './routes/_auth/ec2/(key)/create-key-pair' @@ -32,6 +33,7 @@ import { Route as AuthEc2vpcDescribeVpcsIndexRouteImport } from './routes/_auth/ import { Route as AuthEc2volumesDescribeVolumesIndexRouteImport } from './routes/_auth/ec2/(volumes)/describe-volumes/index' import { Route as AuthEc2subnetDescribeSubnetsIndexRouteImport } from './routes/_auth/ec2/(subnet)/describe-subnets/index' import { Route as AuthEc2snapshotsDescribeSnapshotsIndexRouteImport } from './routes/_auth/ec2/(snapshots)/describe-snapshots/index' +import { Route as AuthEc2securityGroupsDescribeSecurityGroupsIndexRouteImport } from './routes/_auth/ec2/(security-groups)/describe-security-groups/index' import { Route as AuthEc2placementGroupsDescribePlacementGroupsIndexRouteImport } from './routes/_auth/ec2/(placement-groups)/describe-placement-groups/index' import { Route as AuthEc2keyDescribeKeyPairsIndexRouteImport } from './routes/_auth/ec2/(key)/describe-key-pairs/index' import { Route as AuthEc2instancesDescribeInstancesIndexRouteImport } from './routes/_auth/ec2/(instances)/describe-instances/index' @@ -44,6 +46,7 @@ import { Route as AuthEc2volumesModifyVolumeIdRouteImport } from './routes/_auth import { Route as AuthEc2volumesDescribeVolumesIdRouteImport } from './routes/_auth/ec2/(volumes)/describe-volumes/$id' import { Route as AuthEc2subnetDescribeSubnetsIdRouteImport } from './routes/_auth/ec2/(subnet)/describe-subnets/$id' import { Route as AuthEc2snapshotsDescribeSnapshotsIdRouteImport } from './routes/_auth/ec2/(snapshots)/describe-snapshots/$id' +import { Route as AuthEc2securityGroupsDescribeSecurityGroupsIdRouteImport } from './routes/_auth/ec2/(security-groups)/describe-security-groups/$id' import { Route as AuthEc2placementGroupsDescribePlacementGroupsIdRouteImport } from './routes/_auth/ec2/(placement-groups)/describe-placement-groups/$id' import { Route as AuthEc2keyDescribeKeyPairsIdRouteImport } from './routes/_auth/ec2/(key)/describe-key-pairs/$id' import { Route as AuthEc2instancesDescribeInstancesIdRouteImport } from './routes/_auth/ec2/(instances)/describe-instances/$id' @@ -108,6 +111,12 @@ const AuthEc2snapshotsCreateSnapshotRoute = path: '/ec2/create-snapshot', getParentRoute: () => AuthRouteRoute, } as any) +const AuthEc2securityGroupsCreateSecurityGroupRoute = + AuthEc2securityGroupsCreateSecurityGroupRouteImport.update({ + id: '/ec2/(security-groups)/create-security-group', + path: '/ec2/create-security-group', + getParentRoute: () => AuthRouteRoute, + } as any) const AuthEc2placementGroupsCreatePlacementGroupRoute = AuthEc2placementGroupsCreatePlacementGroupRouteImport.update({ id: '/ec2/(placement-groups)/create-placement-group', @@ -176,6 +185,12 @@ const AuthEc2snapshotsDescribeSnapshotsIndexRoute = path: '/ec2/describe-snapshots/', getParentRoute: () => AuthRouteRoute, } as any) +const AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute = + AuthEc2securityGroupsDescribeSecurityGroupsIndexRouteImport.update({ + id: '/ec2/(security-groups)/describe-security-groups/', + path: '/ec2/describe-security-groups/', + getParentRoute: () => AuthRouteRoute, + } as any) const AuthEc2placementGroupsDescribePlacementGroupsIndexRoute = AuthEc2placementGroupsDescribePlacementGroupsIndexRouteImport.update({ id: '/ec2/(placement-groups)/describe-placement-groups/', @@ -247,6 +262,12 @@ const AuthEc2snapshotsDescribeSnapshotsIdRoute = path: '/ec2/describe-snapshots/$id', getParentRoute: () => AuthRouteRoute, } as any) +const AuthEc2securityGroupsDescribeSecurityGroupsIdRoute = + AuthEc2securityGroupsDescribeSecurityGroupsIdRouteImport.update({ + id: '/ec2/(security-groups)/describe-security-groups/$id', + path: '/ec2/describe-security-groups/$id', + getParentRoute: () => AuthRouteRoute, + } as any) const AuthEc2placementGroupsDescribePlacementGroupsIdRoute = AuthEc2placementGroupsDescribePlacementGroupsIdRouteImport.update({ id: '/ec2/(placement-groups)/describe-placement-groups/$id', @@ -280,6 +301,7 @@ export interface FileRoutesByFullPath { '/ec2/create-key-pair': typeof AuthEc2keyCreateKeyPairRoute '/ec2/import-key-pair': typeof AuthEc2keyImportKeyPairRoute '/ec2/create-placement-group': typeof AuthEc2placementGroupsCreatePlacementGroupRoute + '/ec2/create-security-group': typeof AuthEc2securityGroupsCreateSecurityGroupRoute '/ec2/create-snapshot': typeof AuthEc2snapshotsCreateSnapshotRoute '/ec2/create-subnet': typeof AuthEc2subnetCreateSubnetRoute '/ec2/create-volume': typeof AuthEc2volumesCreateVolumeRoute @@ -292,6 +314,7 @@ export interface FileRoutesByFullPath { '/ec2/describe-instances/$id': typeof AuthEc2instancesDescribeInstancesIdRoute '/ec2/describe-key-pairs/$id': typeof AuthEc2keyDescribeKeyPairsIdRoute '/ec2/describe-placement-groups/$id': typeof AuthEc2placementGroupsDescribePlacementGroupsIdRoute + '/ec2/describe-security-groups/$id': typeof AuthEc2securityGroupsDescribeSecurityGroupsIdRoute '/ec2/describe-snapshots/$id': typeof AuthEc2snapshotsDescribeSnapshotsIdRoute '/ec2/describe-subnets/$id': typeof AuthEc2subnetDescribeSubnetsIdRoute '/ec2/describe-volumes/$id': typeof AuthEc2volumesDescribeVolumesIdRoute @@ -304,6 +327,7 @@ export interface FileRoutesByFullPath { '/ec2/describe-instances/': typeof AuthEc2instancesDescribeInstancesIndexRoute '/ec2/describe-key-pairs/': typeof AuthEc2keyDescribeKeyPairsIndexRoute '/ec2/describe-placement-groups/': typeof AuthEc2placementGroupsDescribePlacementGroupsIndexRoute + '/ec2/describe-security-groups/': typeof AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute '/ec2/describe-snapshots/': typeof AuthEc2snapshotsDescribeSnapshotsIndexRoute '/ec2/describe-subnets/': typeof AuthEc2subnetDescribeSubnetsIndexRoute '/ec2/describe-volumes/': typeof AuthEc2volumesDescribeVolumesIndexRoute @@ -319,6 +343,7 @@ export interface FileRoutesByTo { '/ec2/create-key-pair': typeof AuthEc2keyCreateKeyPairRoute '/ec2/import-key-pair': typeof AuthEc2keyImportKeyPairRoute '/ec2/create-placement-group': typeof AuthEc2placementGroupsCreatePlacementGroupRoute + '/ec2/create-security-group': typeof AuthEc2securityGroupsCreateSecurityGroupRoute '/ec2/create-snapshot': typeof AuthEc2snapshotsCreateSnapshotRoute '/ec2/create-subnet': typeof AuthEc2subnetCreateSubnetRoute '/ec2/create-volume': typeof AuthEc2volumesCreateVolumeRoute @@ -331,6 +356,7 @@ export interface FileRoutesByTo { '/ec2/describe-instances/$id': typeof AuthEc2instancesDescribeInstancesIdRoute '/ec2/describe-key-pairs/$id': typeof AuthEc2keyDescribeKeyPairsIdRoute '/ec2/describe-placement-groups/$id': typeof AuthEc2placementGroupsDescribePlacementGroupsIdRoute + '/ec2/describe-security-groups/$id': typeof AuthEc2securityGroupsDescribeSecurityGroupsIdRoute '/ec2/describe-snapshots/$id': typeof AuthEc2snapshotsDescribeSnapshotsIdRoute '/ec2/describe-subnets/$id': typeof AuthEc2subnetDescribeSubnetsIdRoute '/ec2/describe-volumes/$id': typeof AuthEc2volumesDescribeVolumesIdRoute @@ -343,6 +369,7 @@ export interface FileRoutesByTo { '/ec2/describe-instances': typeof AuthEc2instancesDescribeInstancesIndexRoute '/ec2/describe-key-pairs': typeof AuthEc2keyDescribeKeyPairsIndexRoute '/ec2/describe-placement-groups': typeof AuthEc2placementGroupsDescribePlacementGroupsIndexRoute + '/ec2/describe-security-groups': typeof AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute '/ec2/describe-snapshots': typeof AuthEc2snapshotsDescribeSnapshotsIndexRoute '/ec2/describe-subnets': typeof AuthEc2subnetDescribeSubnetsIndexRoute '/ec2/describe-volumes': typeof AuthEc2volumesDescribeVolumesIndexRoute @@ -361,6 +388,7 @@ export interface FileRoutesById { '/_auth/ec2/(key)/create-key-pair': typeof AuthEc2keyCreateKeyPairRoute '/_auth/ec2/(key)/import-key-pair': typeof AuthEc2keyImportKeyPairRoute '/_auth/ec2/(placement-groups)/create-placement-group': typeof AuthEc2placementGroupsCreatePlacementGroupRoute + '/_auth/ec2/(security-groups)/create-security-group': typeof AuthEc2securityGroupsCreateSecurityGroupRoute '/_auth/ec2/(snapshots)/create-snapshot': typeof AuthEc2snapshotsCreateSnapshotRoute '/_auth/ec2/(subnet)/create-subnet': typeof AuthEc2subnetCreateSubnetRoute '/_auth/ec2/(volumes)/create-volume': typeof AuthEc2volumesCreateVolumeRoute @@ -373,6 +401,7 @@ export interface FileRoutesById { '/_auth/ec2/(instances)/describe-instances/$id': typeof AuthEc2instancesDescribeInstancesIdRoute '/_auth/ec2/(key)/describe-key-pairs/$id': typeof AuthEc2keyDescribeKeyPairsIdRoute '/_auth/ec2/(placement-groups)/describe-placement-groups/$id': typeof AuthEc2placementGroupsDescribePlacementGroupsIdRoute + '/_auth/ec2/(security-groups)/describe-security-groups/$id': typeof AuthEc2securityGroupsDescribeSecurityGroupsIdRoute '/_auth/ec2/(snapshots)/describe-snapshots/$id': typeof AuthEc2snapshotsDescribeSnapshotsIdRoute '/_auth/ec2/(subnet)/describe-subnets/$id': typeof AuthEc2subnetDescribeSubnetsIdRoute '/_auth/ec2/(volumes)/describe-volumes/$id': typeof AuthEc2volumesDescribeVolumesIdRoute @@ -385,6 +414,7 @@ export interface FileRoutesById { '/_auth/ec2/(instances)/describe-instances/': typeof AuthEc2instancesDescribeInstancesIndexRoute '/_auth/ec2/(key)/describe-key-pairs/': typeof AuthEc2keyDescribeKeyPairsIndexRoute '/_auth/ec2/(placement-groups)/describe-placement-groups/': typeof AuthEc2placementGroupsDescribePlacementGroupsIndexRoute + '/_auth/ec2/(security-groups)/describe-security-groups/': typeof AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute '/_auth/ec2/(snapshots)/describe-snapshots/': typeof AuthEc2snapshotsDescribeSnapshotsIndexRoute '/_auth/ec2/(subnet)/describe-subnets/': typeof AuthEc2subnetDescribeSubnetsIndexRoute '/_auth/ec2/(volumes)/describe-volumes/': typeof AuthEc2volumesDescribeVolumesIndexRoute @@ -403,6 +433,7 @@ export interface FileRouteTypes { | '/ec2/create-key-pair' | '/ec2/import-key-pair' | '/ec2/create-placement-group' + | '/ec2/create-security-group' | '/ec2/create-snapshot' | '/ec2/create-subnet' | '/ec2/create-volume' @@ -415,6 +446,7 @@ export interface FileRouteTypes { | '/ec2/describe-instances/$id' | '/ec2/describe-key-pairs/$id' | '/ec2/describe-placement-groups/$id' + | '/ec2/describe-security-groups/$id' | '/ec2/describe-snapshots/$id' | '/ec2/describe-subnets/$id' | '/ec2/describe-volumes/$id' @@ -427,6 +459,7 @@ export interface FileRouteTypes { | '/ec2/describe-instances/' | '/ec2/describe-key-pairs/' | '/ec2/describe-placement-groups/' + | '/ec2/describe-security-groups/' | '/ec2/describe-snapshots/' | '/ec2/describe-subnets/' | '/ec2/describe-volumes/' @@ -442,6 +475,7 @@ export interface FileRouteTypes { | '/ec2/create-key-pair' | '/ec2/import-key-pair' | '/ec2/create-placement-group' + | '/ec2/create-security-group' | '/ec2/create-snapshot' | '/ec2/create-subnet' | '/ec2/create-volume' @@ -454,6 +488,7 @@ export interface FileRouteTypes { | '/ec2/describe-instances/$id' | '/ec2/describe-key-pairs/$id' | '/ec2/describe-placement-groups/$id' + | '/ec2/describe-security-groups/$id' | '/ec2/describe-snapshots/$id' | '/ec2/describe-subnets/$id' | '/ec2/describe-volumes/$id' @@ -466,6 +501,7 @@ export interface FileRouteTypes { | '/ec2/describe-instances' | '/ec2/describe-key-pairs' | '/ec2/describe-placement-groups' + | '/ec2/describe-security-groups' | '/ec2/describe-snapshots' | '/ec2/describe-subnets' | '/ec2/describe-volumes' @@ -483,6 +519,7 @@ export interface FileRouteTypes { | '/_auth/ec2/(key)/create-key-pair' | '/_auth/ec2/(key)/import-key-pair' | '/_auth/ec2/(placement-groups)/create-placement-group' + | '/_auth/ec2/(security-groups)/create-security-group' | '/_auth/ec2/(snapshots)/create-snapshot' | '/_auth/ec2/(subnet)/create-subnet' | '/_auth/ec2/(volumes)/create-volume' @@ -495,6 +532,7 @@ export interface FileRouteTypes { | '/_auth/ec2/(instances)/describe-instances/$id' | '/_auth/ec2/(key)/describe-key-pairs/$id' | '/_auth/ec2/(placement-groups)/describe-placement-groups/$id' + | '/_auth/ec2/(security-groups)/describe-security-groups/$id' | '/_auth/ec2/(snapshots)/describe-snapshots/$id' | '/_auth/ec2/(subnet)/describe-subnets/$id' | '/_auth/ec2/(volumes)/describe-volumes/$id' @@ -507,6 +545,7 @@ export interface FileRouteTypes { | '/_auth/ec2/(instances)/describe-instances/' | '/_auth/ec2/(key)/describe-key-pairs/' | '/_auth/ec2/(placement-groups)/describe-placement-groups/' + | '/_auth/ec2/(security-groups)/describe-security-groups/' | '/_auth/ec2/(snapshots)/describe-snapshots/' | '/_auth/ec2/(subnet)/describe-subnets/' | '/_auth/ec2/(volumes)/describe-volumes/' @@ -600,6 +639,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthEc2snapshotsCreateSnapshotRouteImport parentRoute: typeof AuthRouteRoute } + '/_auth/ec2/(security-groups)/create-security-group': { + id: '/_auth/ec2/(security-groups)/create-security-group' + path: '/ec2/create-security-group' + fullPath: '/ec2/create-security-group' + preLoaderRoute: typeof AuthEc2securityGroupsCreateSecurityGroupRouteImport + parentRoute: typeof AuthRouteRoute + } '/_auth/ec2/(placement-groups)/create-placement-group': { id: '/_auth/ec2/(placement-groups)/create-placement-group' path: '/ec2/create-placement-group' @@ -684,6 +730,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthEc2snapshotsDescribeSnapshotsIndexRouteImport parentRoute: typeof AuthRouteRoute } + '/_auth/ec2/(security-groups)/describe-security-groups/': { + id: '/_auth/ec2/(security-groups)/describe-security-groups/' + path: '/ec2/describe-security-groups' + fullPath: '/ec2/describe-security-groups/' + preLoaderRoute: typeof AuthEc2securityGroupsDescribeSecurityGroupsIndexRouteImport + parentRoute: typeof AuthRouteRoute + } '/_auth/ec2/(placement-groups)/describe-placement-groups/': { id: '/_auth/ec2/(placement-groups)/describe-placement-groups/' path: '/ec2/describe-placement-groups' @@ -768,6 +821,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthEc2snapshotsDescribeSnapshotsIdRouteImport parentRoute: typeof AuthRouteRoute } + '/_auth/ec2/(security-groups)/describe-security-groups/$id': { + id: '/_auth/ec2/(security-groups)/describe-security-groups/$id' + path: '/ec2/describe-security-groups/$id' + fullPath: '/ec2/describe-security-groups/$id' + preLoaderRoute: typeof AuthEc2securityGroupsDescribeSecurityGroupsIdRouteImport + parentRoute: typeof AuthRouteRoute + } '/_auth/ec2/(placement-groups)/describe-placement-groups/$id': { id: '/_auth/ec2/(placement-groups)/describe-placement-groups/$id' path: '/ec2/describe-placement-groups/$id' @@ -819,6 +879,7 @@ interface AuthRouteRouteChildren { AuthEc2keyCreateKeyPairRoute: typeof AuthEc2keyCreateKeyPairRoute AuthEc2keyImportKeyPairRoute: typeof AuthEc2keyImportKeyPairRoute AuthEc2placementGroupsCreatePlacementGroupRoute: typeof AuthEc2placementGroupsCreatePlacementGroupRoute + AuthEc2securityGroupsCreateSecurityGroupRoute: typeof AuthEc2securityGroupsCreateSecurityGroupRoute AuthEc2snapshotsCreateSnapshotRoute: typeof AuthEc2snapshotsCreateSnapshotRoute AuthEc2subnetCreateSubnetRoute: typeof AuthEc2subnetCreateSubnetRoute AuthEc2volumesCreateVolumeRoute: typeof AuthEc2volumesCreateVolumeRoute @@ -831,6 +892,7 @@ interface AuthRouteRouteChildren { AuthEc2instancesDescribeInstancesIdRoute: typeof AuthEc2instancesDescribeInstancesIdRoute AuthEc2keyDescribeKeyPairsIdRoute: typeof AuthEc2keyDescribeKeyPairsIdRoute AuthEc2placementGroupsDescribePlacementGroupsIdRoute: typeof AuthEc2placementGroupsDescribePlacementGroupsIdRoute + AuthEc2securityGroupsDescribeSecurityGroupsIdRoute: typeof AuthEc2securityGroupsDescribeSecurityGroupsIdRoute AuthEc2snapshotsDescribeSnapshotsIdRoute: typeof AuthEc2snapshotsDescribeSnapshotsIdRoute AuthEc2subnetDescribeSubnetsIdRoute: typeof AuthEc2subnetDescribeSubnetsIdRoute AuthEc2volumesDescribeVolumesIdRoute: typeof AuthEc2volumesDescribeVolumesIdRoute @@ -842,6 +904,7 @@ interface AuthRouteRouteChildren { AuthEc2instancesDescribeInstancesIndexRoute: typeof AuthEc2instancesDescribeInstancesIndexRoute AuthEc2keyDescribeKeyPairsIndexRoute: typeof AuthEc2keyDescribeKeyPairsIndexRoute AuthEc2placementGroupsDescribePlacementGroupsIndexRoute: typeof AuthEc2placementGroupsDescribePlacementGroupsIndexRoute + AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute: typeof AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute AuthEc2snapshotsDescribeSnapshotsIndexRoute: typeof AuthEc2snapshotsDescribeSnapshotsIndexRoute AuthEc2subnetDescribeSubnetsIndexRoute: typeof AuthEc2subnetDescribeSubnetsIndexRoute AuthEc2volumesDescribeVolumesIndexRoute: typeof AuthEc2volumesDescribeVolumesIndexRoute @@ -858,6 +921,8 @@ const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthEc2keyImportKeyPairRoute: AuthEc2keyImportKeyPairRoute, AuthEc2placementGroupsCreatePlacementGroupRoute: AuthEc2placementGroupsCreatePlacementGroupRoute, + AuthEc2securityGroupsCreateSecurityGroupRoute: + AuthEc2securityGroupsCreateSecurityGroupRoute, AuthEc2snapshotsCreateSnapshotRoute: AuthEc2snapshotsCreateSnapshotRoute, AuthEc2subnetCreateSubnetRoute: AuthEc2subnetCreateSubnetRoute, AuthEc2volumesCreateVolumeRoute: AuthEc2volumesCreateVolumeRoute, @@ -872,6 +937,8 @@ const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthEc2keyDescribeKeyPairsIdRoute: AuthEc2keyDescribeKeyPairsIdRoute, AuthEc2placementGroupsDescribePlacementGroupsIdRoute: AuthEc2placementGroupsDescribePlacementGroupsIdRoute, + AuthEc2securityGroupsDescribeSecurityGroupsIdRoute: + AuthEc2securityGroupsDescribeSecurityGroupsIdRoute, AuthEc2snapshotsDescribeSnapshotsIdRoute: AuthEc2snapshotsDescribeSnapshotsIdRoute, AuthEc2subnetDescribeSubnetsIdRoute: AuthEc2subnetDescribeSubnetsIdRoute, @@ -887,6 +954,8 @@ const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthEc2keyDescribeKeyPairsIndexRoute: AuthEc2keyDescribeKeyPairsIndexRoute, AuthEc2placementGroupsDescribePlacementGroupsIndexRoute: AuthEc2placementGroupsDescribePlacementGroupsIndexRoute, + AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute: + AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute, AuthEc2snapshotsDescribeSnapshotsIndexRoute: AuthEc2snapshotsDescribeSnapshotsIndexRoute, AuthEc2subnetDescribeSubnetsIndexRoute: diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/create-security-group.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/create-security-group.tsx new file mode 100644 index 000000000..2cdad26f1 --- /dev/null +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/create-security-group.tsx @@ -0,0 +1,166 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useSuspenseQuery } from "@tanstack/react-query" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { Controller, useForm } from "react-hook-form" + +import { BackLink } from "@/components/back-link" +import { ErrorBanner } from "@/components/error-banner" +import { FormActions } from "@/components/form-actions" +import { PageHeading } from "@/components/page-heading" +import { Field, FieldError, FieldTitle } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { getNameTag } from "@/lib/utils" +import { useCreateSecurityGroup } from "@/mutations/ec2" +import { ec2VpcsQueryOptions } from "@/queries/ec2" +import { + type CreateSecurityGroupFormData, + createSecurityGroupSchema, +} from "@/types/ec2" + +export const Route = createFileRoute( + "/_auth/ec2/(security-groups)/create-security-group", +)({ + loader: async ({ context }) => { + await context.queryClient.ensureQueryData(ec2VpcsQueryOptions) + }, + head: () => ({ + meta: [ + { + title: "Create Security Group | EC2 | Mulga", + }, + ], + }), + component: CreateSecurityGroup, +}) + +function CreateSecurityGroup() { + const navigate = useNavigate() + const { data: vpcsData } = useSuspenseQuery(ec2VpcsQueryOptions) + const createMutation = useCreateSecurityGroup() + + const vpcs = vpcsData.Vpcs ?? [] + + const { + control, + handleSubmit, + register, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(createSecurityGroupSchema), + defaultValues: { + groupName: "", + description: "", + vpcId: vpcs[0]?.VpcId ?? "", + }, + }) + + const onSubmit = async (data: CreateSecurityGroupFormData) => { + const result = await createMutation.mutateAsync(data) + const groupId = result.GroupId + if (groupId) { + navigate({ + to: "/ec2/describe-security-groups/$id", + params: { id: groupId }, + }) + } else { + navigate({ to: "/ec2/describe-security-groups" }) + } + } + + return ( + <> + + Back to Security Groups + + + + + {createMutation.error && ( + + )} + +
+ + + + + + + + + + + + + + + + + + + + + ( + + )} + /> + + + + navigate({ to: "/ec2/describe-security-groups" })} + pendingLabel="Creating\u2026" + submitLabel="Create Security Group" + /> + + + ) +} diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/describe-security-groups/$id.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/describe-security-groups/$id.tsx new file mode 100644 index 000000000..5ef80991c --- /dev/null +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/describe-security-groups/$id.tsx @@ -0,0 +1,507 @@ +import type { IpPermission } from "@aws-sdk/client-ec2" +import { zodResolver } from "@hookform/resolvers/zod" +import { useSuspenseQuery } from "@tanstack/react-query" +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import { Plus, Trash2, X } from "lucide-react" +import { useState } from "react" +import { Controller, useForm } from "react-hook-form" + +import { BackLink } from "@/components/back-link" +import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog" +import { DetailCard } from "@/components/detail-card" +import { DetailRow } from "@/components/detail-row" +import { ErrorBanner } from "@/components/error-banner" +import { PageHeading } from "@/components/page-heading" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { Field, FieldError, FieldTitle } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + useAuthorizeSecurityGroupEgress, + useAuthorizeSecurityGroupIngress, + useDeleteSecurityGroup, + useRevokeSecurityGroupEgress, + useRevokeSecurityGroupIngress, +} from "@/mutations/ec2" +import { ec2SecurityGroupQueryOptions } from "@/queries/ec2" +import { + type SecurityGroupRuleFormData, + securityGroupRuleSchema, +} from "@/types/ec2" + +export const Route = createFileRoute( + "/_auth/ec2/(security-groups)/describe-security-groups/$id", +)({ + loader: async ({ context, params }) => { + await context.queryClient.ensureQueryData( + ec2SecurityGroupQueryOptions(params.id), + ) + }, + head: ({ params }) => ({ + meta: [ + { + title: `${params.id} | Security Group | Mulga`, + }, + ], + }), + component: SecurityGroupDetail, +}) + +function formatProtocol(protocol: string | undefined): string { + if (!protocol || protocol === "-1") { + return "All traffic" + } + const upper = protocol.toUpperCase() + if (upper === "TCP" || upper === "UDP" || upper === "ICMP") { + return upper + } + return protocol +} + +function formatPortRange( + fromPort: number | undefined, + toPort: number | undefined, + protocol: string | undefined, +): string { + if (!protocol || protocol === "-1") { + return "All" + } + if (fromPort === undefined || toPort === undefined) { + return "All" + } + if (fromPort === -1 && toPort === -1) { + return "All" + } + if (fromPort === toPort) { + return fromPort.toString() + } + return `${fromPort}-${toPort}` +} + +interface AddRuleDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + direction: "ingress" | "egress" + groupId: string +} + +function AddRuleDialog({ + open, + onOpenChange, + direction, + groupId, +}: AddRuleDialogProps) { + const authorizeIngress = useAuthorizeSecurityGroupIngress() + const authorizeEgress = useAuthorizeSecurityGroupEgress() + const mutation = direction === "ingress" ? authorizeIngress : authorizeEgress + + const { + control, + handleSubmit, + register, + reset, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(securityGroupRuleSchema), + defaultValues: { + ipProtocol: "tcp", + fromPort: 0, + toPort: 0, + cidrIp: "0.0.0.0/0", + }, + }) + + const selectedProtocol = watch("ipProtocol") + const portsDisabled = selectedProtocol === "-1" || selectedProtocol === "icmp" + + const onSubmit = async (data: SecurityGroupRuleFormData) => { + const params = { + groupId, + ipProtocol: data.ipProtocol, + fromPort: portsDisabled ? -1 : data.fromPort, + toPort: portsDisabled ? -1 : data.toPort, + cidrIp: data.cidrIp, + } + await mutation.mutateAsync(params) + reset() + onOpenChange(false) + } + + return ( + + + + + Add {direction === "ingress" ? "Inbound" : "Outbound"} Rule + + + + {mutation.error && ( + + )} + +
+ + + + + ( + + )} + /> + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + + { + reset() + mutation.reset() + }} + > + Cancel + + + {mutation.isPending ? "Adding\u2026" : "Add Rule"} + + +
+
+ ) +} + +interface RuleRowProps { + rule: IpPermission + cidr: string + groupId: string + direction: "ingress" | "egress" +} + +function RuleRow({ rule, cidr, groupId, direction }: RuleRowProps) { + const revokeIngress = useRevokeSecurityGroupIngress() + const revokeEgress = useRevokeSecurityGroupEgress() + const mutation = direction === "ingress" ? revokeIngress : revokeEgress + + const handleRemove = async () => { + try { + await mutation.mutateAsync({ + groupId, + ipProtocol: rule.IpProtocol ?? "-1", + fromPort: rule.FromPort ?? -1, + toPort: rule.ToPort ?? -1, + cidrIp: cidr, + }) + } catch { + // Error is stored in mutation.error and rendered below + } + } + + return ( +
+
+
+
+ Protocol: + {formatProtocol(rule.IpProtocol)} +
+
+ Port Range: + {formatPortRange(rule.FromPort, rule.ToPort, rule.IpProtocol)} +
+
+ + {direction === "ingress" ? "Source" : "Destination"}:{" "} + + {cidr} +
+
+ +
+ {mutation.error && ( +
+ +
+ )} +
+ ) +} + +function SecurityGroupDetail() { + const { id } = Route.useParams() + const navigate = useNavigate() + const { data } = useSuspenseQuery(ec2SecurityGroupQueryOptions(id)) + const sg = data.SecurityGroups?.[0] + const deleteMutation = useDeleteSecurityGroup() + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [showIngressDialog, setShowIngressDialog] = useState(false) + const [showEgressDialog, setShowEgressDialog] = useState(false) + + const handleDelete = async () => { + try { + await deleteMutation.mutateAsync(id) + navigate({ to: "/ec2/describe-security-groups" }) + } finally { + setShowDeleteDialog(false) + } + } + + if (!sg?.GroupId) { + return ( + <> + + Back to Security Groups + +

Security group not found.

+ + ) + } + + const ingressRules = sg.IpPermissions ?? [] + const egressRules = sg.IpPermissionsEgress ?? [] + + return ( + <> + + Back to Security Groups + + + {deleteMutation.error && ( + + )} + +
+ + +
+ } + subtitle="Security Group Details" + title={sg.GroupName ? `${sg.GroupId} (${sg.GroupName})` : sg.GroupId} + /> + + + Security Group Information + + + + + + {sg.VpcId} + + ) : ( + "\u2014" + ) + } + /> + + + + + +
+

+ Inbound Rules ({ingressRules.length}) +

+ +
+ {ingressRules.length > 0 ? ( +
+ {ingressRules.map((rule) => + (rule.IpRanges ?? []).map((range) => ( + + )), + )} +
+ ) : ( +

+ No inbound rules. +

+ )} +
+ + +
+

+ Outbound Rules ({egressRules.length}) +

+ +
+ {egressRules.length > 0 ? ( +
+ {egressRules.map((rule) => + (rule.IpRanges ?? []).map((range) => ( + + )), + )} +
+ ) : ( +

+ No outbound rules. +

+ )} +
+ + + + + + + + + ) +} diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/describe-security-groups/index.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/describe-security-groups/index.tsx new file mode 100644 index 000000000..6b522782b --- /dev/null +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(security-groups)/describe-security-groups/index.tsx @@ -0,0 +1,66 @@ +import type { SecurityGroup } from "@aws-sdk/client-ec2" +import { useSuspenseQuery } from "@tanstack/react-query" +import { createFileRoute, Link } from "@tanstack/react-router" + +import { ListCard } from "@/components/list-card" +import { PageHeading } from "@/components/page-heading" +import { Button } from "@/components/ui/button" +import { ec2SecurityGroupsQueryOptions } from "@/queries/ec2" + +export const Route = createFileRoute( + "/_auth/ec2/(security-groups)/describe-security-groups/", +)({ + loader: async ({ context }) => { + await context.queryClient.ensureQueryData(ec2SecurityGroupsQueryOptions) + }, + head: () => ({ + meta: [ + { + title: "Security Groups | EC2 | Mulga", + }, + ], + }), + component: SecurityGroups, +}) + +function SecurityGroups() { + const { data } = useSuspenseQuery(ec2SecurityGroupsQueryOptions) + + const securityGroups = data.SecurityGroups ?? [] + + return ( + <> + + + + } + title="Security Groups" + /> + + {securityGroups.length > 0 ? ( +
+ {securityGroups.map((sg: SecurityGroup) => { + if (!sg.GroupId) { + return null + } + return ( + + ) + })} +
+ ) : ( +

No security groups found.

+ )} + + ) +} diff --git a/spinifex/services/spinifexui/frontend/src/types/ec2.test.ts b/spinifex/services/spinifexui/frontend/src/types/ec2.test.ts index ae79c07d0..32ec939ed 100644 --- a/spinifex/services/spinifexui/frontend/src/types/ec2.test.ts +++ b/spinifex/services/spinifexui/frontend/src/types/ec2.test.ts @@ -6,6 +6,7 @@ import { createInstanceSchema, createKeyPairSchema, createPlacementGroupSchema, + createSecurityGroupSchema, createSnapshotSchema, createSubnetSchema, createVolumeSchema, @@ -14,6 +15,7 @@ import { formTagSchema, importKeyPairSchema, modifyVolumeSchema, + securityGroupRuleSchema, } from "./ec2" describe("createInstanceSchema", () => { @@ -343,6 +345,163 @@ describe("formTagSchema", () => { }) }) +describe("createSecurityGroupSchema", () => { + it("accepts valid security group params", () => { + const result = createSecurityGroupSchema.safeParse({ + groupName: "web-sg", + description: "Allow web traffic", + vpcId: "vpc-123", + }) + expect(result.success).toBe(true) + }) + + it("rejects empty group name", () => { + const result = createSecurityGroupSchema.safeParse({ + groupName: "", + description: "Allow web traffic", + vpcId: "vpc-123", + }) + expect(result.success).toBe(false) + }) + + it("rejects group name over 255 chars", () => { + const result = createSecurityGroupSchema.safeParse({ + groupName: "a".repeat(256), + description: "Allow web traffic", + vpcId: "vpc-123", + }) + expect(result.success).toBe(false) + }) + + it("accepts group name at 255 chars", () => { + const result = createSecurityGroupSchema.safeParse({ + groupName: "a".repeat(255), + description: "Allow web traffic", + vpcId: "vpc-123", + }) + expect(result.success).toBe(true) + }) + + it("rejects empty description", () => { + const result = createSecurityGroupSchema.safeParse({ + groupName: "web-sg", + description: "", + vpcId: "vpc-123", + }) + expect(result.success).toBe(false) + }) + + it("rejects description over 255 chars", () => { + const result = createSecurityGroupSchema.safeParse({ + groupName: "web-sg", + description: "a".repeat(256), + vpcId: "vpc-123", + }) + expect(result.success).toBe(false) + }) + + it("rejects empty vpcId", () => { + const result = createSecurityGroupSchema.safeParse({ + groupName: "web-sg", + description: "Allow web traffic", + vpcId: "", + }) + expect(result.success).toBe(false) + }) +}) + +describe("securityGroupRuleSchema", () => { + it("accepts valid TCP rule", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "tcp", + fromPort: 443, + toPort: 443, + cidrIp: "0.0.0.0/0", + }) + expect(result.success).toBe(true) + }) + + it("accepts all-traffic protocol with port -1", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "-1", + fromPort: -1, + toPort: -1, + cidrIp: "10.0.0.0/16", + }) + expect(result.success).toBe(true) + }) + + it("accepts port 65535 (upper boundary)", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "tcp", + fromPort: 65_535, + toPort: 65_535, + cidrIp: "0.0.0.0/0", + }) + expect(result.success).toBe(true) + }) + + it("rejects port 65536 (above boundary)", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "tcp", + fromPort: 65_536, + toPort: 443, + cidrIp: "0.0.0.0/0", + }) + expect(result.success).toBe(false) + }) + + it("rejects port -2 (below boundary)", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "tcp", + fromPort: -2, + toPort: 443, + cidrIp: "0.0.0.0/0", + }) + expect(result.success).toBe(false) + }) + + it("rejects fractional port", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "tcp", + fromPort: 22.5, + toPort: 443, + cidrIp: "0.0.0.0/0", + }) + expect(result.success).toBe(false) + }) + + it("rejects invalid CIDR format", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "tcp", + fromPort: 22, + toPort: 22, + cidrIp: "not-a-cidr", + }) + expect(result.success).toBe(false) + }) + + it("rejects empty CIDR", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "tcp", + fromPort: 22, + toPort: 22, + cidrIp: "", + }) + expect(result.success).toBe(false) + }) + + it("rejects empty protocol", () => { + const result = securityGroupRuleSchema.safeParse({ + ipProtocol: "", + fromPort: 22, + toPort: 22, + cidrIp: "0.0.0.0/0", + }) + expect(result.success).toBe(false) + }) +}) + describe("createVpcWizardSchema", () => { const validBase = { mode: "vpc-only" as const, diff --git a/spinifex/services/spinifexui/frontend/src/types/ec2.ts b/spinifex/services/spinifexui/frontend/src/types/ec2.ts index ab1354efa..9b85857d4 100644 --- a/spinifex/services/spinifexui/frontend/src/types/ec2.ts +++ b/spinifex/services/spinifexui/frontend/src/types/ec2.ts @@ -239,3 +239,31 @@ export const createPlacementGroupSchema = z.object({ export type CreatePlacementGroupFormData = z.infer< typeof createPlacementGroupSchema > + +export const createSecurityGroupSchema = z.object({ + groupName: z + .string() + .min(1, "Group name is required") + .max(255, "Group name must be 255 characters or less"), + description: z + .string() + .min(1, "Description is required") + .max(255, "Description must be 255 characters or less"), + vpcId: z.string().min(1, "VPC is required"), +}) + +export type CreateSecurityGroupFormData = z.infer< + typeof createSecurityGroupSchema +> + +export const securityGroupRuleSchema = z.object({ + ipProtocol: z.string().min(1, "Protocol is required"), + fromPort: z.number().int().min(-1).max(65_535), + toPort: z.number().int().min(-1).max(65_535), + cidrIp: z + .string() + .min(1, "CIDR is required") + .regex(CIDR_REGEX, "Must be a valid CIDR block (e.g. 0.0.0.0/0)"), +}) + +export type SecurityGroupRuleFormData = z.infer