diff --git a/README.md b/README.md index 4dd21d3..9a9d79d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Container ToolKit -Visually generate docker compose manifests and deploy apps to AWS ECS (coming soon). +Visually generate docker compose & kubernetes manifests. ![Alt text](https://ctk-public.s3.amazonaws.com/ui.png?raw=true "UI") @@ -27,12 +27,6 @@ make dev_server ... this command will bring up the backend, the database, sync migrations, -## Project roadmap - -- Kubernetes manifest generation. -- ECS deployment. -- K8S deployment. - ## Docs - https://docs.jsplumbtoolkit.com/community/ diff --git a/deploy/Makefile b/deploy/Makefile index a70c33a..496a95f 100644 --- a/deploy/Makefile +++ b/deploy/Makefile @@ -12,7 +12,7 @@ endif build-image : docker build -t $(ORGANIZATION)/$(CONTAINER):$(VERSION) . -deploy : +run : docker run --rm --name $(CONTAINER) \ --env AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \ --env AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \ diff --git a/docker-compose.yml b/docker-compose.yml index a4acf52..73d0ca6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,9 +33,11 @@ services: - postgres volumes: - ./services/backend/src:/home/server/ + - ./services/backend/configs:/home/configs/ - django-static:/static/ ports: - "9001:9001" + - "9000:9000" environment: - DB_REMOTE=False - APP_URL= @@ -47,15 +49,8 @@ services: context: ./ dockerfile: ./services/frontend/Dockerfile image: corpulent/ctk-frontend:1.0.0 - depends_on: - - backend - links: - - backend volumes: - - ./services/frontend/configs/nginx/uwsgi_params:/home/config/uwsgi/uwsgi_params - - ./services/frontend/configs/nginx/localhost.conf:/etc/nginx/conf.d/default.conf - - ./services/frontend/configs/nginx/nginx.conf:/etc/nginx/nginx.conf + - ./services/frontend/configs/nginx/default.conf:/etc/nginx/conf.d/default.conf - ./services/frontend/build:/usr/share/nginx/html/ - - django-static:/home/server/static/ ports: - - "80:80" + - "8080:8080" diff --git a/services/backend/Dockerfile b/services/backend/Dockerfile index e2a45b0..c29ff26 100644 --- a/services/backend/Dockerfile +++ b/services/backend/Dockerfile @@ -13,11 +13,15 @@ RUN apt-get update && \ postgresql-contrib \ wget \ nano \ - curl \ lsof \ + curl \ supervisor && \ rm -rf /var/lib/apt/lists/* +RUN wget https://github.com/kubernetes/kompose/releases/download/v1.26.1/kompose_1.26.1_amd64.deb +RUN apt install ./kompose_1.26.1_amd64.deb && \ + rm kompose_1.26.1_amd64.deb + RUN useradd uwsgi && adduser uwsgi root RUN useradd supervisor && adduser supervisor root @@ -26,19 +30,12 @@ RUN pip install --upgrade pip && \ pip install -r ./requirements.txt && \ rm ./requirements.txt -RUN touch /var/log/backend_out.log && \ - touch /var/log/django.log - -RUN chmod g+w -R /var/log/ - -EXPOSE 9000 9001 - COPY ./services/backend/src ./server COPY ./services/backend/configs/supervisor/api.conf /etc/supervisor/conf.d/api.conf -COPY ./services/backend/configs/uwsgi ./config/uwsgi +COPY ./services/backend/configs/uwsgi ./configs/uwsgi + +EXPOSE 9000 9001 -RUN rm -rf /tmp/uwsgi && \ - mkdir -p /tmp/uwsgi && \ - ln -s ./config/uwsgi/uwsgi.ini /tmp/uwsgi/ +HEALTHCHECK CMD curl --fail http://localhost:9000/v1 || exit 1 -CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"] +CMD ["/usr/local/bin/uwsgi", "--ini", "/home/configs/uwsgi/uwsgi.ini"] diff --git a/services/backend/configs/supervisor/api.conf b/services/backend/configs/supervisor/api.conf index 7cd1b64..fe12574 100644 --- a/services/backend/configs/supervisor/api.conf +++ b/services/backend/configs/supervisor/api.conf @@ -3,6 +3,6 @@ nodaemon=true [program:app] priority=1 -user = uwsgi -command = /usr/local/bin/uwsgi --ini /tmp/uwsgi/uwsgi.ini +user=uwsgi +command=/usr/local/bin/uwsgi --ini /home/config/uwsgi/uwsgi.ini autorestart=false diff --git a/services/backend/configs/uwsgi/uwsgi.ini b/services/backend/configs/uwsgi/uwsgi.ini index 40edf1a..6bf4dab 100644 --- a/services/backend/configs/uwsgi/uwsgi.ini +++ b/services/backend/configs/uwsgi/uwsgi.ini @@ -1,6 +1,10 @@ [uwsgi] ini = :base -socket = 0.0.0.0:9000 + +# use socket option a third-party router (nginx), +# use http option to set uwsgi to accept incoming +# HTTP requests and route them by itself +http = 0.0.0.0:9000 master = true processes = 5 @@ -8,7 +12,7 @@ processes = 5 [base] chdir = /home/server -module = server.wsgi:application +module = main.wsgi:application chmod-socket=666 uid = uwsgi gid = uwsgi diff --git a/services/backend/requirements.txt b/services/backend/requirements.txt index 52b8034..45010ad 100644 --- a/services/backend/requirements.txt +++ b/services/backend/requirements.txt @@ -1,6 +1,5 @@ django==4.0.4 django-cors-headers==3.11.0 -django-axes==5.32.0 djangorestframework==3.13.1 djangorestframework-simplejwt==5.1.0 django-storages==1.13.1 diff --git a/services/backend/src/api/routing.py b/services/backend/src/api/routing.py index 72e9f02..28f53e7 100644 --- a/services/backend/src/api/routing.py +++ b/services/backend/src/api/routing.py @@ -17,7 +17,9 @@ api_urls = [ path("projects/", project.ProjectListCreateAPIView.as_view()), path("projects/import/", project.ProjectImportAPIView.as_view()), path("projects//", project.ProjectGenericAPIView.as_view()), - path("generate/", generate.GenerateGenericAPIView.as_view()), + path("generate/", generate.GenerateDockerComposeView.as_view()), + path("generate/docker-compose", generate.GenerateDockerComposeView.as_view()), + path("generate/kubernetes", generate.GenerateK8sView.as_view()), path("auth/self/", user.UserGenericAPIView.as_view()), path("auth/", include("dj_rest_auth.urls")), path("auth/github/", auth.GitHubLogin.as_view(), name="github_login"), diff --git a/services/backend/src/api/views/generate.py b/services/backend/src/api/views/generate.py index 9ffeae3..9ab7eb7 100644 --- a/services/backend/src/api/views/generate.py +++ b/services/backend/src/api/views/generate.py @@ -1,11 +1,32 @@ +import contextlib +import re +import io +import shutil import json +import subprocess as sp + +from ruamel.yaml import YAML +from pathlib import Path from rest_framework import generics, status from rest_framework.response import Response +from .utils import ( + generate, clean_dict, get_random_string, read_dir) + -from .utils import generate +def generate_docker_compose(data): + version = data.get('version', '3') + services = data.get('services', None) + volumes = data.get('volumes', None) + networks = data.get('networks', None) + return generate( + services, + volumes, + networks, + version=version, + return_format='yaml') -class GenerateGenericAPIView(generics.GenericAPIView): +class GenerateDockerComposeView(generics.GenericAPIView): permission_classes = [] def get(self, request): @@ -13,18 +34,71 @@ class GenerateGenericAPIView(generics.GenericAPIView): def post(self, request, format=None): request_data = json.loads(request.data) - version = request_data['data'].get('version', '3') - services = request_data['data'].get('services', None) - volumes = request_data['data'].get('volumes', None) - networks = request_data['data'].get('networks', None) - - code = generate( - services, - volumes, - networks, - version=version, - return_format='yaml') + code = generate_docker_compose(request_data["data"]) resp = {'code': code} + return Response(resp, status=status.HTTP_200_OK) + +class GenerateK8sView(generics.GenericAPIView): + permission_classes = [] + + def get(self, request): + return Response({}, status=status.HTTP_404_NOT_FOUND) + + def post(self, request, format=None): + resp = { + 'code': "", + 'error': "" + } + workdir = f"/tmp/{get_random_string(8)}" + request_data = json.loads(request.data) + omitted = clean_dict( + request_data["data"], + ["env_file", "build", "secrets", "profiles"]) + docker_compose_code = generate_docker_compose(omitted) + path = Path(workdir) + path.mkdir(exist_ok=True) + + with open(f"{path}/docker-compose.yaml", 'w') as f: + f.write(docker_compose_code) + + process = sp.Popen([ + "kompose", + "--suppress-warnings", + "--file", + f"{path}/docker-compose.yaml", "convert" + ], cwd=workdir, stdout=sp.PIPE, stderr=sp.PIPE) + process.wait() + _, out = process.communicate() + + if out: + out = out.decode("utf-8") + parts = out.split(" ") + parts.pop() + parts.pop(0) + final_list = [re.sub(r'\[.*?;.*?m', '', x) for x in parts if any(x)] + resp["error"] = " ".join(final_list) + + workdir_files = read_dir(workdir) + workdir_files.remove("docker-compose.yaml") + + for file in workdir_files: + with open(f"{workdir}/{file}") as f: + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.explicit_start = True + data = yaml.load(f) + + with contextlib.suppress(KeyError): + del data["metadata"]["annotations"] + with contextlib.suppress(KeyError): + del data["spec"]["template"]["metadata"]["annotations"] + + buf = io.BytesIO() + yaml.dump(data, buf) + resp["code"] += buf.getvalue().decode() + if file != workdir_files[-1]: + resp["code"] += "\n" + shutil.rmtree(workdir) return Response(resp, status=status.HTTP_200_OK) diff --git a/services/backend/src/api/views/utils.py b/services/backend/src/api/views/utils.py index 8d933d8..c496f68 100644 --- a/services/backend/src/api/views/utils.py +++ b/services/backend/src/api/views/utils.py @@ -1,5 +1,8 @@ import io +import os import contextlib +import random +import string from ruamel.yaml import YAML from ruamel.yaml.scalarstring import DoubleQuotedScalarString @@ -58,11 +61,11 @@ def sequence_indent_one(s): return ret_val -def get_version(verion): +def get_version(version): try: - return int(verion) + return int(version) except ValueError: - return float(verion) + return float(version) def generate(services, volumes, networks, version="3", return_format='yaml'): if return_format != 'yaml': @@ -95,8 +98,23 @@ def generate(services, volumes, networks, version="3", return_format='yaml'): if volumes: ret_yaml.dump({'volumes': volumes}, s) - s.write('\n') s.seek(0) + return s.read() + +def clean_dict(dic, omit=None): + if type(dic) is dict: + for key, item in dic.copy().items(): + if omit and key in omit: + del dic[key] + elif type(item) is dict: + dic[key] = clean_dict(item, omit) + + return dic + +def get_random_string(length): + letters = string.ascii_lowercase + return ''.join(random.choice(letters) for _ in range(length)) - return s +def read_dir(path): + return [f for f in os.listdir(path) if os.path.isfile(f"{path}/{f}")] diff --git a/services/backend/src/api/views/view.py b/services/backend/src/api/views/view.py index 486d5b6..ba6992f 100644 --- a/services/backend/src/api/views/view.py +++ b/services/backend/src/api/views/view.py @@ -6,4 +6,4 @@ class ViewGenericAPIView(generics.GenericAPIView): permission_classes = [] def get(self, request): - return Response({}, status=status.HTTP_404_NOT_FOUND) + return Response({}, status=status.HTTP_200_OK) diff --git a/services/backend/src/main/settings.py b/services/backend/src/main/settings.py index 3e71290..e9e41b9 100644 --- a/services/backend/src/main/settings.py +++ b/services/backend/src/main/settings.py @@ -56,7 +56,6 @@ INSTALLED_APPS = [ "dj_rest_auth.registration", "storages", "corsheaders", - "axes", "organizations", "api", ] @@ -69,8 +68,7 @@ MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "axes.middleware.AxesMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware" ] ROOT_URLCONF = "main.urls" @@ -162,8 +160,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", - "allauth.account.auth_backends.AuthenticationBackend", - "axes.backends.AxesBackend", + "allauth.account.auth_backends.AuthenticationBackend" ] REST_FRAMEWORK = { diff --git a/services/frontend/Dockerfile b/services/frontend/Dockerfile index 12a44bd..3a5e2fd 100644 --- a/services/frontend/Dockerfile +++ b/services/frontend/Dockerfile @@ -7,6 +7,6 @@ RUN npm run build FROM nginx:stable-alpine COPY --from=build /build/build /usr/share/nginx/html -COPY --from=build /build/configs/nginx/nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 +COPY --from=build /build/configs/nginx/default.conf /etc/nginx/conf.d/default.conf +EXPOSE 8080 CMD ["nginx", "-g", "daemon off;"] diff --git a/services/frontend/configs/nginx/default.conf b/services/frontend/configs/nginx/default.conf new file mode 100644 index 0000000..1272468 --- /dev/null +++ b/services/frontend/configs/nginx/default.conf @@ -0,0 +1,20 @@ +# vi:syntax=nginx + +server { + listen 8080; + listen [::]:8080; + server_name localhost; + + charset utf-8; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} \ No newline at end of file diff --git a/services/frontend/configs/nginx/localhost.conf b/services/frontend/configs/nginx/localhost.conf deleted file mode 100644 index 91d7479..0000000 --- a/services/frontend/configs/nginx/localhost.conf +++ /dev/null @@ -1,38 +0,0 @@ -# vi:syntax=nginx - -upstream django { - server backend:9000; -} - -server { - listen 80; - listen [::]:80; - server_name localhost; - - charset utf-8; - - location /static { - alias /home/server/static; - } - - location /admin { - uwsgi_pass django; - include /home/config/uwsgi/uwsgi_params; - } - - location /api { - uwsgi_pass django; - include /home/config/uwsgi/uwsgi_params; - } - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; - } - - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } -} \ No newline at end of file diff --git a/services/frontend/configs/nginx/nginx.conf b/services/frontend/configs/nginx/nginx.conf deleted file mode 100644 index 6e16d69..0000000 --- a/services/frontend/configs/nginx/nginx.conf +++ /dev/null @@ -1,43 +0,0 @@ -worker_processes 4; -pid /run/nginx.pid; - -events { - worker_connections 768; - # multi_accept on; -} - -http { - # Basic Settings - tcp_nopush on; - tcp_nodelay on; - #keepalive_timeout 65; - types_hash_max_size 2048; - - include /etc/nginx/mime.types; - default_type application/octet-stream; - - access_log /var/log/nginx/access.log ; - - sendfile on; - #tcp_nopush on; - client_max_body_size 20M; - - keepalive_timeout 0; - - uwsgi_read_timeout 86400; - uwsgi_send_timeout 86400; - - # Gzip Settings - - gzip on; - gzip_disable "msie6"; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - # Virtual Host Configs - include /etc/nginx/conf.d/*.conf; - #include /etc/nginx/sites-enabled/*; -} \ No newline at end of file diff --git a/services/frontend/configs/nginx/uwsgi_params b/services/frontend/configs/nginx/uwsgi_params deleted file mode 100644 index 0bdf13b..0000000 --- a/services/frontend/configs/nginx/uwsgi_params +++ /dev/null @@ -1,15 +0,0 @@ -uwsgi_param QUERY_STRING $query_string; -uwsgi_param REQUEST_METHOD $request_method; -uwsgi_param CONTENT_TYPE $content_type; -uwsgi_param CONTENT_LENGTH $content_length; - -uwsgi_param REQUEST_URI $request_uri; -uwsgi_param PATH_INFO $document_uri; -uwsgi_param DOCUMENT_ROOT $document_root; -uwsgi_param SERVER_PROTOCOL $server_protocol; -uwsgi_param HTTPS $https if_not_empty; - -uwsgi_param REMOTE_ADDR $remote_addr; -uwsgi_param REMOTE_PORT $remote_port; -uwsgi_param SERVER_PORT $server_port; -uwsgi_param SERVER_NAME $server_name; \ No newline at end of file diff --git a/services/frontend/package.json b/services/frontend/package.json index f60986f..30899d0 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -80,7 +80,7 @@ "@types/d3": "^7.1.0", "@types/jest": "^27.4.0", "@types/lodash": "^4.14.178", - "@types/node": "^16.11.22", + "@types/node": "^16.11.56", "@types/react": "^17.0.45", "@types/react-dom": "^17.0.11", "@types/uuid": "^8.3.4", diff --git a/services/frontend/src/components/Project/CodeBox.tsx b/services/frontend/src/components/Project/CodeBox.tsx new file mode 100644 index 0000000..d765da2 --- /dev/null +++ b/services/frontend/src/components/Project/CodeBox.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import YAML from "yaml"; +import { debounce } from "lodash"; +import { manifestTypes } from "../../constants"; +import { generatePayload } from "../../utils/generators"; +import { checkHttpStatus } from "../../services/helpers"; +import { generateHttp } from "../../services/generate"; +import { toaster } from "../../utils"; +import eventBus from "../../events/eventBus"; +import ManifestSelect from "./ManifestSelect"; +import CodeEditor from "../CodeEditor"; +import useWindowDimensions from "../../hooks/useWindowDimensions"; + +const CodeBox = () => { + const versionRef = useRef(); + const manifestRef = useRef(); + const [language, setLanguage] = useState("yaml"); + const [version, setVersion] = useState("3"); + const [copyText, setCopyText] = useState("Copy"); + const [generatedCode, setGeneratedCode] = useState(""); + const [formattedCode, setFormattedCode] = useState(""); + const [manifest, setManifest] = useState(manifestTypes.DOCKER_COMPOSE); + const { height } = useWindowDimensions(); + + versionRef.current = version; + manifestRef.current = manifest; + + const getCode = (payload: any, manifest: string) => { + generateHttp(JSON.stringify(payload), manifest) + .then(checkHttpStatus) + .then((data) => { + if (data["code"]) { + setGeneratedCode(data["code"]); + } else { + setGeneratedCode(""); + } + + if (data["error"]) { + setGeneratedCode(""); + toaster(`error ${data["error"]}`, "error"); + } + }); + }; + + const debouncedOnGraphUpdate = useMemo( + () => + debounce((payload, manifest) => { + getCode(payload, manifest); + }, 600), + [] + ); + + const versionChange = (e: any) => { + setVersion(e.target.value); + }; + + const copy = () => { + navigator.clipboard.writeText(formattedCode); + setCopyText("Copied"); + + setTimeout(() => { + setCopyText("Copy"); + }, 300); + }; + + useEffect(() => { + if (language === "json") { + setFormattedCode( + JSON.stringify(YAML.parseAllDocuments(generatedCode), null, 2) + ); + } + + if (language === "yaml") { + setFormattedCode(generatedCode); + } + }, [language, generatedCode]); + + useEffect(() => { + eventBus.dispatch("GENERATE", { + message: { + id: "" + } + }); + }, [version, manifest]); + + useEffect(() => { + eventBus.on("FETCH_CODE", (data) => { + const graphData = data.detail.message; + graphData.version = versionRef.current; + debouncedOnGraphUpdate(generatePayload(graphData), manifestRef.current); + }); + + return () => { + eventBus.remove("FETCH_CODE", () => undefined); + }; + }, []); + + return ( + <> +
+ + + + + +
+ +
+ +
+ + { + return; + }} + disabled={true} + lineWrapping={false} + height={height - 64} + /> + + ); +}; + +export default CodeBox; diff --git a/services/frontend/src/components/Project/Header.tsx b/services/frontend/src/components/Project/Header.tsx new file mode 100644 index 0000000..5186e4d --- /dev/null +++ b/services/frontend/src/components/Project/Header.tsx @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { CallbackFunction, IProject } from "../../types"; +import Spinner from "../global/Spinner"; +import VisibilitySwitch from "../global/VisibilitySwitch"; + +interface IHeaderProps { + onSave: CallbackFunction; + isLoading: boolean; + projectData: IProject; + isAuthenticated: boolean; +} + +const Header = (props: IHeaderProps) => { + const { onSave, isLoading, projectData, isAuthenticated } = props; + const [visibility, setVisibility] = useState(false); + const [projectName, setProjectName] = useState("Untitled"); + + const visibilityRef = useRef(false); + const projectNameRef = useRef("Untitled"); + + const handleNameChange = useCallback((e: any) => { + setProjectName(e.target.value); + projectNameRef.current = e.target.value; + }, []); + + const handleSave = useCallback(() => { + const data: any = { + name: projectNameRef.current, + visibility: +visibilityRef.current + }; + + onSave(data); + }, []); + + useEffect(() => { + if (!projectData) { + return; + } + + setProjectName(projectData.name); + setVisibility(Boolean(projectData.visibility)); + + visibilityRef.current = Boolean(projectData.visibility); + projectNameRef.current = projectData.name; + }, [projectData]); + + return ( + <> +
+
+ + +
+ {isAuthenticated && ( + { + setVisibility(!visibility); + visibilityRef.current = !visibility; + }} + /> + )} + + +
+
+
+ + ); +}; + +export default Header; diff --git a/services/frontend/src/components/Project/ManifestSelect.tsx b/services/frontend/src/components/Project/ManifestSelect.tsx new file mode 100644 index 0000000..1444f5d --- /dev/null +++ b/services/frontend/src/components/Project/ManifestSelect.tsx @@ -0,0 +1,61 @@ +import { styled } from "@mui/material"; +import { useCallback, useState } from "react"; +import { manifestTypes } from "../../constants"; +import DcLogo from "../global/dc-logo"; +import K8sLogo from "../global/k8s-logo"; + +interface IButtonProps { + selected: boolean; +} + +const Button = styled("button", { + shouldForwardProp: (name) => name !== "selected" +})` + filter: grayscale(${({ selected }) => (selected ? "0%" : "100%")}); + opacity: ${({ selected }) => (selected ? "100%" : "80%")}; + + &:hover { + filter: grayscale(0%); + } +`; + +interface IManifestSelectProps { + setManifest: any; +} + +const ManifestSelect = (props: IManifestSelectProps) => { + const { setManifest } = props; + const [selected, setSelected] = useState(manifestTypes.DOCKER_COMPOSE); + + const handleK8s = useCallback(() => { + setManifest(manifestTypes.KUBERNETES); + setSelected(manifestTypes.KUBERNETES); + }, []); + + const handleDC = useCallback(() => { + setManifest(manifestTypes.DOCKER_COMPOSE); + setSelected(manifestTypes.DOCKER_COMPOSE); + }, []); + + return ( + <> + + + + + ); +}; + +export default ManifestSelect; diff --git a/services/frontend/src/components/Project/index.tsx b/services/frontend/src/components/Project/index.tsx index 37e3130..6d9d5b5 100644 --- a/services/frontend/src/components/Project/index.tsx +++ b/services/frontend/src/components/Project/index.tsx @@ -1,25 +1,21 @@ -import { useEffect, useState, useRef, useMemo } from "react"; +import { useEffect, useState, useRef } from "react"; import { useParams } from "react-router-dom"; -import { debounce, Dictionary, omit } from "lodash"; -import YAML from "yaml"; +import { Dictionary, omit, remove } from "lodash"; import { GlobeAltIcon, CubeIcon, FolderAddIcon } from "@heroicons/react/solid"; import { - IProjectPayload, IServiceNodeItem, IVolumeNodeItem, IServiceNodePosition, IProject, - IEditServiceFormDependsOn + IProjectPayload } from "../../types"; import eventBus from "../../events/eventBus"; -import { useMutation } from "react-query"; import { + createProject, useProject, - useUpdateProject, - createProject + useUpdateProject } from "../../hooks/useProject"; import useWindowDimensions from "../../hooks/useWindowDimensions"; -import { generatePayload } from "../../utils/generators"; import { nodeLibraries } from "../../utils/data/libraries"; import { getClientNodeItem, @@ -28,8 +24,6 @@ import { getClientNodesAndConnections, getMatchingSetIndex } from "../../utils"; -import { checkHttpStatus } from "../../services/helpers"; -import { generateHttp } from "../../services/generate"; import { Canvas } from "../Canvas"; import Spinner from "../global/Spinner"; import ModalConfirmDelete from "../modals/ConfirmDelete"; @@ -38,10 +32,10 @@ import ModalServiceEdit from "../modals/docker-compose/service/Edit"; import ModalNetwork from "../modals/docker-compose/network"; import CreateVolumeModal from "../modals/docker-compose/volume/CreateVolumeModal"; import EditVolumeModal from "../modals/docker-compose/volume/EditVolumeModal"; -import CodeEditor from "../CodeEditor"; import { useTitle } from "../../hooks"; -import VisibilitySwitch from "../global/VisibilitySwitch"; -import _ from "lodash"; +import CodeBox from "./CodeBox"; +import Header from "./Header"; +import { useMutation } from "react-query"; interface IProjectProps { isAuthenticated: boolean; @@ -56,10 +50,8 @@ export default function Project(props: IProjectProps) { useRef>(); const stateConnectionsRef = useRef<[[string, string]] | []>(); const stateNetworksRef = useRef({}); + const stateProjectRef = useRef(); - const [isVisible, setIsVisible] = useState(false); - const [generatedCode, setGeneratedCode] = useState(); - const [formattedCode, setFormattedCode] = useState(""); const [showModalCreateService, setShowModalCreateService] = useState(false); const [showVolumesModal, setShowVolumesModal] = useState(false); const [showNetworksModal, setShowNetworksModal] = useState(false); @@ -74,14 +66,9 @@ export default function Project(props: IProjectProps) { const [volumeToDelete, setVolumeToDelete] = useState( null ); - const [language, setLanguage] = useState("yaml"); - const [version, setVersion] = useState("3"); - const [copyText, setCopyText] = useState("Copy"); const [nodes, setNodes] = useState>({}); const [connections, setConnections] = useState<[[string, string]] | []>([]); const [networks, setNetworks] = useState>({}); - const [projectName, setProjectName] = useState("Untitled"); - const [canvasPosition, setCanvasPosition] = useState({ top: 0, left: 0, @@ -109,10 +96,7 @@ export default function Project(props: IProjectProps) { stateNodesRef.current = nodes; stateConnectionsRef.current = connections; stateNetworksRef.current = networks; - - const handleNameChange = (e: any) => { - setProjectName(e.target.value); - }; + stateProjectRef.current = data; const onNodeUpdate = (positionData: IServiceNodePosition) => { if (stateNodesRef.current) { @@ -124,36 +108,6 @@ export default function Project(props: IProjectProps) { } }; - const onSave = () => { - const payload: IProjectPayload = { - name: projectName, - visibility: +isVisible, - data: { - canvas: { - position: canvasPosition, - nodes: nodes, - connections: connections, - networks: networks - } - } - }; - - if (uuid) { - updateProjectMutation.mutate(payload); - } else { - createProjectMutation.mutate(payload); - } - }; - - const copy = () => { - navigator.clipboard.writeText(formattedCode); - setCopyText("Copied"); - - setTimeout(() => { - setCopyText("Copy"); - }, 300); - }; - useEffect(() => { if (!data) { return; @@ -167,42 +121,41 @@ export default function Project(props: IProjectProps) { nodesAsList, nodeLibraries ); - - setProjectName(data.name); - setIsVisible(Boolean(data.visibility)); setNodes(clientNodeItems); setConnections(canvasData.canvas.connections); setNetworks(canvasData.canvas.networks); setCanvasPosition(canvasData.canvas.position); }, [data]); - const debouncedOnGraphUpdate = useMemo( - () => - debounce((payload) => { - generateHttp(JSON.stringify(payload)) - .then(checkHttpStatus) - .then((data) => { - if (data["code"].length) { - for (let i = 0; i < data["code"].length; ++i) { - data["code"][i] = data["code"][i].replace(/(\r\n|\n|\r)/gm, ""); - } + const onSave = (partial: any) => { + const base: IProjectPayload = { + name: data?.name ?? "", + visibility: data?.visibility ?? 0, + data: { + canvas: { + position: canvasPosition, + nodes: stateNodesRef.current, + connections: stateConnectionsRef.current, + networks: stateNetworksRef.current + } + } + }; - const code = data["code"].join("\n"); - setGeneratedCode(code); - } - }) - .catch(() => undefined) - .finally(() => undefined); - }, 600), - [] - ); + const payload = { ...base, ...partial }; + + if (uuid) { + updateProjectMutation.mutate(payload); + } else { + createProjectMutation.mutate(payload); + } + }; const onGraphUpdate = (graphData: any) => { const data = { ...graphData }; - data.version = version; data.networks = stateNetworksRef.current; - const payload = generatePayload(data); - debouncedOnGraphUpdate(payload); + eventBus.dispatch("FETCH_CODE", { + message: data + }); }; const onCanvasUpdate = (updatedCanvasPosition: any) => { @@ -325,7 +278,7 @@ export default function Project(props: IProjectProps) { dependsOnKeys.forEach((key: string) => { if (key === targetServiceName) { if (Array.isArray(sourceDependsOn)) { - _.remove(sourceDependsOn, (key) => key === targetServiceName); + remove(sourceDependsOn, (key) => key === targetServiceName); } if (sourceDependsOn && sourceDependsOn.constructor === Object) { @@ -404,32 +357,6 @@ export default function Project(props: IProjectProps) { eventBus.dispatch("NODE_DELETED", { message: { node: node } }); }; - const versionChange = (e: any) => { - setVersion(e.target.value); - }; - - useEffect(() => { - if (!generatedCode) { - return; - } - - if (language === "json") { - setFormattedCode(JSON.stringify(YAML.parse(generatedCode), null, 2)); - } - - if (language === "yaml") { - setFormattedCode(generatedCode); - } - }, [language, generatedCode]); - - useEffect(() => { - eventBus.dispatch("GENERATE", { - message: { - id: "" - } - }); - }, [version]); - if (!isFetching) { if (!error) { return ( @@ -495,70 +422,15 @@ export default function Project(props: IProjectProps) { ) : null}
-
-
- - -
- {isAuthenticated && ( - { - setIsVisible(!isVisible); - }} - /> - )} - - -
-
-
+
-
- - - - - -
- - { - return; - }} - disabled={true} - lineWrapping={false} - height={height - 64} - /> +
diff --git a/services/frontend/src/components/global/dc-logo.tsx b/services/frontend/src/components/global/dc-logo.tsx new file mode 100644 index 0000000..992074d --- /dev/null +++ b/services/frontend/src/components/global/dc-logo.tsx @@ -0,0 +1,29 @@ +const DcLogo = () => { + return ( + + + + + + + + + + + + ); +}; + +export default DcLogo; diff --git a/services/frontend/src/components/global/k8s-logo.tsx b/services/frontend/src/components/global/k8s-logo.tsx new file mode 100644 index 0000000..9faf79c --- /dev/null +++ b/services/frontend/src/components/global/k8s-logo.tsx @@ -0,0 +1,26 @@ +const K8sLogo = () => { + return ( + + + + + + ); +}; + +export default K8sLogo; diff --git a/services/frontend/src/components/useJsPlumb.ts b/services/frontend/src/components/useJsPlumb.ts index d622486..295a4b5 100644 --- a/services/frontend/src/components/useJsPlumb.ts +++ b/services/frontend/src/components/useJsPlumb.ts @@ -433,13 +433,13 @@ export const useJsPlumb = ( useEffect(() => { eventBus.on("GENERATE", () => { - if (!instance) return; + if (!instanceRef.current) return; if (stateRef.current) { onGraphUpdate({ nodes: stateRef.current, connections: getConnections( - instance.getConnections({}, true) as Connection[] + instanceRef.current.getConnections({}, true) as Connection[] ) }); } diff --git a/services/frontend/src/constants/index.ts b/services/frontend/src/constants/index.ts index 0ca9c77..0e89549 100644 --- a/services/frontend/src/constants/index.ts +++ b/services/frontend/src/constants/index.ts @@ -4,3 +4,7 @@ export const REACT_APP_GITHUB_CLIENT_ID = export const REACT_APP_GITHUB_SCOPE = process.env.REACT_APP_GITHUB_SCOPE; export const PROJECTS_FETCH_LIMIT = 300; export const LOCAL_STORAGE = "CtkLocalStorage"; +export const manifestTypes = { + DOCKER_COMPOSE: "DOCKER_COMPOSE", + KUBERNETES: "KUBERNETES" +}; diff --git a/services/frontend/src/events/eventBus.ts b/services/frontend/src/events/eventBus.ts index 8214bf8..0d18654 100644 --- a/services/frontend/src/events/eventBus.ts +++ b/services/frontend/src/events/eventBus.ts @@ -7,7 +7,7 @@ const eventBus = { }, dispatch( event: string, - data: { message: { id: string } | { id: string } | { node: any } } + data: { message: { id: string } | { data: any } | { node: any } } ) { document.dispatchEvent(new CustomEvent(event, { detail: data })); }, diff --git a/services/frontend/src/hooks/useProject.ts b/services/frontend/src/hooks/useProject.ts index ab4178e..1622bef 100644 --- a/services/frontend/src/hooks/useProject.ts +++ b/services/frontend/src/hooks/useProject.ts @@ -137,6 +137,7 @@ export const useUpdateProject = (uuid: string | undefined) => { }, { onSuccess: (projectData) => { + toaster("Project saved!", "success"); queryClient.setQueryData(["projects", uuid], projectData); } } @@ -157,9 +158,9 @@ export const useDeleteProject = (uuid: string | undefined) => { return data; } catch (err: any) { if (err.response.status === 404) { - // console.error("Resource could not be found!"); + toaster("Resource could not be found!", "error"); } else { - // console.error(err.message); + toaster(err.message, "error"); } } }, @@ -180,6 +181,7 @@ export const useDeleteProject = (uuid: string | undefined) => { } else { queryClient.invalidateQueries(["projects"]); } + toaster("Project deleted!", "success"); } } ); diff --git a/services/frontend/src/services/generate.ts b/services/frontend/src/services/generate.ts index c6d3901..1f8364c 100644 --- a/services/frontend/src/services/generate.ts +++ b/services/frontend/src/services/generate.ts @@ -1,7 +1,17 @@ +import { manifestTypes } from "../constants"; import { API_SERVER_URL } from "../constants"; -export const generateHttp = (data: string) => { - return fetch(`${API_SERVER_URL}/generate/`, { +export const generateHttp = (data: string, manifest: string) => { + let endpoint = `${API_SERVER_URL}/generate/`; + if (manifest === manifestTypes.DOCKER_COMPOSE) { + endpoint += "docker-compose"; + } + + if (manifest === manifestTypes.KUBERNETES) { + endpoint += "kubernetes"; + } + + return fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts index eadd3c5..825fea6 100644 --- a/services/frontend/src/types/index.ts +++ b/services/frontend/src/types/index.ts @@ -21,6 +21,7 @@ export interface IServiceNodePosition { export interface IProject { id: number; name: string; + visibility: number; uuid: string; data: string; created_at: string;