diff --git a/web/src/App.tsx b/web/src/App.tsx index 501f4910..4103d057 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { AnsibleLayout } from './pages/ansible/components/layout'; import DockerAnsible from './pages/ansible/docker/docker'; import NginxAnsible from './pages/ansible/nginx/nginx'; import KubernetesAnsible from './pages/ansible/kuber/kuber'; +import DockerCompose from './pages/docker-compose/docker-compose'; function App() { const location = useLocation(); @@ -27,6 +28,7 @@ function App() { } /> } /> } /> + } /> }> } /> } /> diff --git a/web/src/components/navbar/navbar.tsx b/web/src/components/navbar/navbar.tsx index 25dbec08..0a609be2 100644 --- a/web/src/components/navbar/navbar.tsx +++ b/web/src/components/navbar/navbar.tsx @@ -23,6 +23,10 @@ const navbar = [ url: '/helm-template', title: 'Helm Template', }, + { + url: '/docker-compose', + title: 'Docker Compose', + }, { url: '/ansible-template', title: 'Ansible Template', diff --git a/web/src/enums/api.enums.ts b/web/src/enums/api.enums.ts index 1d8f7fb6..f90e6c92 100644 --- a/web/src/enums/api.enums.ts +++ b/web/src/enums/api.enums.ts @@ -11,6 +11,7 @@ export enum API { BugFix = '/IaC-bugfix', Installation = '/IaC-install', HelmTemplate = '/Helm-template', + DockerCompose = "/docker-compose" } export enum AnsibleTemplateAPI { diff --git a/web/src/lib/helper.ts b/web/src/lib/helper.ts index fe1e45f7..7bf8d285 100644 --- a/web/src/lib/helper.ts +++ b/web/src/lib/helper.ts @@ -1,3 +1,36 @@ +import { IServiceConfig } from '@/pages/docker-compose/docker-compose.type'; + export const getNestedValue = (obj: any, path: string) => { return path.split('.').reduce((acc, part) => acc && acc[part], obj); }; + +export const convertKVtoObject = ( + kvArray: Array<{ key: string; value: string } | null>, +) => { + return kvArray?.reduce( + (acc, curr) => { + + if (curr && acc) { + acc[curr.key] = curr?.value; + + } + return acc; + }, + {} as Record | null, + ); +}; + +interface Service { + name: string; + [key: string]: any; +} + +export const convertServicesToObject = ( + services: Service[], +): IServiceConfig => { + return services.reduce((acc, service) => { + const { name, ...serviceWithoutName } = service; + acc[name] = serviceWithoutName as IServiceConfig[string]; + return acc; + }, {} as IServiceConfig); +}; diff --git a/web/src/pages/docker-compose/components/network-fields.tsx b/web/src/pages/docker-compose/components/network-fields.tsx new file mode 100644 index 00000000..c2664be6 --- /dev/null +++ b/web/src/pages/docker-compose/components/network-fields.tsx @@ -0,0 +1,116 @@ +import { FC } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { FormInput } from '@/components/form/form-input'; +import { FormCheckbox } from '@/components/form/form-checkbox'; +import { FormSelect } from '@/components/form/form-select'; + +const defaultNetworkDrivers = ['bridge', 'host', 'none', 'overlay'] as const; + +const NetworkFields: FC = () => { + const { control, watch } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'networks.app_network', + }); + + const customNetwork = watch('networks.custom'); + + const handleRemoveNetwork = (index: number) => { + remove(index); + }; + + const handleAppendNetwork = () => { + const networkData = customNetwork + ? { + network_name: '', + external: false, + name: '', + } + : { + network_name: '', + driver: { + label: 'bridge', + value: 'bridge', + }, + }; + + append(networkData); + }; + + return ( +
+
+
+

Networks

+ +
+ +
+ +
+
+ {fields.map((field, index) => ( +
+
+

Network #{index + 1}

+ {index > 0 && ( + + )} +
+ +
+ {customNetwork && ( +
+ +
+ )} +
+ + {!customNetwork && ( + ({ + label: driver, + value: driver, + }))} + /> + )} + {customNetwork && ( + + )} +
+
+
+ ))} +
+
+
+ ); +}; + +export default NetworkFields; diff --git a/web/src/pages/docker-compose/components/service-build-fields.tsx b/web/src/pages/docker-compose/components/service-build-fields.tsx new file mode 100644 index 00000000..07799fb3 --- /dev/null +++ b/web/src/pages/docker-compose/components/service-build-fields.tsx @@ -0,0 +1,101 @@ +import { FC } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { FormInput } from '@/components/form/form-input'; +import { FormCheckbox } from '@/components/form/form-checkbox'; +import { cn } from '@/lib/utils'; + +type ServiceBuildFieldsProps = { + serviceIndex: number; +}; + +export const ServiceBuildFields: FC = ({ + serviceIndex, +}) => { + const { control, watch } = useFormContext(); + const buildEnabled = watch(`services.${serviceIndex}.build.enabled`); + + const { fields, append, remove } = useFieldArray({ + control, + name: `services.${serviceIndex}.build.args`, + }); + + return ( +
+
+

Build Configuration

+ +
+ + {buildEnabled && ( +
+
+ + +
+ +
+
+

Build Arguments

+ +
+ + {fields.map((field, idx) => ( +
+
+ + + +
+
+ ))} +
+
+ )} +
+ ); +}; diff --git a/web/src/pages/docker-compose/components/service-depends-on-fields.tsx b/web/src/pages/docker-compose/components/service-depends-on-fields.tsx new file mode 100644 index 00000000..858d8ee7 --- /dev/null +++ b/web/src/pages/docker-compose/components/service-depends-on-fields.tsx @@ -0,0 +1,63 @@ +import { FC } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { FormInput } from '@/components/form/form-input'; +import { cn } from '@/lib/utils'; + +type ServiceDependsOnFieldsProps = { + serviceIndex: number; +}; + +const ServiceDependsOnFields: FC = ({ + serviceIndex, +}) => { + const { control } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: `services.${serviceIndex}.depends_on`, + }); + + return ( +
+
+

Depends On

+ + +
+
+ {fields.map((field, depIdx) => ( +
div]:mb-0', + )} + key={field.id} + > + + + +
+ ))} +
+
+ ); +}; + +export default ServiceDependsOnFields; diff --git a/web/src/pages/docker-compose/components/service-environment-fields.tsx b/web/src/pages/docker-compose/components/service-environment-fields.tsx new file mode 100644 index 00000000..f21c711d --- /dev/null +++ b/web/src/pages/docker-compose/components/service-environment-fields.tsx @@ -0,0 +1,77 @@ +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { Plus, Trash2 } from 'lucide-react'; +import { FormInput } from '@/components/form/form-input'; +import { cn } from '@/lib/utils'; +import { FC } from 'react'; + +type ServiceEnvironmentFieldsProps = { + serviceIndex: number; +}; + +const ServiceEnvironmentFields: FC = ({ + serviceIndex, +}) => { + const { control } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: `services.${serviceIndex}.environment`, + }); + + return ( +
+
+

Environments

+ + +
+
+ {fields.map((field, envIdx) => ( +
div]:mb-0', + { + 'divide-red-500 border-red-500 dark:divide-red-500 dark:border-red-500': + control.getFieldState( + `services.${serviceIndex}.environment.${envIdx}.name`, + ).invalid, + }, + )} + key={field.id} + > + + + + +
+ ))} +
+
+ ); +}; + +export default ServiceEnvironmentFields; diff --git a/web/src/pages/docker-compose/components/service-network-fields.tsx b/web/src/pages/docker-compose/components/service-network-fields.tsx new file mode 100644 index 00000000..8293381c --- /dev/null +++ b/web/src/pages/docker-compose/components/service-network-fields.tsx @@ -0,0 +1,69 @@ +import { FC } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { FormInput } from '@/components/form/form-input'; +import { cn } from '@/lib/utils'; + +type ServiceNetworkFieldsProps = { + serviceIndex: number; +}; + +const ServiceNetworkFields: FC = ({ + serviceIndex, +}) => { + const { control } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: `services.${serviceIndex}.network`, + }); + + return ( +
+
+

Network

+ + +
+
+ {fields.map((field, envIdx) => ( +
div]:mb-0', + { + 'divide-red-500 border-red-500 dark:divide-red-500 dark:border-red-500': + control.getFieldState( + `pods.${serviceIndex}.environment.${envIdx}.name`, + ).invalid, + }, + )} + key={field.id} + > + + + +
+ ))} +
+
+ ); +}; + +export default ServiceNetworkFields; diff --git a/web/src/pages/docker-compose/components/service-ports-fields.tsx b/web/src/pages/docker-compose/components/service-ports-fields.tsx new file mode 100644 index 00000000..05669c69 --- /dev/null +++ b/web/src/pages/docker-compose/components/service-ports-fields.tsx @@ -0,0 +1,66 @@ +import { FC } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { FormInput } from '@/components/form/form-input'; +import { cn } from '@/lib/utils'; + +type ServicePortsFieldsProps = { + serviceIndex: number; +}; + +const ServicePortsFields: FC = ({ serviceIndex }) => { + const { control } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: `services.${serviceIndex}.ports`, + }); + + return ( +
+
+

Ports

+ + +
+
+ {fields.map((field, idx) => ( +
+ + + +
+ ))} +
+
+ ); +}; + +export default ServicePortsFields; diff --git a/web/src/pages/docker-compose/components/service-volumes-fields.tsx b/web/src/pages/docker-compose/components/service-volumes-fields.tsx new file mode 100644 index 00000000..1fa8de0e --- /dev/null +++ b/web/src/pages/docker-compose/components/service-volumes-fields.tsx @@ -0,0 +1,67 @@ +import { FC } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { FormInput } from '@/components/form/form-input'; +import { cn } from '@/lib/utils'; + +type ServiceVolumesFieldsProps = { + serviceIndex: number; +}; + +export const ServiceVolumesFields: FC = ({ + serviceIndex, +}) => { + const { control } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: `services.${serviceIndex}.volumes`, + }); + + return ( +
+
+

Volumes

+ + +
+
+ {fields.map((field, idx) => ( +
+ + + +
+ ))} +
+
+ ); +}; diff --git a/web/src/pages/docker-compose/docker-compose.tsx b/web/src/pages/docker-compose/docker-compose.tsx new file mode 100644 index 00000000..b76b8caa --- /dev/null +++ b/web/src/pages/docker-compose/docker-compose.tsx @@ -0,0 +1,312 @@ +import { FC, useState } from 'react'; +import { Plus, Trash2, ChevronDown } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { FormWrapper } from '@/components/form/form-wrapper'; +import { FormInput } from '@/components/form/form-input'; +import { + DockerComposeBody, + DockerComposeResponse, + DockerComposeSchema, + DockerComposeValidationError, + INetworkConfig, + TDockerCompose, +} from './docker-compose.type'; +import { cn } from '@/lib/utils'; +import ServiceNetworkFields from './components/service-network-fields'; +import ServiceDependsOnFields from './components/service-depends-on-fields'; +import { ServiceVolumesFields } from './components/service-volumes-fields'; +import ServicePortsFields from './components/service-ports-fields'; +import { ServiceBuildFields } from './components/service-build-fields'; +import { toast } from 'sonner'; +import { isAxiosError } from 'axios'; +import { usePost } from '@/core/react-query'; +import { API } from '@/enums/api.enums'; +import ServiceEnvironmentFields from './components/service-environment-fields'; +import { convertKVtoObject, convertServicesToObject } from '@/lib/helper'; +import NetworkFields from './components/network-fields'; +import { useDownload } from '@/hooks'; + +const DockerCompose: FC = () => { + const [openService, setOpenService] = useState(0); + + const { mutateAsync: dockerComposeMutate, isPending: dockerComposePending } = + usePost( + API.DockerCompose, + 'docker-compose', + ); + + const { download, isPending: downloadPending } = useDownload({ + downloadFileName: 'DockerCompose', + source: 'docker', + folderName: 'MyCompose', + }); + + const defaultValues = { + version: '', + services: [ + { + name: '', + build: { + context: '', + dockerfile: '', + args: [], + }, + command: '', + container_name: '', + environment: [], + image: '', + ports: [''], + volumes: [''], + networks: [''], + depends_on: [''], + }, + ], + networks: { + custom: false, + app_network: [ + { + network_name: '', + driver: { value: 'bridge', label: 'bridge' }, + }, + ], + }, + }; + + const methods = useForm({ + resolver: zodResolver(DockerComposeSchema), + defaultValues, + }); + + const { control } = methods; + + const { + fields: services, + append, + remove, + } = useFieldArray({ + control, + name: 'services', + }); + + const handleAddService = () => { + append({ + build: { + args: [], + context: '', + dockerfile: '', + }, + name: '', + command: '', + container_name: '', + image: '', + environment: [], + depends_on: [''], + networks: [''], + ports: [''], + volumes: [''], + }); + }; + + const handleRemoveService = (index: number) => { + remove(index); + }; + + const handleSubmit = async (data: TDockerCompose) => { + try { + const refactoredService = data.services.map( + ({ build: { enabled, ...buildRest }, ...service }) => ({ + ...service, + ...(service.environment && { + environment: convertKVtoObject(service.environment), + }), + ...(enabled + ? { + build: { + ...buildRest, + args: convertKVtoObject(buildRest.args), + }, + } + : { build: null }), + }), + ); + + const refactoredNetwork = data.networks.app_network.reduce( + (acc: INetworkConfig, network) => { + if (!data.networks.custom) { + if ('driver' in network) { + acc[network.network_name] = { + driver: network.driver?.value, + }; + } + } + if ('name' in network && 'external' in network) { + acc[network.network_name] = { + name: network.name, + external: !!network.external, + }; + } + return acc; + }, + {}, + ); + + const services = refactoredService.map((item) => { + if (item.ports && item?.ports[0].length === 0) { + item.ports = null; + } + if (item.volumes && item.volumes[0].length === 0) { + item.volumes = null; + } + if (item.networks && item.networks[0].length === 0) { + item.networks = null; + } + if (item.depends_on && item.depends_on[0].length === 0) { + item.depends_on = null; + } + if (item.environment && !item.environment[0]) { + item.environment = null; + } + if (item.environment && item.environment[0]) { + item.environment = null; + } + return item; + }); + + const requestBody: DockerComposeBody = { + version: data.version, + services: convertServicesToObject(services), + networks: refactoredNetwork, + }; + + await dockerComposeMutate(requestBody); + await download(); + } catch (error) { + console.log(error); + if (isAxiosError(error)) { + toast.error( + `${error.response?.data.detail[0].loc[error.response?.data.detail[0].loc.length - 1]} ${error.response?.data.detail[0].msg}`, + ); + } else { + console.log(error); + toast.error('Something went wrong'); + } + } + }; + + return ( +
+
+ +
+ +
+
+ +
+ +
+

Services

+ +
+ +
+ {services.map((service, index) => ( +
+
+

Service #{index + 1}

+
+ {index > 0 && ( + + )} + + setOpenService(openService === index ? -1 : index) + } + /> +
+
+
+
+ +
+
+ +
+
+ +
+ + + + + + +
+
+ ))} +
+ +
+
+
+ ); +}; + +export default DockerCompose; diff --git a/web/src/pages/docker-compose/docker-compose.type.ts b/web/src/pages/docker-compose/docker-compose.type.ts new file mode 100644 index 00000000..ff2ec3c0 --- /dev/null +++ b/web/src/pages/docker-compose/docker-compose.type.ts @@ -0,0 +1,140 @@ +import { z as zod } from 'zod'; + +interface IBuildConfig { + args: { + [key: string]: string; + }; + context: string; + dockerfile: string; +} + +export interface IServiceConfig { + [key: string]: { + build?: IBuildConfig; + image: string; + environment: { + [key: string]: string; + }; + container_name: string; + ports: string[]; + command?: string; + volumes: string[]; + networks: string[]; + depends_on: string[]; + }; +} + +export interface INetworkConfig { + [key: string]: + | { + driver: 'bridge' | 'host' | 'none' | 'overlay'; + } + | { + name: string; + external: boolean; + }; +} + +export interface DockerComposeBody { + version: string; + services: IServiceConfig; + networks: INetworkConfig; +} + +export interface DockerComposeResponse { + output: string; +} + +export interface DockerComposeValidationError { + detail: [ + { + type: string; + loc: string[]; + msg: string; + input: null; + }, + ]; +} + +const KV_Schema = zod.array( + zod.object({ + key: zod + .string() + .min(1, { message: 'Key must be at least 1 character long' }), + value: zod + .string() + .min(1, { message: 'Value must be at least 1 character long' }), + }).nullable(), +); + +export const BuildSchema = zod.object({ + enabled: zod.boolean(), + args: KV_Schema, + context: zod.string(), + dockerfile: zod.string(), +}); + +export const ServiceSchema = zod.object({ + name: zod.string(), + build: BuildSchema, + image: zod.string(), + environment: KV_Schema, + container_name: zod.string(), + ports: zod.array(zod.string()).nullable(), + command: zod.string().optional(), + volumes: zod.array(zod.string()).nullable(), + networks: zod.array(zod.string()).nullable(), + depends_on: zod.array(zod.string()).nullable(), +}); + +const labelValueSchema = zod.object({ + label: zod.string(), + value: zod.enum(['bridge', 'host', 'none', 'overlay']), +}); + +export const NetworkSchema = zod.union([ + zod.object({ + custom: zod.literal(false), + app_network: zod.array( + zod.object({ + network_name: zod.string(), + driver: labelValueSchema, + }), + ), + }), + zod.object({ + custom: zod.literal(true), + app_network: zod.array( + zod.object({ + network_name: zod.string(), + external: zod.boolean().optional(), + name: zod.string(), + }), + ), + }), +]); + +export const DockerComposeSchema = zod.object({ + version: zod.string(), + services: zod.array(ServiceSchema), + networks: NetworkSchema, +}); + +export type TDockerCompose = zod.infer; + + +type AppNetwork = { + network_name: string; + driver: { + label: string; + value: "bridge" | "host" | "none" | "overlay"; + }; +}; + +type NetworkConfig = { + name: string; + network_name: string; + external?: boolean; +}; + +export type CombinedNetworkType = AppNetwork | NetworkConfig; \ No newline at end of file