feat: security groups frontend (#97)

This commit is contained in:
Julian Sommer
2026-03-27 15:56:00 +10:00
committed by GitHub
parent 94a80a3be7
commit 5d194098ca
12 changed files with 1447 additions and 2 deletions
@@ -12,6 +12,7 @@ import {
Network,
Server,
Shield,
ShieldCheck,
Users,
} from "lucide-react"
@@ -197,6 +198,21 @@ export function SidebarLayout() {
</Link>
</SidebarMenuItem>
<SidebarMenuItem>
<Link to="/ec2/describe-security-groups">
<SidebarMenuButton
isActive={
pathname.startsWith("/ec2/describe-security-groups") ||
pathname.startsWith("/ec2/create-security-group")
}
tooltip="Security Groups"
>
<ShieldCheck className="size-4" />
<span>Security Groups</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
{/* uncomment when route pages are created
<SidebarMenuItem>
<Link to="/ec2/describe-route-tables">
@@ -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")
})
})
@@ -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",
@@ -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) {
@@ -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<unknown>
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<unknown>
await queryFn({} as never)
expect(mockSend.mock.calls[0]?.[0].input).toEqual({
GroupIds: ["sg-123"],
})
})
})
@@ -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: () => {
@@ -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:
@@ -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<CreateSecurityGroupFormData>({
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 (
<>
<BackLink to="/ec2/describe-security-groups">
Back to Security Groups
</BackLink>
<PageHeading title="Create Security Group" />
{createMutation.error && (
<ErrorBanner
error={createMutation.error}
msg="Failed to create security group"
/>
)}
<form className="max-w-4xl space-y-6" onSubmit={handleSubmit(onSubmit)}>
<Field>
<FieldTitle>
<label htmlFor="groupName">Group Name</label>
</FieldTitle>
<Input
aria-invalid={!!errors.groupName}
id="groupName"
placeholder="my-security-group"
{...register("groupName")}
/>
<FieldError errors={[errors.groupName]} />
</Field>
<Field>
<FieldTitle>
<label htmlFor="description">Description</label>
</FieldTitle>
<Input
aria-invalid={!!errors.description}
id="description"
placeholder="My security group description"
{...register("description")}
/>
<FieldError errors={[errors.description]} />
</Field>
<Field>
<FieldTitle>
<label htmlFor="vpcId">VPC</label>
</FieldTitle>
<Controller
control={control}
name="vpcId"
render={({ field }) => (
<Select
onValueChange={(value) => field.onChange(value)}
value={field.value ?? ""}
>
<SelectTrigger
aria-invalid={!!errors.vpcId}
className="w-full"
id="vpcId"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{vpcs.map((vpc) => {
const name = getNameTag(vpc.Tags)
return (
<SelectItem key={vpc.VpcId} value={vpc.VpcId ?? ""}>
{name
? `${vpc.VpcId} (${name})`
: `${vpc.VpcId} (${vpc.CidrBlock})`}
</SelectItem>
)
})}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.vpcId]} />
</Field>
<FormActions
isPending={createMutation.isPending}
isSubmitting={isSubmitting}
onCancel={() => navigate({ to: "/ec2/describe-security-groups" })}
pendingLabel="Creating\u2026"
submitLabel="Create Security Group"
/>
</form>
</>
)
}
@@ -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<SecurityGroupRuleFormData>({
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 (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Add {direction === "ingress" ? "Inbound" : "Outbound"} Rule
</AlertDialogTitle>
</AlertDialogHeader>
{mutation.error && (
<ErrorBanner error={mutation.error} msg="Failed to add rule" />
)}
<form
className="space-y-4"
id="add-rule-form"
onSubmit={handleSubmit(onSubmit)}
>
<Field>
<FieldTitle>
<label htmlFor="ipProtocol">Protocol</label>
</FieldTitle>
<Controller
control={control}
name="ipProtocol"
render={({ field }) => (
<Select
onValueChange={(value) => field.onChange(value)}
value={field.value}
>
<SelectTrigger className="w-full" id="ipProtocol">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="-1">All traffic (-1)</SelectItem>
</SelectContent>
</Select>
)}
/>
<FieldError errors={[errors.ipProtocol]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldTitle>
<label htmlFor="fromPort">From Port</label>
</FieldTitle>
<Input
aria-invalid={!!errors.fromPort}
disabled={portsDisabled}
id="fromPort"
type="number"
{...register("fromPort", { valueAsNumber: true })}
/>
<FieldError errors={[errors.fromPort]} />
</Field>
<Field>
<FieldTitle>
<label htmlFor="toPort">To Port</label>
</FieldTitle>
<Input
aria-invalid={!!errors.toPort}
disabled={portsDisabled}
id="toPort"
type="number"
{...register("toPort", { valueAsNumber: true })}
/>
<FieldError errors={[errors.toPort]} />
</Field>
</div>
<Field>
<FieldTitle>
<label htmlFor="cidrIp">
{direction === "ingress" ? "Source" : "Destination"} CIDR
</label>
</FieldTitle>
<Input
aria-invalid={!!errors.cidrIp}
id="cidrIp"
placeholder="0.0.0.0/0"
{...register("cidrIp")}
/>
<FieldError errors={[errors.cidrIp]} />
</Field>
</form>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
reset()
mutation.reset()
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={mutation.isPending}
form="add-rule-form"
type="submit"
>
{mutation.isPending ? "Adding\u2026" : "Add Rule"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
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 (
<div className="space-y-0">
<div className="flex items-center justify-between border-b px-4 py-3 last:border-b-0">
<div className="grid flex-1 grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Protocol: </span>
{formatProtocol(rule.IpProtocol)}
</div>
<div>
<span className="text-muted-foreground">Port Range: </span>
{formatPortRange(rule.FromPort, rule.ToPort, rule.IpProtocol)}
</div>
<div>
<span className="text-muted-foreground">
{direction === "ingress" ? "Source" : "Destination"}:{" "}
</span>
{cidr}
</div>
</div>
<Button
disabled={mutation.isPending}
onClick={handleRemove}
size="icon-xs"
variant="ghost"
>
<X className="size-3" />
</Button>
</div>
{mutation.error && (
<div className="px-4 pb-2">
<ErrorBanner error={mutation.error} msg="Failed to remove rule" />
</div>
)}
</div>
)
}
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 (
<>
<BackLink to="/ec2/describe-security-groups">
Back to Security Groups
</BackLink>
<p className="text-muted-foreground">Security group not found.</p>
</>
)
}
const ingressRules = sg.IpPermissions ?? []
const egressRules = sg.IpPermissionsEgress ?? []
return (
<>
<BackLink to="/ec2/describe-security-groups">
Back to Security Groups
</BackLink>
{deleteMutation.error && (
<ErrorBanner
error={deleteMutation.error}
msg="Failed to delete security group"
/>
)}
<div className="space-y-6">
<PageHeading
actions={
<div className="flex items-center gap-2">
<Button
onClick={() => setShowDeleteDialog(true)}
size="sm"
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</Button>
</div>
}
subtitle="Security Group Details"
title={sg.GroupName ? `${sg.GroupId} (${sg.GroupName})` : sg.GroupId}
/>
<DetailCard>
<DetailCard.Header>Security Group Information</DetailCard.Header>
<DetailCard.Content>
<DetailRow label="Group ID" value={sg.GroupId} />
<DetailRow label="Group Name" value={sg.GroupName} />
<DetailRow label="Description" value={sg.Description} />
<DetailRow
label="VPC ID"
value={
sg.VpcId ? (
<Link
className="text-primary hover:underline"
params={{ id: sg.VpcId }}
to="/ec2/describe-vpcs/$id"
>
{sg.VpcId}
</Link>
) : (
"\u2014"
)
}
/>
<DetailRow label="Owner ID" value={sg.OwnerId} />
</DetailCard.Content>
</DetailCard>
<DetailCard>
<div className="flex items-center justify-between border-b p-4">
<h2 className="font-semibold">
Inbound Rules ({ingressRules.length})
</h2>
<Button
onClick={() => setShowIngressDialog(true)}
size="sm"
variant="outline"
>
<Plus className="size-4" />
Add Rule
</Button>
</div>
{ingressRules.length > 0 ? (
<div>
{ingressRules.map((rule) =>
(rule.IpRanges ?? []).map((range) => (
<RuleRow
cidr={range.CidrIp ?? ""}
direction="ingress"
groupId={sg.GroupId ?? ""}
key={`${rule.IpProtocol}-${rule.FromPort}-${rule.ToPort}-${range.CidrIp}`}
rule={rule}
/>
)),
)}
</div>
) : (
<p className="p-4 text-sm text-muted-foreground">
No inbound rules.
</p>
)}
</DetailCard>
<DetailCard>
<div className="flex items-center justify-between border-b p-4">
<h2 className="font-semibold">
Outbound Rules ({egressRules.length})
</h2>
<Button
onClick={() => setShowEgressDialog(true)}
size="sm"
variant="outline"
>
<Plus className="size-4" />
Add Rule
</Button>
</div>
{egressRules.length > 0 ? (
<div>
{egressRules.map((rule) =>
(rule.IpRanges ?? []).map((range) => (
<RuleRow
cidr={range.CidrIp ?? ""}
direction="egress"
groupId={sg.GroupId ?? ""}
key={`${rule.IpProtocol}-${rule.FromPort}-${rule.ToPort}-${range.CidrIp}`}
rule={rule}
/>
)),
)}
</div>
) : (
<p className="p-4 text-sm text-muted-foreground">
No outbound rules.
</p>
)}
</DetailCard>
</div>
<DeleteConfirmationDialog
description={`Are you sure you want to delete the security group "${sg.GroupId}"? This action cannot be undone.`}
isPending={deleteMutation.isPending}
onConfirm={handleDelete}
onOpenChange={setShowDeleteDialog}
open={showDeleteDialog}
title="Delete Security Group"
/>
<AddRuleDialog
direction="ingress"
groupId={sg.GroupId}
onOpenChange={setShowIngressDialog}
open={showIngressDialog}
/>
<AddRuleDialog
direction="egress"
groupId={sg.GroupId}
onOpenChange={setShowEgressDialog}
open={showEgressDialog}
/>
</>
)
}
@@ -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 (
<>
<PageHeading
actions={
<Link to="/ec2/create-security-group">
<Button>Create Security Group</Button>
</Link>
}
title="Security Groups"
/>
{securityGroups.length > 0 ? (
<div className="space-y-4">
{securityGroups.map((sg: SecurityGroup) => {
if (!sg.GroupId) {
return null
}
return (
<ListCard
key={sg.GroupId}
params={{ id: sg.GroupId }}
subtitle={sg.VpcId ?? ""}
title={
sg.GroupName ? `${sg.GroupId} (${sg.GroupName})` : sg.GroupId
}
to="/ec2/describe-security-groups/$id"
/>
)
})}
</div>
) : (
<p className="text-muted-foreground">No security groups found.</p>
)}
</>
)
}
@@ -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,
@@ -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<typeof securityGroupRuleSchema>