├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── COMMIT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── deploy ├── Dockerfile ├── Makefile └── run.sh ├── docker-compose.yml └── services ├── backend ├── Dockerfile ├── Makefile ├── configs │ ├── supervisor │ │ └── api.conf │ └── uwsgi │ │ ├── uwsgi.ini │ │ └── uwsgi_params ├── docker-entrypoint.sh ├── requirements.txt └── src │ ├── api │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── filters.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_project_uuid.py │ │ ├── 0003_project_visibility.py │ │ └── __init__.py │ ├── models │ │ ├── __init__.py │ │ └── project.py │ ├── routing.py │ ├── serializers.py │ ├── tests.py │ └── views │ │ ├── auth.py │ │ ├── generate.py │ │ ├── project.py │ │ ├── user.py │ │ ├── utils.py │ │ └── view.py │ ├── main │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py │ ├── manage.py │ └── organizations │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── tests.py │ ├── utils.py │ └── views.py └── frontend ├── .env.development ├── .env.production ├── .eslintrc ├── .prettierrc ├── Dockerfile ├── configs └── nginx │ └── default.conf ├── package.json ├── postcss.config.js ├── public ├── favicon.png ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.test.tsx ├── App.tsx ├── components │ ├── Auth │ │ ├── GitHub │ │ │ ├── LoginBtn.tsx │ │ │ └── index.tsx │ │ ├── Login │ │ │ └── index.tsx │ │ └── Signup │ │ │ └── index.tsx │ ├── Canvas │ │ ├── NodeIcon.tsx │ │ ├── Popover.tsx │ │ ├── ServiceNode.tsx │ │ ├── VolumeNode.tsx │ │ └── index.tsx │ ├── CodeEditor │ │ ├── index.tsx │ │ ├── themes │ │ │ ├── highlight │ │ │ │ └── dark.ts │ │ │ └── ui │ │ │ │ └── dark.ts │ │ └── useCodeMirror.tsx │ ├── FormModal.tsx │ ├── GridColumn.tsx │ ├── GridRow.tsx │ ├── Modal.tsx │ ├── Profile │ │ └── index.tsx │ ├── Project │ │ ├── CodeBox.tsx │ │ ├── Header.tsx │ │ ├── ManifestSelect.tsx │ │ └── index.tsx │ ├── Projects │ │ ├── PreviewBlock.tsx │ │ └── index.tsx │ ├── Record.tsx │ ├── Records.tsx │ ├── ScrollView.tsx │ ├── SuperForm.tsx │ ├── SuperFormProvider.tsx │ ├── Tab.tsx │ ├── Tabs.tsx │ ├── global │ │ ├── DarkModeSwitch │ │ │ ├── index.tsx │ │ │ └── userDarkMode.tsx │ │ ├── FormElements │ │ │ ├── TextField.tsx │ │ │ └── Toggle.tsx │ │ ├── Pagination.tsx │ │ ├── Search.tsx │ │ ├── SideBar.tsx │ │ ├── Spinner.tsx │ │ ├── UserMenu.tsx │ │ ├── VisibilitySwitch │ │ │ └── index.tsx │ │ ├── dc-logo.tsx │ │ ├── k8s-logo.tsx │ │ └── logo.tsx │ ├── modals │ │ ├── ConfirmDelete.tsx │ │ ├── docker-compose │ │ │ ├── network │ │ │ │ ├── CreateNetworkModal.tsx │ │ │ │ ├── EditNetworkModal.tsx │ │ │ │ ├── EmptyNetworks.tsx │ │ │ │ ├── General.tsx │ │ │ │ ├── IPAM.tsx │ │ │ │ ├── NetworkList.tsx │ │ │ │ ├── form-utils.ts │ │ │ │ └── index.tsx │ │ │ ├── service │ │ │ │ ├── Accordion.tsx │ │ │ │ ├── Build.tsx │ │ │ │ ├── Create.tsx │ │ │ │ ├── Data.tsx │ │ │ │ ├── Deploy.tsx │ │ │ │ ├── Edit.tsx │ │ │ │ ├── Environment.tsx │ │ │ │ ├── General.tsx │ │ │ │ └── form-utils.ts │ │ │ └── volume │ │ │ │ ├── CreateVolumeModal.tsx │ │ │ │ ├── EditVolumeModal.tsx │ │ │ │ ├── General.tsx │ │ │ │ └── form-utils.ts │ │ └── import │ │ │ ├── form-utils.ts │ │ │ └── index.tsx │ └── useJsPlumb.ts ├── constants │ └── index.ts ├── contexts │ ├── SuperFormContext.tsx │ ├── TabContext.tsx │ └── index.ts ├── events │ └── eventBus.ts ├── hooks │ ├── auth.ts │ ├── index.ts │ ├── useAccordionState.ts │ ├── useImportProject.ts │ ├── useLocalStorageJWTKeys.ts │ ├── useProject.ts │ ├── useProjects.ts │ ├── useSocialAuth.ts │ ├── useSuperForm.ts │ ├── useTabContext.ts │ ├── useTitle.ts │ └── useWindowDimensions.ts ├── index.css ├── index.tsx ├── partials │ ├── ProtectedRoute.tsx │ └── useClickOutside.tsx ├── react-app-env.d.ts ├── reducers │ └── index.ts ├── reportWebVitals.ts ├── services │ ├── auth.ts │ ├── generate.ts │ ├── helpers.ts │ └── utils.ts ├── setupTests.ts ├── types │ ├── enums.ts │ └── index.ts └── utils │ ├── clickOutside.tsx │ ├── data │ ├── libraries.ts │ └── startConfig.ts │ ├── forms.tsx │ ├── generators.ts │ ├── index.ts │ ├── options.ts │ ├── position.ts │ ├── styles.tsx │ └── theme.tsx ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": false, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "semi": false, 7 | "bracketSpacing": true, 8 | "trailingComma": "none", 9 | "arrowParens": "avoid" 10 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.lineHeight": 20, 3 | "editor.tabSize": 2, 4 | "editor.formatOnSave": false, 5 | "editor.renderWhitespace": "all", 6 | "editor.renderControlCharacters": true, 7 | "files.eol": "\n", 8 | "python.formatting.provider": "black" 9 | } -------------------------------------------------------------------------------- /COMMIT.md: -------------------------------------------------------------------------------- 1 | # Conventional Commits 2 | 3 | The commit message should be structured as follows: 4 | 5 | ``` 6 | [optional scope]: 7 | 8 | [optional body] 9 | 10 | [optional footer(s)] 11 | ``` 12 | 13 | The commit contains the following structural elements, to communicate intent to the consumers of your library: 14 | 15 | 1. fix: a commit of the type fix patches a bug in your codebase (this correlates with [PATCH](https://semver.org/#summary) in Semantic Versioning). 16 | 1. feat: a commit of the type feat introduces a new feature to the codebase (this correlates with [MINOR](https://semver.org/#summary) in Semantic Versioning). 17 | 1. BREAKING CHANGE: a commit that has a footer BREAKING CHANGE:, or appends a ! after the type/scope, introduces a breaking API change (correlating with [MAJOR](https://semver.org/#summary) in Semantic Versioning). A BREAKING CHANGE can be part of commits of any type. 18 | 1. types other than fix: and feat: are allowed, for example [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) (based on the [the Angular convention](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines)) recommends build:, chore:, ci:, docs:, style:, refactor:, perf:, test:, and others. 19 | 1. footers other than BREAKING CHANGE: may be provided and follow a convention similar to [git trailer format](https://git-scm.com/docs/git-interpret-trailers). 20 | 21 | Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE). A scope may be provided to a commit’s type, to provide additional contextual information and is contained within parenthesis, e.g., feat(parser): add ability to parse arrays. 22 | 23 | ### Specification 24 | The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt). 25 | 26 | 1. Commits MUST be prefixed with a type, which consists of a noun, feat, fix, etc., followed by the OPTIONAL scope, OPTIONAL !, and REQUIRED terminal colon and space. 27 | 1. The type feat MUST be used when a commit adds a new feature to your application or library. 28 | 1. The type fix MUST be used when a commit represents a bug fix for your application. 29 | 1. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., fix(parser): 30 | 1. A description MUST immediately follow the colon and space after the type/scope prefix. The description is a short summary of the code changes, e.g., fix: array parsing issue when multiple spaces were contained in string. 31 | 1. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description. 32 | 1. A commit body is free-form and MAY consist of any number of newline separated paragraphs. 33 | 1. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a : or # separator, followed by a string value (this is inspired by the [git trailer convention](https://git-scm.com/docs/git-interpret-trailers). 34 | 1. A footer’s token MUST use - in place of whitespace characters, e.g., Acked-by (this helps differentiate the footer section from a multi-paragraph body). An exception is made for BREAKING CHANGE, which MAY also be used as a token. 35 | 1. A footer’s value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer token/separator pair is observed. 36 | 1. Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the footer. 37 | 1. If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., BREAKING CHANGE: environment variables now take precedence over config files. 38 | 1. If included in the type/scope prefix, breaking changes MUST be indicated by a ! immediately before the :. If ! is used, BREAKING CHANGE: MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change. 39 | 1. Types other than feat and fix MAY be used in your commit messages, e.g., docs: updated ref docs. 40 | 1. The units of information that make up Conventional Commits MUST NOT be treated as case sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase. 41 | BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing # 2 | 3 | :heart: Thanks for taking the time and for your help improving this project! 4 | 5 | ## Submitting a Pull Request ## 6 | 7 | Do you have an improvement? 8 | 9 | 1. Submit an [issue][issue] describing your proposed change. 10 | 2. I will try to respond to your issue promptly. 11 | 3. Fork this repo, develop and test your code changes. 12 | 4. Submit a pull request against this repo's `master` branch. 13 | - Include instructions on how to test your changes. 14 | 5. Your branch may be merged once all configured checks pass. 15 | 16 | ## Committing ## 17 | 18 | Squash or rebase commits so that all changes from a branch are 19 | committed to master as a single commit. All pull requests are squashed when 20 | merged, but rebasing prior to merge gives you better control over the commit 21 | message. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ORGANIZATION = corpulent 2 | CONTAINER = ctk-server 3 | VERSION = 0.1.0 4 | 5 | .PHONY : validate build pull up down down_clean reset run backend_dev shell_server shell_nginx local_setup local_build 6 | 7 | validate : 8 | docker compose config 9 | 10 | build : validate 11 | docker compose build 12 | 13 | pull : 14 | docker compose pull 15 | 16 | up : 17 | docker compose up -d 18 | 19 | up_local : 20 | docker compose up -d --no-build 21 | 22 | down : 23 | docker compose down 24 | 25 | down_clean : down 26 | -docker volume rm ctk_postgres_data 27 | -docker volume rm ctk_django_static 28 | 29 | reset : down 30 | make up 31 | 32 | dev_server : 33 | docker exec -ti $(CONTAINER) python /home/server/manage.py runserver 0.0.0.0:9001 34 | 35 | shell_server: 36 | docker exec -it ${CONTAINER} bash 37 | 38 | frontend_build: 39 | @ cd ./services/frontend/src && npm install && npm run build 40 | 41 | local_server_init: 42 | docker exec -it ${CONTAINER} python /home/server/manage.py makemigrations \ 43 | && docker exec -it ${CONTAINER} python /home/server/manage.py migrate \ 44 | && docker exec -it ${CONTAINER} python /home/server/manage.py collectstatic --noinput 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Container ToolKit 2 | 3 | Visually generate docker compose & kubernetes manifests. 4 | 5 | ![Alt text](https://ctk-public.s3.amazonaws.com/ui.png?raw=true "UI") 6 | 7 | ## Local setup and development 8 | 9 | On a Mac/Linux/Windows you need Docker, Docker Compose installed. Optionally GCC to run make commands for convenience, or just run the commands from the Makefile by hand. 10 | 11 | To get the tool working locally, just run: 12 | 13 | ```shell script 14 | make up 15 | make local_server_init 16 | make dev_server 17 | cd services/frontend && npm i && npm run start 18 | ``` 19 | 20 | ### Server 21 | 22 | ```bash 23 | make up 24 | make local_server_init 25 | make dev_server 26 | ``` 27 | 28 | ... this command will bring up the backend, the database, sync migrations, 29 | 30 | ## Docs 31 | 32 | - https://docs.jsplumbtoolkit.com/community/ 33 | - https://github.com/compose-spec/compose-spec/blob/master/spec.md 34 | - https://docs.docker.com/compose/compose-file/ 35 | -------------------------------------------------------------------------------- /deploy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | WORKDIR /usr/src 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y \ 7 | software-properties-common \ 8 | build-essential 9 | 10 | RUN apt-get -y update \ 11 | && apt-get -y install \ 12 | zip \ 13 | git \ 14 | wget \ 15 | curl \ 16 | dos2unix \ 17 | awscli \ 18 | && apt-get clean 19 | 20 | COPY ./run.sh run.sh 21 | RUN chmod +x run.sh 22 | 23 | ENTRYPOINT ["/bin/bash", "run.sh"] 24 | -------------------------------------------------------------------------------- /deploy/Makefile: -------------------------------------------------------------------------------- 1 | ORGANIZATION = corpulent 2 | CONTAINER = ctk-backend-build 3 | VERSION = 0.1.0 4 | 5 | ifneq (,$(wildcard ./.env)) 6 | include .env 7 | export 8 | endif 9 | 10 | .PHONY : build-image 11 | 12 | build-image : 13 | docker build -t $(ORGANIZATION)/$(CONTAINER):$(VERSION) . 14 | 15 | run : 16 | docker run --rm --name $(CONTAINER) \ 17 | --env AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \ 18 | --env AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \ 19 | --env ACTION=${ACTION} \ 20 | --env STAGE=${STAGE} \ 21 | -v ${PWD}/../services/backend/src:/usr/src/src \ 22 | -v ${PWD}/../services/backend/requirements.txt:/usr/src/requirements.txt \ 23 | -v ${PWD}/run.sh:/usr/src/run.sh \ 24 | $(ORGANIZATION)/$(CONTAINER):$(VERSION) 25 | 26 | shell : 27 | docker run -it --rm --name $(CONTAINER) \ 28 | --entrypoint /bin/bash \ 29 | --env AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \ 30 | --env AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \ 31 | --env ACTION=${ACTION} \ 32 | --env STAGE=${STAGE} \ 33 | -v ${PWD}/../services/backend/src:/usr/src/src \ 34 | -v ${PWD}/../services/backend/requirements.txt:/usr/src/requirements.txt \ 35 | -v ${PWD}/run.sh:/usr/src/run.sh \ 36 | $(ORGANIZATION)/$(CONTAINER):$(VERSION) 37 | -------------------------------------------------------------------------------- /deploy/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | _ACTION=${ACTION:-} 6 | _STAGE=${STAGE:-} 7 | 8 | if [[ -z $_ACTION || -z $_STAGE ]]; then 9 | echo 'make sure action and stage are specified' 10 | exit 1 11 | fi 12 | 13 | python -m venv .env 14 | source .env/bin/activate 15 | pip install -r requirements.txt 16 | pip install zappa==0.54.2 17 | 18 | cd src 19 | 20 | zappa ${_ACTION} ${_STAGE} 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | volumes: 4 | postgres-data: 5 | driver: local 6 | name: ctk_postgres_data 7 | django-static: 8 | driver: local 9 | name: ctk_django_static 10 | 11 | services: 12 | postgres: 13 | container_name: ctk-postgres 14 | image: postgres:11 15 | ports: 16 | - 5432:5432 17 | volumes: 18 | - postgres-data:/var/lib/postgresql/data 19 | environment: 20 | - POSTGRES_PASSWORD=postgres 21 | 22 | backend: 23 | container_name: ctk-server 24 | restart: always 25 | build: 26 | context: ./ 27 | dockerfile: ./services/backend/Dockerfile 28 | image: corpulent/ctk-api:1.0.0 29 | working_dir: /home 30 | depends_on: 31 | - postgres 32 | links: 33 | - postgres 34 | volumes: 35 | - ./services/backend/src:/home/server/ 36 | - ./services/backend/configs:/home/configs/ 37 | - django-static:/static/ 38 | ports: 39 | - "9001:9001" 40 | - "9000:9000" 41 | environment: 42 | - DB_REMOTE=False 43 | - APP_URL= 44 | 45 | frontend: 46 | container_name: ctk-frontend 47 | restart: always 48 | build: 49 | context: ./ 50 | dockerfile: ./services/frontend/Dockerfile 51 | image: corpulent/ctk-frontend:1.0.0 52 | volumes: 53 | - ./services/frontend/configs/nginx/default.conf:/etc/nginx/conf.d/default.conf 54 | - ./services/frontend/build:/usr/share/nginx/html/ 55 | ports: 56 | - "8080:8080" 57 | -------------------------------------------------------------------------------- /services/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | WORKDIR /home 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y \ 7 | software-properties-common \ 8 | build-essential 9 | 10 | RUN apt-get update && \ 11 | apt-get install -y \ 12 | postgresql \ 13 | postgresql-contrib \ 14 | wget \ 15 | nano \ 16 | lsof \ 17 | curl \ 18 | supervisor && \ 19 | rm -rf /var/lib/apt/lists/* 20 | 21 | RUN wget https://github.com/kubernetes/kompose/releases/download/v1.26.1/kompose_1.26.1_amd64.deb 22 | RUN apt install ./kompose_1.26.1_amd64.deb && \ 23 | rm kompose_1.26.1_amd64.deb 24 | 25 | RUN useradd uwsgi && adduser uwsgi root 26 | RUN useradd supervisor && adduser supervisor root 27 | 28 | COPY ./services/backend/requirements.txt ./requirements.txt 29 | RUN pip install --upgrade pip && \ 30 | pip install -r ./requirements.txt && \ 31 | rm ./requirements.txt 32 | 33 | COPY ./services/backend/src ./server 34 | COPY ./services/backend/configs/supervisor/api.conf /etc/supervisor/conf.d/api.conf 35 | COPY ./services/backend/configs/uwsgi ./configs/uwsgi 36 | 37 | EXPOSE 9000 9001 38 | 39 | HEALTHCHECK CMD curl --fail http://localhost:9000/v1 || exit 1 40 | 41 | CMD ["/usr/local/bin/uwsgi", "--ini", "/home/configs/uwsgi/uwsgi.ini"] 42 | -------------------------------------------------------------------------------- /services/backend/Makefile: -------------------------------------------------------------------------------- 1 | ORGANIZATION = agolub 2 | CONTAINER = ctk-server 3 | VERSION = 0.1.0 4 | 5 | .PHONY: build 6 | 7 | build : 8 | docker build -t $(ORGANIZATION)/$(CONTAINER):$(VERSION) . 9 | -------------------------------------------------------------------------------- /services/backend/configs/supervisor/api.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:app] 5 | priority=1 6 | user=uwsgi 7 | command=/usr/local/bin/uwsgi --ini /home/config/uwsgi/uwsgi.ini 8 | autorestart=false 9 | -------------------------------------------------------------------------------- /services/backend/configs/uwsgi/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | ini = :base 3 | 4 | # use socket option a third-party router (nginx), 5 | # use http option to set uwsgi to accept incoming 6 | # HTTP requests and route them by itself 7 | http = 0.0.0.0:9000 8 | 9 | master = true 10 | processes = 5 11 | 12 | 13 | [base] 14 | chdir = /home/server 15 | module = main.wsgi:application 16 | chmod-socket=666 17 | uid = uwsgi 18 | gid = uwsgi 19 | 20 | 21 | [dev] -------------------------------------------------------------------------------- /services/backend/configs/uwsgi/uwsgi_params: -------------------------------------------------------------------------------- 1 | uwsgi_param QUERY_STRING $query_string; 2 | uwsgi_param REQUEST_METHOD $request_method; 3 | uwsgi_param CONTENT_TYPE $content_type; 4 | uwsgi_param CONTENT_LENGTH $content_length; 5 | 6 | uwsgi_param REQUEST_URI $request_uri; 7 | uwsgi_param PATH_INFO $document_uri; 8 | uwsgi_param DOCUMENT_ROOT $document_root; 9 | uwsgi_param SERVER_PROTOCOL $server_protocol; 10 | uwsgi_param HTTPS $https if_not_empty; 11 | 12 | uwsgi_param REMOTE_ADDR $remote_addr; 13 | uwsgi_param REMOTE_PORT $remote_port; 14 | uwsgi_param SERVER_PORT $server_port; 15 | uwsgi_param SERVER_NAME $server_name; -------------------------------------------------------------------------------- /services/backend/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [ "${DB_REMOTE}" = False ]; then 6 | echo "Waiting for Postgres..." 7 | 8 | while ! pg_isready -h "postgres" -p "5432" > /dev/null 2> /dev/null; do 9 | echo "Connecting to postgres Failed" 10 | sleep 1 11 | done 12 | 13 | >&2 echo "Postgres is up - executing command" 14 | fi 15 | 16 | rm -rf /tmp/uwsgi && \ 17 | mkdir -p /tmp/uwsgi && \ 18 | ln -s /home/config/uwsgi/uwsgi.ini /tmp/uwsgi/ 19 | 20 | /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf 21 | 22 | exec "$@" -------------------------------------------------------------------------------- /services/backend/requirements.txt: -------------------------------------------------------------------------------- 1 | django==4.0.4 2 | django-cors-headers==3.11.0 3 | djangorestframework==3.13.1 4 | djangorestframework-simplejwt==5.1.0 5 | django-storages==1.13.1 6 | drf-extensions==0.7.1 7 | dj-rest-auth[with_social]==2.2.4 8 | 9 | psycopg[binary]==3.0.12 10 | psycopg==3.0.12 11 | psycopg2-binary==2.9.3 12 | uwsgi==2.0.20 13 | botocore==1.24.46 14 | boto3==1.21.46 15 | requests==2.27.1 16 | pyaml==21.10.1 17 | ruamel.yaml==0.17.21 18 | networkx==2.8.5 19 | numpy==1.23.1 20 | -------------------------------------------------------------------------------- /services/backend/src/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctk-hq/ctk/a0390526216e3fc46bb82180d87998d3a62635f9/services/backend/src/api/__init__.py -------------------------------------------------------------------------------- /services/backend/src/api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Project 3 | 4 | 5 | class ProjectAdmin(admin.ModelAdmin): 6 | list_display = ( 7 | 'id', 8 | 'visibility', 9 | 'name', 10 | 'uuid', 11 | 'created_at', 12 | 'updated_at') 13 | 14 | 15 | admin.site.register(Project, ProjectAdmin) 16 | -------------------------------------------------------------------------------- /services/backend/src/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'api' 7 | -------------------------------------------------------------------------------- /services/backend/src/api/filters.py: -------------------------------------------------------------------------------- 1 | from rest_framework.filters import BaseFilterBackend 2 | from organizations.utils import get_user_org 3 | 4 | 5 | class FilterByOrg(BaseFilterBackend): 6 | def filter_queryset(self, request, queryset, view): 7 | org = get_user_org(request.user) 8 | queryset_filters = {"org": org} 9 | return queryset.filter(**queryset_filters) 10 | -------------------------------------------------------------------------------- /services/backend/src/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.4 on 2022-06-22 10:14 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ('organizations', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Project', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(default='Untitled', max_length=500)), 21 | ('data', models.TextField()), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('updated_at', models.DateTimeField(auto_now=True)), 24 | ('org', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='organizations.organization')), 25 | ], 26 | options={ 27 | 'verbose_name': 'Project', 28 | 'verbose_name_plural': 'Projects', 29 | 'ordering': ['-created_at'], 30 | }, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /services/backend/src/api/migrations/0002_project_uuid.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.4 on 2022-06-22 10:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='project', 15 | name='uuid', 16 | field=models.CharField(blank=True, max_length=500, null=True, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /services/backend/src/api/migrations/0003_project_visibility.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.4 on 2022-08-04 06:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0002_project_uuid'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='project', 15 | name='visibility', 16 | field=models.SmallIntegerField(default='1'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /services/backend/src/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctk-hq/ctk/a0390526216e3fc46bb82180d87998d3a62635f9/services/backend/src/api/migrations/__init__.py -------------------------------------------------------------------------------- /services/backend/src/api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .project import Project 2 | 3 | __all__ = [ 4 | "Project" 5 | ] 6 | -------------------------------------------------------------------------------- /services/backend/src/api/models/project.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from organizations.models import Organization 3 | 4 | 5 | class Project(models.Model): 6 | org = models.ForeignKey( 7 | Organization, 8 | blank=True, 9 | null=True, 10 | related_name="projects", 11 | on_delete=models.CASCADE, 12 | ) 13 | visibility = models.SmallIntegerField(blank=False, null=False, default="1") 14 | name = models.CharField(max_length=500, blank=False, null=False, default="Untitled") 15 | uuid = models.CharField(max_length=500, blank=True, null=True, unique=True) 16 | data = models.TextField(blank=False) 17 | created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) 18 | updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) 19 | 20 | class Meta: 21 | verbose_name = "Project" 22 | verbose_name_plural = "Projects" 23 | ordering = ["-created_at"] 24 | -------------------------------------------------------------------------------- /services/backend/src/api/routing.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from rest_framework_extensions.routers import ExtendedDefaultRouter 4 | 5 | from .views import project, generate, user, view, auth 6 | 7 | 8 | class DefaultRouterPlusPlus(ExtendedDefaultRouter): 9 | """DefaultRouter with optional trailing slash and drf-extensions nesting.""" 10 | 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | self.trailing_slash = r"/?" 14 | 15 | api_urls = [ 16 | path("", view.ViewGenericAPIView.as_view()), 17 | path("projects/", project.ProjectListCreateAPIView.as_view()), 18 | path("projects/import/", project.ProjectImportAPIView.as_view()), 19 | path("projects//", project.ProjectGenericAPIView.as_view()), 20 | path("generate/", generate.GenerateDockerComposeView.as_view()), 21 | path("generate/docker-compose", generate.GenerateDockerComposeView.as_view()), 22 | path("generate/kubernetes", generate.GenerateK8sView.as_view()), 23 | path("auth/self/", user.UserGenericAPIView.as_view()), 24 | path("auth/", include("dj_rest_auth.urls")), 25 | path("auth/github/", auth.GitHubLogin.as_view(), name="github_login"), 26 | path("auth/registration/", include("dj_rest_auth.registration.urls")), 27 | ] 28 | -------------------------------------------------------------------------------- /services/backend/src/api/serializers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from rest_framework import serializers 3 | from .models import Project 4 | 5 | 6 | class DataField(serializers.Field): 7 | def to_representation(self, value): 8 | return value 9 | 10 | def to_internal_value(self, value): 11 | return json.dumps(value) 12 | 13 | 14 | class ProjectSerializer(serializers.ModelSerializer): 15 | data = DataField() 16 | class Meta(object): 17 | model = Project 18 | fields = "__all__" 19 | 20 | 21 | class UserSelfSerializer(serializers.Serializer): 22 | pk = serializers.IntegerField() 23 | username = serializers.CharField(max_length=200) 24 | first_name = serializers.CharField(max_length=200) 25 | last_name = serializers.CharField(max_length=200) 26 | email = serializers.CharField(max_length=200) 27 | -------------------------------------------------------------------------------- /services/backend/src/api/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /services/backend/src/api/views/auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter 3 | from allauth.socialaccount.providers.oauth2.client import OAuth2Client 4 | from dj_rest_auth.registration.views import SocialLoginView 5 | 6 | APP_URL = os.environ.get("APP_URL", "") 7 | 8 | class GitHubLogin(SocialLoginView): 9 | adapter_class = GitHubOAuth2Adapter 10 | callback_url = f"{APP_URL}/github/cb" 11 | client_class = OAuth2Client 12 | -------------------------------------------------------------------------------- /services/backend/src/api/views/generate.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import re 3 | import io 4 | import shutil 5 | import json 6 | import subprocess as sp 7 | 8 | from ruamel.yaml import YAML 9 | from pathlib import Path 10 | from rest_framework import generics, status 11 | from rest_framework.response import Response 12 | from .utils import ( 13 | generate, clean_dict, get_random_string, read_dir) 14 | 15 | 16 | def generate_docker_compose(data): 17 | version = data.get('version', '3') 18 | services = data.get('services', None) 19 | volumes = data.get('volumes', None) 20 | networks = data.get('networks', None) 21 | 22 | return generate( 23 | services, 24 | volumes, 25 | networks, 26 | version=version, 27 | return_format='yaml') 28 | 29 | class GenerateDockerComposeView(generics.GenericAPIView): 30 | permission_classes = [] 31 | 32 | def get(self, request): 33 | return Response({}, status=status.HTTP_404_NOT_FOUND) 34 | 35 | def post(self, request, format=None): 36 | request_data = json.loads(request.data) 37 | code = generate_docker_compose(request_data["data"]) 38 | resp = {'code': code} 39 | return Response(resp, status=status.HTTP_200_OK) 40 | 41 | class GenerateK8sView(generics.GenericAPIView): 42 | permission_classes = [] 43 | 44 | def get(self, request): 45 | return Response({}, status=status.HTTP_404_NOT_FOUND) 46 | 47 | def post(self, request, format=None): 48 | resp = { 49 | 'code': "", 50 | 'error': "" 51 | } 52 | workdir = f"/tmp/{get_random_string(8)}" 53 | request_data = json.loads(request.data) 54 | omitted = clean_dict( 55 | request_data["data"], 56 | ["env_file", "build", "secrets", "profiles"]) 57 | docker_compose_code = generate_docker_compose(omitted) 58 | path = Path(workdir) 59 | path.mkdir(exist_ok=True) 60 | 61 | with open(f"{path}/docker-compose.yaml", 'w') as f: 62 | f.write(docker_compose_code) 63 | 64 | process = sp.Popen([ 65 | "kompose", 66 | "--suppress-warnings", 67 | "--file", 68 | f"{path}/docker-compose.yaml", "convert" 69 | ], cwd=workdir, stdout=sp.PIPE, stderr=sp.PIPE) 70 | process.wait() 71 | _, out = process.communicate() 72 | 73 | if out: 74 | out = out.decode("utf-8") 75 | parts = out.split(" ") 76 | parts.pop() 77 | parts.pop(0) 78 | final_list = [re.sub(r'\[.*?;.*?m', '', x) for x in parts if any(x)] 79 | resp["error"] = " ".join(final_list) 80 | 81 | workdir_files = read_dir(workdir) 82 | workdir_files.remove("docker-compose.yaml") 83 | 84 | for file in workdir_files: 85 | with open(f"{workdir}/{file}") as f: 86 | yaml = YAML() 87 | yaml.indent(mapping=2, sequence=4, offset=2) 88 | yaml.explicit_start = True 89 | data = yaml.load(f) 90 | 91 | with contextlib.suppress(KeyError): 92 | del data["metadata"]["annotations"] 93 | with contextlib.suppress(KeyError): 94 | del data["spec"]["template"]["metadata"]["annotations"] 95 | 96 | buf = io.BytesIO() 97 | yaml.dump(data, buf) 98 | resp["code"] += buf.getvalue().decode() 99 | 100 | if file != workdir_files[-1]: 101 | resp["code"] += "\n" 102 | 103 | shutil.rmtree(workdir) 104 | return Response(resp, status=status.HTTP_200_OK) 105 | -------------------------------------------------------------------------------- /services/backend/src/api/views/user.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics, status 2 | from rest_framework.permissions import IsAuthenticated 3 | from rest_framework.response import Response 4 | 5 | from api.serializers import UserSelfSerializer 6 | 7 | 8 | class UserGenericAPIView(generics.GenericAPIView): 9 | permission_classes = [IsAuthenticated] 10 | 11 | def get(self, request): 12 | return Response(UserSelfSerializer(request.user).data) 13 | -------------------------------------------------------------------------------- /services/backend/src/api/views/utils.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import contextlib 4 | import random 5 | import string 6 | from ruamel.yaml import YAML 7 | from ruamel.yaml.scalarstring import DoubleQuotedScalarString 8 | 9 | from api.models import Project 10 | 11 | 12 | try: 13 | import textwrap 14 | textwrap.indent 15 | except AttributeError: 16 | def indent(text, amount, ch=' '): 17 | padding = amount * ch 18 | return ''.join(padding+line for line in text.splitlines(True)) 19 | else: 20 | def indent(text, amount, ch=' '): 21 | return textwrap.indent(text, amount * ch) 22 | 23 | def get_project_obj_by_uuid(uuid): 24 | with contextlib.suppress(Project.DoesNotExist): 25 | return Project.objects.get(uuid=uuid) 26 | return None 27 | 28 | def sequence_indent_four(s): 29 | ret_val = '' 30 | first_indent = True 31 | 32 | for line in s.splitlines(True): 33 | ls = line.lstrip() 34 | indent = len(line) - len(ls) 35 | 36 | if indent == 2 and first_indent == False: 37 | ret_val += "\n" 38 | 39 | ret_val += line 40 | 41 | if indent == 2 and first_indent == True: 42 | first_indent = False 43 | 44 | return ret_val 45 | 46 | def sequence_indent_one(s): 47 | ret_val = '' 48 | first_indent = True 49 | 50 | for line in s.splitlines(True): 51 | ls = line.lstrip() 52 | indent = len(line) - len(ls) 53 | 54 | if indent == 0 and first_indent == False: 55 | ret_val += "\n" 56 | 57 | ret_val += line 58 | 59 | if indent == 0 and first_indent == True: 60 | first_indent = False 61 | 62 | return ret_val 63 | 64 | def get_version(version): 65 | try: 66 | return int(version) 67 | except ValueError: 68 | return float(version) 69 | 70 | def generate(services, volumes, networks, version="3", return_format='yaml'): 71 | if return_format != 'yaml': 72 | return 73 | 74 | s = io.StringIO() 75 | ret_yaml = YAML() 76 | ret_yaml.indent(mapping=2, sequence=4, offset=2) 77 | ret_yaml.preserve_quotes = True 78 | ret_yaml.explicit_start = True 79 | specified_version = get_version(version) 80 | base_version = int(specified_version) 81 | 82 | ret_yaml.dump({'version': DoubleQuotedScalarString(specified_version)}, s) 83 | ret_yaml.explicit_start = False 84 | s.write('\n') 85 | 86 | if services: 87 | if base_version in {2, 3}: 88 | ret_yaml.dump({'services': services}, s, transform=sequence_indent_four) 89 | 90 | if base_version == 1: 91 | ret_yaml.dump(services, s, transform=sequence_indent_one) 92 | 93 | s.write('\n') 94 | 95 | if base_version in {3, 2} and networks: 96 | ret_yaml.dump({'networks': networks}, s) 97 | s.write('\n') 98 | 99 | if volumes: 100 | ret_yaml.dump({'volumes': volumes}, s) 101 | 102 | s.seek(0) 103 | return s.read() 104 | 105 | def clean_dict(dic, omit=None): 106 | if type(dic) is dict: 107 | for key, item in dic.copy().items(): 108 | if omit and key in omit: 109 | del dic[key] 110 | elif type(item) is dict: 111 | dic[key] = clean_dict(item, omit) 112 | 113 | return dic 114 | 115 | def get_random_string(length): 116 | letters = string.ascii_lowercase 117 | return ''.join(random.choice(letters) for _ in range(length)) 118 | 119 | def read_dir(path): 120 | return [f for f in os.listdir(path) if os.path.isfile(f"{path}/{f}")] 121 | -------------------------------------------------------------------------------- /services/backend/src/api/views/view.py: -------------------------------------------------------------------------------- 1 | from rest_framework.response import Response 2 | from rest_framework import generics, status 3 | 4 | 5 | class ViewGenericAPIView(generics.GenericAPIView): 6 | permission_classes = [] 7 | 8 | def get(self, request): 9 | return Response({}, status=status.HTTP_200_OK) 10 | -------------------------------------------------------------------------------- /services/backend/src/main/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctk-hq/ctk/a0390526216e3fc46bb82180d87998d3a62635f9/services/backend/src/main/__init__.py -------------------------------------------------------------------------------- /services/backend/src/main/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /services/backend/src/main/urls.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional 2 | 3 | from django.contrib import admin 4 | from django.urls import URLPattern, include, path 5 | 6 | from api.routing import api_urls 7 | 8 | 9 | def opt_slash_path(route: str, view: Callable, name: Optional[str] = None) -> URLPattern: 10 | """Catches path with or without trailing slash, taking into account query param and hash.""" 11 | # Ignoring the type because while name can be optional on re_path, mypy doesn't agree 12 | return re_path(fr"^{route}/?(?:[?#].*)?$", view, name=name) # type: ignore 13 | 14 | 15 | urlpatterns = [ 16 | path('admin/', admin.site.urls), 17 | path("v1/", include(api_urls)), 18 | ] 19 | -------------------------------------------------------------------------------- /services/backend/src/main/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /services/backend/src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /services/backend/src/organizations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctk-hq/ctk/a0390526216e3fc46bb82180d87998d3a62635f9/services/backend/src/organizations/__init__.py -------------------------------------------------------------------------------- /services/backend/src/organizations/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Organization 3 | 4 | 5 | class OrganizationAdmin(admin.ModelAdmin): 6 | list_display = ("id", "name", "created_at", "updated_at") 7 | 8 | 9 | admin.site.register(Organization, OrganizationAdmin) 10 | -------------------------------------------------------------------------------- /services/backend/src/organizations/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OrganizationsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'organizations' 7 | -------------------------------------------------------------------------------- /services/backend/src/organizations/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.4 on 2022-05-31 14:47 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Organization', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(blank=True, max_length=255, null=True)), 21 | ('created_at', models.DateTimeField(auto_now_add=True)), 22 | ('updated_at', models.DateTimeField(auto_now=True)), 23 | ('users', models.ManyToManyField(related_name='orgs', to=settings.AUTH_USER_MODEL)), 24 | ], 25 | options={ 26 | 'verbose_name': 'Organization', 27 | 'verbose_name_plural': 'Organizations', 28 | 'ordering': ['-created_at'], 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /services/backend/src/organizations/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctk-hq/ctk/a0390526216e3fc46bb82180d87998d3a62635f9/services/backend/src/organizations/migrations/__init__.py -------------------------------------------------------------------------------- /services/backend/src/organizations/models.py: -------------------------------------------------------------------------------- 1 | from allauth.account.signals import user_signed_up, user_logged_in 2 | from django.db.models.signals import pre_delete 3 | from django.contrib.auth import get_user_model 4 | from django.dispatch import receiver 5 | from django.db import models 6 | 7 | 8 | User = get_user_model() 9 | 10 | 11 | class Organization(models.Model): 12 | name = models.CharField(max_length=255, blank=True, null=True) 13 | users = models.ManyToManyField(User, related_name="orgs") 14 | created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) 15 | updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) 16 | 17 | class Meta: 18 | verbose_name = "Organization" 19 | verbose_name_plural = "Organizations" 20 | ordering = ["-created_at"] 21 | 22 | def total_members(self): 23 | return self.users.count() 24 | 25 | def is_member(self, user): 26 | return user in self.users.all() 27 | 28 | def add_user(self, user): 29 | return self.users.add(user) 30 | 31 | def remove_user(self, user): 32 | return self.users.remove(user) 33 | 34 | def __str__(self): 35 | return f"{self.name}" 36 | 37 | 38 | @receiver(user_signed_up) 39 | def handler(sender, request, user, **kwargs): 40 | org_name = f"{user.username.lower()}-org" 41 | org = Organization.objects.create(name=org_name) 42 | org.add_user(user) 43 | 44 | 45 | @receiver(pre_delete, sender=User) 46 | def handler(instance, **kwargs): 47 | for org in instance.orgs.all(): 48 | org.remove_user(instance) 49 | if org.total_members() == 0: 50 | org.delete() 51 | -------------------------------------------------------------------------------- /services/backend/src/organizations/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /services/backend/src/organizations/utils.py: -------------------------------------------------------------------------------- 1 | def get_user_org(user): 2 | user_orgs = user.orgs.all() 3 | if user_orgs.count(): 4 | return user_orgs[0] 5 | -------------------------------------------------------------------------------- /services/backend/src/organizations/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /services/frontend/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_SERVER=http://localhost:9001/v1 -------------------------------------------------------------------------------- /services/frontend/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_API_SERVER=https://api.ctk.dev/v1 -------------------------------------------------------------------------------- /services/frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "prettier" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier" 13 | ], 14 | "rules": { 15 | "no-console": 1, // Means warning 16 | "prettier/prettier": 2, // Means error 17 | "no-empty-function": 1, 18 | "@typescript-eslint/no-empty-function": 1, 19 | "@typescript-eslint/no-explicit-any": [ 20 | "off" 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /services/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "printWidth": 80 5 | } -------------------------------------------------------------------------------- /services/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 as build 2 | 3 | WORKDIR /build 4 | COPY ./services/frontend/ . 5 | RUN npm install 6 | RUN npm run build 7 | 8 | FROM nginx:stable-alpine 9 | COPY --from=build /build/build /usr/share/nginx/html 10 | COPY --from=build /build/configs/nginx/default.conf /etc/nginx/conf.d/default.conf 11 | EXPOSE 8080 12 | CMD ["nginx", "-g", "daemon off;"] 13 | -------------------------------------------------------------------------------- /services/frontend/configs/nginx/default.conf: -------------------------------------------------------------------------------- 1 | # vi:syntax=nginx 2 | 3 | server { 4 | listen 8080; 5 | listen [::]:8080; 6 | server_name localhost; 7 | 8 | charset utf-8; 9 | 10 | location / { 11 | root /usr/share/nginx/html; 12 | index index.html index.htm; 13 | try_files $uri $uri/ /index.html; 14 | } 15 | 16 | error_page 500 502 503 504 /50x.html; 17 | location = /50x.html { 18 | root /usr/share/nginx/html; 19 | } 20 | } -------------------------------------------------------------------------------- /services/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ctk-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@codemirror/autocomplete": "^0.19.0", 7 | "@codemirror/closebrackets": "^0.19.0", 8 | "@codemirror/commands": "^0.19.0", 9 | "@codemirror/comment": "^0.19.0", 10 | "@codemirror/fold": "^0.19.0", 11 | "@codemirror/history": "^0.19.0", 12 | "@codemirror/lang-json": "^0.19.2", 13 | "@codemirror/lang-python": "^0.19.0", 14 | "@codemirror/legacy-modes": "^0.19.0", 15 | "@codemirror/lint": "^0.19.0", 16 | "@codemirror/matchbrackets": "^0.19.0", 17 | "@codemirror/rectangular-selection": "^0.19.0", 18 | "@codemirror/search": "^0.19.0", 19 | "@codemirror/stream-parser": "^0.19.9", 20 | "@codemirror/view": "^0.19.0", 21 | "@emotion/react": "^11.10.0", 22 | "@emotion/styled": "^11.10.0", 23 | "@heroicons/react": "^1.0.5", 24 | "@jsplumb/browser-ui": "^5.5.2", 25 | "@jsplumb/common": "^5.5.2", 26 | "@jsplumb/connector-bezier": "^5.5.2", 27 | "@jsplumb/core": "^5.5.2", 28 | "@jsplumb/util": "^5.5.2", 29 | "@mui/material": "^5.10.4", 30 | "@tailwindcss/forms": "^0.5.2", 31 | "@tailwindcss/typography": "^0.5.1", 32 | "@testing-library/jest-dom": "^5.16.2", 33 | "@testing-library/react": "^12.1.2", 34 | "@testing-library/user-event": "^13.5.0", 35 | "axios": "^0.27.2", 36 | "codemirror": "^5.65.5", 37 | "d3": "^7.3.0", 38 | "formik": "^2.2.9", 39 | "lodash": "^4.17.21", 40 | "react": "^17.0.2", 41 | "react-dom": "^17.0.2", 42 | "react-hot-toast": "^2.2.0", 43 | "react-query": "^3.39.1", 44 | "react-router-dom": "^6.3.0", 45 | "react-scripts": "5.0.0", 46 | "tailwindcss": "^3.0.19", 47 | "typescript": "^4.5.5", 48 | "uuid": "^8.3.2", 49 | "web-vitals": "^2.1.4", 50 | "yaml": "^1.10.2", 51 | "yup": "^0.32.11" 52 | }, 53 | "scripts": { 54 | "start": "react-scripts start", 55 | "build": "react-scripts build", 56 | "test": "react-scripts test", 57 | "eject": "react-scripts eject", 58 | "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", 59 | "lint": "eslint . --ext .ts" 60 | }, 61 | "eslintConfig": { 62 | "extends": [ 63 | "react-app", 64 | "react-app/jest" 65 | ] 66 | }, 67 | "browserslist": { 68 | "production": [ 69 | ">0.2%", 70 | "not dead", 71 | "not op_mini all" 72 | ], 73 | "development": [ 74 | "last 1 chrome version", 75 | "last 1 firefox version", 76 | "last 1 safari version" 77 | ] 78 | }, 79 | "devDependencies": { 80 | "@types/d3": "^7.1.0", 81 | "@types/jest": "^27.4.0", 82 | "@types/lodash": "^4.14.178", 83 | "@types/node": "^16.11.56", 84 | "@types/react": "^17.0.45", 85 | "@types/react-dom": "^17.0.11", 86 | "@types/uuid": "^8.3.4", 87 | "autoprefixer": "10.4.7", 88 | "eslint": "^8.19.0", 89 | "eslint-config-prettier": "^8.5.0", 90 | "eslint-plugin-prettier": "^4.2.1", 91 | "postcss": "^8.4.14", 92 | "prettier": "^2.7.1" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /services/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; -------------------------------------------------------------------------------- /services/frontend/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctk-hq/ctk/a0390526216e3fc46bb82180d87998d3a62635f9/services/frontend/public/favicon.png -------------------------------------------------------------------------------- /services/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Container Toolkit 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /services/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Visual Argo Workflows", 3 | "name": "Visual Argo Workflows", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /services/frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /services/frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /services/frontend/src/components/Auth/GitHub/LoginBtn.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | REACT_APP_GITHUB_CLIENT_ID, 3 | REACT_APP_GITHUB_SCOPE 4 | } from "../../../constants"; 5 | 6 | const LoginBtn = () => { 7 | return ( 8 | 30 | ); 31 | }; 32 | 33 | export default LoginBtn; 34 | -------------------------------------------------------------------------------- /services/frontend/src/components/Auth/GitHub/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate, useSearchParams } from "react-router-dom"; 3 | import { LOCAL_STORAGE } from "../../../constants"; 4 | import { toaster } from "../../../utils"; 5 | import { socialAuth } from "../../../hooks/useSocialAuth"; 6 | import { authLoginSuccess } from "../../../reducers"; 7 | import Spinner from "../../global/Spinner"; 8 | 9 | interface IGitHubProps { 10 | dispatch: any; 11 | } 12 | 13 | const GitHub = (props: IGitHubProps) => { 14 | const navigate = useNavigate(); 15 | const { dispatch } = props; 16 | const [searchParams] = useSearchParams(); 17 | const [loading, setLoading] = useState(false); 18 | const code = searchParams.get("code"); 19 | 20 | useEffect(() => { 21 | if (code) { 22 | setLoading(true); 23 | socialAuth(code) 24 | .then((data: any) => { 25 | localStorage.setItem( 26 | LOCAL_STORAGE, 27 | JSON.stringify({ 28 | access_token: data.access_token, 29 | refresh_token: data.refresh_token 30 | }) 31 | ); 32 | dispatch(authLoginSuccess(data)); 33 | navigate("/"); 34 | }) 35 | .catch(() => { 36 | localStorage.removeItem(LOCAL_STORAGE); 37 | navigate(`/login`); 38 | toaster(`Something went wrong! Session may have expired.`, "error"); 39 | }) 40 | .finally(() => { 41 | setLoading(false); 42 | }); 43 | } else { 44 | navigate(`/login`); 45 | } 46 | }, [code]); 47 | 48 | return ( 49 |
59 |
60 | {loading && ( 61 |
62 | 63 | logging in... 64 |
65 | )} 66 |
67 |
68 | ); 69 | }; 70 | 71 | export default GitHub; 72 | -------------------------------------------------------------------------------- /services/frontend/src/components/Canvas/NodeIcon.tsx: -------------------------------------------------------------------------------- 1 | import { DatabaseIcon, ServerIcon, ChipIcon } from "@heroicons/react/solid"; 2 | 3 | type NodeIconProps = { 4 | nodeType: string; 5 | }; 6 | 7 | const NodeIcon = ({ nodeType }: NodeIconProps) => { 8 | switch (nodeType) { 9 | case "SERVICE": 10 | return ( 11 | 12 | ); 13 | case "VOLUME": 14 | return ( 15 | 16 | ); 17 | case "NETWORK": 18 | return ( 19 | 20 | ); 21 | default: 22 | return ( 23 | 24 | ); 25 | } 26 | }; 27 | 28 | export default NodeIcon; 29 | -------------------------------------------------------------------------------- /services/frontend/src/components/Canvas/Popover.tsx: -------------------------------------------------------------------------------- 1 | import { TrashIcon, PencilIcon } from "@heroicons/react/solid"; 2 | import { CallbackFunction } from "../../types"; 3 | 4 | export const Popover = ({ 5 | onEditClick, 6 | onDeleteClick 7 | }: { 8 | onEditClick: CallbackFunction; 9 | onDeleteClick: CallbackFunction; 10 | }) => { 11 | return ( 12 |
13 |
14 | 15 |
16 | onDeleteClick()} 18 | className="w-3 h-3 text-red-400" 19 | > 20 | onEditClick()} 22 | className="w-3 h-3" 23 | > 24 |
25 |
26 |
27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /services/frontend/src/components/Canvas/ServiceNode.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { truncateStr } from "../../utils"; 3 | import { IServiceNodeItem, CallbackFunction } from "../../types"; 4 | import eventBus from "../../events/eventBus"; 5 | import { Popover } from "./Popover"; 6 | import NodeIcon from "./NodeIcon"; 7 | 8 | interface INodeProps { 9 | node: IServiceNodeItem; 10 | setServiceToEdit: CallbackFunction; 11 | setServiceToDelete: CallableFunction; 12 | } 13 | 14 | export default function ServiceNode(props: INodeProps) { 15 | const { node, setServiceToEdit, setServiceToDelete } = props; 16 | const [nodeDragging, setNodeDragging] = useState(); 17 | const [nodeHovering, setNodeHovering] = useState(); 18 | 19 | useEffect(() => { 20 | eventBus.on("EVENT_DRAG_START", (data: any) => { 21 | setNodeDragging(data.detail.message.id); 22 | }); 23 | 24 | eventBus.on("EVENT_DRAG_STOP", () => { 25 | setNodeDragging(null); 26 | }); 27 | 28 | return () => { 29 | eventBus.remove("EVENT_DRAG_START", () => undefined); 30 | eventBus.remove("EVENT_DRAG_STOP", () => undefined); 31 | }; 32 | }, []); 33 | 34 | return ( 35 |
setNodeHovering(node.key)} 41 | onMouseLeave={() => { 42 | if (nodeHovering === node.key) { 43 | setNodeHovering(null); 44 | } 45 | }} 46 | > 47 | {nodeHovering === node.key && nodeDragging !== node.key && ( 48 | { 50 | setServiceToEdit(node); 51 | }} 52 | onDeleteClick={() => { 53 | setServiceToDelete(node); 54 | }} 55 | > 56 | )} 57 |
58 | <> 59 | {node.canvasConfig.node_name && ( 60 |
61 | {truncateStr(node.canvasConfig.node_name, 12)} 62 |
63 | )} 64 | 65 | {node.serviceConfig.container_name && ( 66 |
67 | {truncateStr(node.serviceConfig.container_name, 20)} 68 |
69 | )} 70 | 71 | 72 | 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /services/frontend/src/components/Canvas/VolumeNode.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { truncateStr } from "../../utils"; 3 | import { IVolumeNodeItem, CallbackFunction } from "../../types"; 4 | import eventBus from "../../events/eventBus"; 5 | import { Popover } from "./Popover"; 6 | import NodeIcon from "./NodeIcon"; 7 | 8 | interface INodeProps { 9 | node: IVolumeNodeItem; 10 | setVolumeToEdit: CallbackFunction; 11 | setVolumeToDelete: CallableFunction; 12 | } 13 | 14 | export default function VolumeNode(props: INodeProps) { 15 | const { node, setVolumeToEdit, setVolumeToDelete } = props; 16 | const [nodeDragging, setNodeDragging] = useState(); 17 | const [nodeHovering, setNodeHovering] = useState(); 18 | 19 | useEffect(() => { 20 | eventBus.on("EVENT_DRAG_START", (data: any) => { 21 | setNodeDragging(data.detail.message.id); 22 | }); 23 | 24 | eventBus.on("EVENT_DRAG_STOP", () => { 25 | setNodeDragging(null); 26 | }); 27 | 28 | return () => { 29 | eventBus.remove("EVENT_DRAG_START", () => undefined); 30 | eventBus.remove("EVENT_DRAG_STOP", () => undefined); 31 | }; 32 | }, []); 33 | 34 | return ( 35 |
setNodeHovering(node.key)} 41 | onMouseLeave={() => { 42 | if (nodeHovering === node.key) { 43 | setNodeHovering(null); 44 | } 45 | }} 46 | > 47 | {nodeHovering === node.key && nodeDragging !== node.key && ( 48 | { 50 | setVolumeToEdit(node); 51 | }} 52 | onDeleteClick={() => { 53 | setVolumeToDelete(node); 54 | }} 55 | > 56 | )} 57 |
58 | <> 59 | {node.canvasConfig.node_name && ( 60 |
61 | {truncateStr(node.canvasConfig.node_name, 12)} 62 |
63 | )} 64 | 65 | {node.volumeConfig.name && ( 66 |
67 | {truncateStr(node.volumeConfig.name, 20)} 68 |
69 | )} 70 | 71 | 72 | 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /services/frontend/src/components/CodeEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import { StreamLanguage } from "@codemirror/stream-parser"; 2 | import { EditorState } from "@codemirror/state"; 3 | import { 4 | EditorView, 5 | highlightSpecialChars, 6 | drawSelection, 7 | highlightActiveLine, 8 | keymap 9 | } from "@codemirror/view"; 10 | import { jsonLanguage } from "@codemirror/lang-json"; 11 | import { yaml } from "@codemirror/legacy-modes/mode/yaml"; 12 | 13 | import { history, historyKeymap } from "@codemirror/history"; 14 | import { foldGutter, foldKeymap } from "@codemirror/fold"; 15 | import { bracketMatching } from "@codemirror/matchbrackets"; 16 | import { closeBrackets, closeBracketsKeymap } from "@codemirror/closebrackets"; 17 | import { searchKeymap, highlightSelectionMatches } from "@codemirror/search"; 18 | import { autocompletion, completionKeymap } from "@codemirror/autocomplete"; 19 | import { rectangularSelection } from "@codemirror/rectangular-selection"; 20 | import { commentKeymap } from "@codemirror/comment"; 21 | import { lintKeymap } from "@codemirror/lint"; 22 | import { indentOnInput, LanguageSupport } from "@codemirror/language"; 23 | import { lineNumbers } from "@codemirror/gutter"; 24 | import { defaultKeymap, indentMore, indentLess } from "@codemirror/commands"; 25 | import { defaultHighlightStyle } from "@codemirror/highlight"; 26 | import { solarizedDark } from "./themes/ui/dark"; 27 | import darkHighlightStyle from "./themes/highlight/dark"; 28 | import useCodeMirror from "./useCodeMirror"; 29 | 30 | interface ICodeEditorProps { 31 | data: string; 32 | language: string; 33 | onChange: any; 34 | disabled: boolean; 35 | lineWrapping: boolean; 36 | height: number; 37 | } 38 | 39 | const languageExtensions: any = { 40 | json: [new LanguageSupport(jsonLanguage)], 41 | yaml: [StreamLanguage.define(yaml)], 42 | blank: undefined 43 | }; 44 | 45 | const themeExtensions = { 46 | light: [defaultHighlightStyle], 47 | dark: [solarizedDark] 48 | }; 49 | 50 | const highlightExtensions = { 51 | dark: darkHighlightStyle 52 | }; 53 | 54 | const CodeEditor = (props: ICodeEditorProps) => { 55 | const { data, language, onChange, disabled, lineWrapping, height } = props; 56 | const extensions = [ 57 | [ 58 | lineNumbers(), 59 | highlightSpecialChars(), 60 | history(), 61 | foldGutter(), 62 | drawSelection(), 63 | indentOnInput(), 64 | bracketMatching(), 65 | closeBrackets(), 66 | autocompletion(), 67 | rectangularSelection(), 68 | highlightActiveLine(), 69 | highlightSelectionMatches(), 70 | ...(languageExtensions[language] ? languageExtensions[language] : []), 71 | keymap.of([ 72 | ...closeBracketsKeymap, 73 | ...defaultKeymap, 74 | ...searchKeymap, 75 | ...historyKeymap, 76 | ...foldKeymap, 77 | ...commentKeymap, 78 | ...completionKeymap, 79 | ...lintKeymap, 80 | { 81 | key: "Tab", 82 | preventDefault: true, 83 | run: indentMore 84 | }, 85 | { 86 | key: "Shift-Tab", 87 | preventDefault: true, 88 | run: indentLess 89 | } 90 | /*{ 91 | key: "Ctrl-S", 92 | preventDefault: true, 93 | run: indentLess, 94 | }*/ 95 | ]), 96 | EditorView.updateListener.of((update) => { 97 | if (update.changes) { 98 | onChange(update.state.doc.toString()); 99 | } 100 | }), 101 | EditorState.allowMultipleSelections.of(true), 102 | ...(disabled 103 | ? [EditorState.readOnly.of(true)] 104 | : [EditorState.readOnly.of(false)]), 105 | ...(lineWrapping ? [EditorView.lineWrapping] : []), 106 | ...[themeExtensions["dark"]], 107 | ...[highlightExtensions["dark"]] 108 | ] 109 | ]; 110 | 111 | const { ref } = useCodeMirror(extensions, data); 112 | 113 | return ( 114 |
119 | ); 120 | }; 121 | 122 | export default CodeEditor; 123 | -------------------------------------------------------------------------------- /services/frontend/src/components/CodeEditor/themes/highlight/dark.ts: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/codemirror/theme-one-dark 2 | // Copyright (C) 2018-2021 by Marijn Haverbeke and others 3 | // MIT License: https://github.com/codemirror/theme-one-dark/blob/main/LICENSE 4 | 5 | import { HighlightStyle, tags as t } from "@codemirror/highlight"; 6 | 7 | const chalky = "#e5c07b", 8 | coral = "#e06c75", 9 | cyan = "#56b6c2", 10 | invalid = "#ffffff", 11 | ivory = "#abb2bf", 12 | stone = "#5c6370", 13 | malibu = "#61afef", 14 | sage = "#98c379", 15 | whiskey = "#d19a66", 16 | violet = "#c678dd"; 17 | 18 | /// The highlighting style for code in the One Dark theme. 19 | export default HighlightStyle.define([ 20 | { 21 | tag: t.keyword, 22 | color: violet 23 | }, 24 | { 25 | tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], 26 | color: coral 27 | }, 28 | { 29 | tag: [t.processingInstruction, t.string, t.inserted], 30 | color: sage 31 | }, 32 | { 33 | tag: [t.function(t.variableName), t.labelName], 34 | color: malibu 35 | }, 36 | { 37 | tag: [t.color, t.constant(t.name), t.standard(t.name)], 38 | color: whiskey 39 | }, 40 | { 41 | tag: [t.definition(t.name), t.separator], 42 | color: ivory 43 | }, 44 | { 45 | tag: [ 46 | t.typeName, 47 | t.className, 48 | t.number, 49 | t.changed, 50 | t.annotation, 51 | t.modifier, 52 | t.self, 53 | t.namespace 54 | ], 55 | color: chalky 56 | }, 57 | { 58 | tag: [ 59 | t.operator, 60 | t.operatorKeyword, 61 | t.url, 62 | t.escape, 63 | t.regexp, 64 | t.link, 65 | t.special(t.string) 66 | ], 67 | color: cyan 68 | }, 69 | { 70 | tag: [t.meta, t.comment], 71 | color: stone 72 | }, 73 | { 74 | tag: t.strong, 75 | fontWeight: "bold" 76 | }, 77 | { 78 | tag: t.emphasis, 79 | fontStyle: "italic" 80 | }, 81 | { 82 | tag: t.link, 83 | color: stone, 84 | textDecoration: "underline" 85 | }, 86 | { 87 | tag: t.heading, 88 | fontWeight: "bold", 89 | color: coral 90 | }, 91 | { 92 | tag: [t.atom, t.bool, t.special(t.variableName)], 93 | color: whiskey 94 | }, 95 | { 96 | tag: t.invalid, 97 | color: invalid 98 | } 99 | ]); 100 | -------------------------------------------------------------------------------- /services/frontend/src/components/CodeEditor/useCodeMirror.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { EditorView } from "@codemirror/view"; 3 | import { EditorState } from "@codemirror/state"; 4 | import { Extension } from "@codemirror/state"; 5 | 6 | export default function useCodeMirror(extensions: Extension[], doc: any) { 7 | const [element, setElement] = useState(); 8 | 9 | const ref = useCallback((node: HTMLElement | null) => { 10 | if (!node) return; 11 | 12 | setElement(node); 13 | }, []); 14 | 15 | useEffect(() => { 16 | if (!element) return; 17 | 18 | const view = new EditorView({ 19 | state: EditorState.create({ 20 | doc: doc, 21 | extensions: [...extensions] 22 | }), 23 | parent: element 24 | }); 25 | 26 | return () => view?.destroy(); 27 | }, [element, extensions, doc]); 28 | 29 | return { ref }; 30 | } 31 | -------------------------------------------------------------------------------- /services/frontend/src/components/FormModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Fragment, 3 | FunctionComponent, 4 | useCallback, 5 | useMemo, 6 | useState 7 | } from "react"; 8 | import { Formik } from "formik"; 9 | import { Button, styled } from "@mui/material"; 10 | 11 | import { reportErrorsAndSubmit } from "../utils/forms"; 12 | import { ScrollView } from "./ScrollView"; 13 | import Modal from "./Modal"; 14 | import Tabs from "./Tabs"; 15 | import Tab from "./Tab"; 16 | 17 | export interface ITab { 18 | value: string; 19 | title: string; 20 | component: FunctionComponent; 21 | } 22 | 23 | export interface IFormModalProps { 24 | title: string; 25 | tabs: ITab[]; 26 | onHide: () => void; 27 | getFinalValues: (values: any, selectedNode?: any) => any; 28 | getInitialValues: (selectedNode?: any) => any; 29 | validationSchema: any; 30 | onCreate: (finalValues: any, values: any, formik: any) => void; 31 | selectedNode?: any; 32 | } 33 | 34 | const StyledScrollView = styled(ScrollView)` 35 | position: relative; 36 | padding-top: 0.75rem; 37 | padding-bottom: 0.75rem; 38 | padding-left: 1rem; 39 | padding-right: 1rem; 40 | flex: 1 1 auto; 41 | `; 42 | 43 | const Actions = styled("div")` 44 | display: flex; 45 | padding-top: 0.75rem; 46 | padding-bottom: 0.75rem; 47 | padding-left: 1rem; 48 | padding-right: 1rem; 49 | justify-content: flex-end; 50 | align-items: center; 51 | border-bottom-right-radius: 0.25rem; 52 | border-bottom-left-radius: 0.25rem; 53 | border-top-width: 1px; 54 | border-style: solid; 55 | `; 56 | 57 | const SaveButton = styled(Button)` 58 | text-transform: none; 59 | `; 60 | 61 | const FormModal = (props: IFormModalProps) => { 62 | const { 63 | title, 64 | tabs, 65 | getInitialValues, 66 | getFinalValues, 67 | validationSchema, 68 | onHide, 69 | onCreate, 70 | selectedNode 71 | } = props; 72 | 73 | const [openTab, setOpenTab] = useState(() => tabs[0].value); 74 | 75 | const initialValues = useMemo( 76 | () => getInitialValues(selectedNode), 77 | [getInitialValues, selectedNode] 78 | ); 79 | 80 | const handleCreate = useCallback( 81 | (values: any, formik: any) => { 82 | onCreate(getFinalValues(values, selectedNode), values, formik); 83 | }, 84 | [getFinalValues, onCreate] 85 | ); 86 | 87 | const renderTab = (tab: ITab) => { 88 | const Component = tab.component; 89 | return ( 90 | 91 | {openTab === tab.value && } 92 | 93 | ); 94 | }; 95 | 96 | return ( 97 | 98 | 104 | {(formik) => ( 105 | <> 106 | 107 | {tabs.map((tab) => ( 108 | 109 | ))} 110 | 111 | 112 | 113 | {tabs.map(renderTab)} 114 | 115 | 116 | 117 | 124 | Save 125 | 126 | 127 | 128 | )} 129 | 130 | 131 | ); 132 | }; 133 | 134 | export default FormModal; 135 | -------------------------------------------------------------------------------- /services/frontend/src/components/GridColumn.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement } from "react"; 2 | import { styled } from "@mui/material"; 3 | import { useSuperForm } from "../hooks"; 4 | import { IFormField } from "../types"; 5 | 6 | interface IRootProps { 7 | spans: number[]; 8 | } 9 | 10 | const Root = styled("div", { 11 | shouldForwardProp: (name) => name !== "spans" 12 | })` 13 | grid-column: span ${({ spans }) => spans[0]}; 14 | @media (max-width: 640px) { 15 | grid-column: span ${({ spans }) => spans[1]}; 16 | } 17 | `; 18 | 19 | export interface IGridColumnProps { 20 | spans: number[]; 21 | fields: IFormField[]; 22 | } 23 | 24 | export const GridColumn: FunctionComponent = ( 25 | props: IGridColumnProps 26 | ): ReactElement => { 27 | const { spans, fields } = props; 28 | const { renderField } = useSuperForm(); 29 | 30 | return {fields.map(renderField)}; 31 | }; 32 | -------------------------------------------------------------------------------- /services/frontend/src/components/GridRow.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement, ReactNode } from "react"; 2 | import { styled } from "@mui/material"; 3 | import { useSuperForm } from "../hooks"; 4 | import { IFormField } from "../types"; 5 | 6 | const Root = styled("div")` 7 | display: grid; 8 | grid-template-columns: repeat(2, 1fr); 9 | grid-column-gap: 0px; 10 | grid-row-gap: 0px; 11 | @media (max-width: 640px) { 12 | grid-template-columns: repeat(1, 1fr); 13 | } 14 | column-gap: ${({ theme }) => theme.spacing(1)}; 15 | width: 100%; 16 | `; 17 | 18 | export interface IGridProps { 19 | fields: IFormField[]; 20 | children?: ReactNode; 21 | } 22 | 23 | export const GridRow: FunctionComponent = ( 24 | props: IGridProps 25 | ): ReactElement => { 26 | const { fields } = props; 27 | const { renderField } = useSuperForm(); 28 | 29 | return {fields.map(renderField)}; 30 | }; 31 | -------------------------------------------------------------------------------- /services/frontend/src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement, ReactNode } from "react"; 2 | import { XIcon } from "@heroicons/react/outline"; 3 | import { styled } from "@mui/material"; 4 | 5 | export interface IModalProps { 6 | title: string; 7 | onHide: () => void; 8 | children: ReactNode; 9 | } 10 | 11 | const Root = styled("div")` 12 | overflow-y: auto; 13 | position: fixed; 14 | top: 0; 15 | right: 0; 16 | bottom: 0; 17 | left: 0; 18 | z-index: 50; 19 | `; 20 | 21 | const Container = styled("div")` 22 | display: flex; 23 | overflow-y: auto; 24 | overflow-x: hidden; 25 | position: fixed; 26 | top: 0; 27 | right: 0; 28 | bottom: 0; 29 | left: 0; 30 | justify-content: center; 31 | align-items: center; 32 | outline: 0; 33 | `; 34 | 35 | const Backdrop = styled("div")` 36 | position: fixed; 37 | top: 0; 38 | right: 0; 39 | bottom: 0; 40 | left: 0; 41 | z-index: 40; 42 | background-color: #000000; 43 | opacity: 0.25; 44 | `; 45 | 46 | const Content = styled("div")` 47 | position: relative; 48 | z-index: 50; 49 | margin-top: 1.5rem; 50 | margin-bottom: 1.5rem; 51 | width: auto; 52 | max-width: 64rem; 53 | `; 54 | 55 | const Content2 = styled("div")` 56 | display: flex; 57 | position: relative; 58 | background-color: #ffffff; 59 | flex-direction: column; 60 | width: 100%; 61 | border-radius: 0.5rem; 62 | border-width: 0; 63 | outline: 0; 64 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 65 | 0 4px 6px -2px rgba(0, 0, 0, 0.05); 66 | `; 67 | 68 | const Header = styled("div")` 69 | display: flex; 70 | padding-top: 0.75rem; 71 | padding-bottom: 0.75rem; 72 | padding-left: 1rem; 73 | padding-right: 1rem; 74 | justify-content: space-between; 75 | align-items: center; 76 | border-top-left-radius: 0.25rem; 77 | border-top-right-radius: 0.25rem; 78 | border-bottom-width: 1px; 79 | border-style: solid; 80 | `; 81 | 82 | const Title = styled("h3")` 83 | font-size: 0.875rem; 84 | line-height: 1.25rem; 85 | font-weight: 600; 86 | `; 87 | 88 | const CloseButton = styled("button")` 89 | float: right; 90 | padding: 0.25rem; 91 | color: #000000; 92 | outline: 0; 93 | `; 94 | 95 | const Modal: FunctionComponent = ( 96 | props: IModalProps 97 | ): ReactElement => { 98 | const { title, onHide, children } = props; 99 | 100 | return ( 101 | 102 | 103 | 104 | 105 | 106 |
107 | {title} 108 | 109 | 110 | 111 |
112 | {children} 113 |
114 |
115 |
116 |
117 | ); 118 | }; 119 | 120 | export default Modal; 121 | -------------------------------------------------------------------------------- /services/frontend/src/components/Profile/index.tsx: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE } from "../../constants"; 2 | import { authSelf } from "../../reducers"; 3 | 4 | interface IProfileProps { 5 | dispatch: any; 6 | state: any; 7 | } 8 | 9 | const Profile = (props: IProfileProps) => { 10 | const { dispatch, state } = props; 11 | 12 | const logOut = () => { 13 | localStorage.removeItem(LOCAL_STORAGE); 14 | dispatch(authSelf(null)); 15 | }; 16 | 17 | return ( 18 | <> 19 |
20 |
21 |
22 |

23 | Profile 24 |

25 | 31 |
32 |
33 | {state.user && ( 34 | <> 35 | {state.user.username && ( 36 |
37 |
38 | username 39 |
40 |
41 | {state.user.username} 42 |
43 |
44 | )} 45 | 46 | {state.user.email && ( 47 |
48 |
49 | email 50 |
51 |
52 | {state.user.email} 53 |
54 |
55 | )} 56 | 57 | )} 58 |
59 |
60 |
61 | 62 | ); 63 | }; 64 | 65 | export default Profile; 66 | -------------------------------------------------------------------------------- /services/frontend/src/components/Project/CodeBox.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from "react"; 2 | import YAML from "yaml"; 3 | import { debounce } from "lodash"; 4 | import { manifestTypes } from "../../constants"; 5 | import { generatePayload } from "../../utils/generators"; 6 | import { checkHttpStatus } from "../../services/helpers"; 7 | import { generateHttp } from "../../services/generate"; 8 | import { toaster } from "../../utils"; 9 | import eventBus from "../../events/eventBus"; 10 | import ManifestSelect from "./ManifestSelect"; 11 | import CodeEditor from "../CodeEditor"; 12 | import useWindowDimensions from "../../hooks/useWindowDimensions"; 13 | 14 | const CodeBox = () => { 15 | const versionRef = useRef(); 16 | const manifestRef = useRef(); 17 | const [language, setLanguage] = useState("yaml"); 18 | const [version, setVersion] = useState("3"); 19 | const [copyText, setCopyText] = useState("Copy"); 20 | const [generatedCode, setGeneratedCode] = useState(""); 21 | const [formattedCode, setFormattedCode] = useState(""); 22 | const [manifest, setManifest] = useState(manifestTypes.DOCKER_COMPOSE); 23 | const { height } = useWindowDimensions(); 24 | 25 | versionRef.current = version; 26 | manifestRef.current = manifest; 27 | 28 | const getCode = (payload: any, manifest: string) => { 29 | generateHttp(JSON.stringify(payload), manifest) 30 | .then(checkHttpStatus) 31 | .then((data) => { 32 | if (data["code"]) { 33 | setGeneratedCode(data["code"]); 34 | } else { 35 | setGeneratedCode(""); 36 | } 37 | 38 | if (data["error"]) { 39 | setGeneratedCode(""); 40 | toaster(`error ${data["error"]}`, "error"); 41 | } 42 | }); 43 | }; 44 | 45 | const debouncedOnGraphUpdate = useMemo( 46 | () => 47 | debounce((payload, manifest) => { 48 | getCode(payload, manifest); 49 | }, 600), 50 | [] 51 | ); 52 | 53 | const versionChange = (e: any) => { 54 | setVersion(e.target.value); 55 | }; 56 | 57 | const copy = () => { 58 | navigator.clipboard.writeText(formattedCode); 59 | setCopyText("Copied"); 60 | 61 | setTimeout(() => { 62 | setCopyText("Copy"); 63 | }, 300); 64 | }; 65 | 66 | useEffect(() => { 67 | if (language === "json") { 68 | setFormattedCode( 69 | JSON.stringify(YAML.parseAllDocuments(generatedCode), null, 2) 70 | ); 71 | } 72 | 73 | if (language === "yaml") { 74 | setFormattedCode(generatedCode); 75 | } 76 | }, [language, generatedCode]); 77 | 78 | useEffect(() => { 79 | eventBus.dispatch("GENERATE", { 80 | message: { 81 | id: "" 82 | } 83 | }); 84 | }, [version, manifest]); 85 | 86 | useEffect(() => { 87 | eventBus.on("FETCH_CODE", (data) => { 88 | const graphData = data.detail.message; 89 | graphData.version = versionRef.current; 90 | debouncedOnGraphUpdate(generatePayload(graphData), manifestRef.current); 91 | }); 92 | 93 | return () => { 94 | eventBus.remove("FETCH_CODE", () => undefined); 95 | }; 96 | }, []); 97 | 98 | return ( 99 | <> 100 |
103 | 113 | 114 | 122 | 130 | 133 |
134 | 135 |
138 | 139 |
140 | 141 | { 145 | return; 146 | }} 147 | disabled={true} 148 | lineWrapping={false} 149 | height={height - 64} 150 | /> 151 | 152 | ); 153 | }; 154 | 155 | export default CodeBox; 156 | -------------------------------------------------------------------------------- /services/frontend/src/components/Project/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import { CallbackFunction, IProject } from "../../types"; 3 | import Spinner from "../global/Spinner"; 4 | import VisibilitySwitch from "../global/VisibilitySwitch"; 5 | 6 | interface IHeaderProps { 7 | onSave: CallbackFunction; 8 | isLoading: boolean; 9 | projectData: IProject; 10 | isAuthenticated: boolean; 11 | } 12 | 13 | const Header = (props: IHeaderProps) => { 14 | const { onSave, isLoading, projectData, isAuthenticated } = props; 15 | const [visibility, setVisibility] = useState(false); 16 | const [projectName, setProjectName] = useState("Untitled"); 17 | 18 | const visibilityRef = useRef(false); 19 | const projectNameRef = useRef("Untitled"); 20 | 21 | const handleNameChange = useCallback((e: any) => { 22 | setProjectName(e.target.value); 23 | projectNameRef.current = e.target.value; 24 | }, []); 25 | 26 | const handleSave = useCallback(() => { 27 | const data: any = { 28 | name: projectNameRef.current, 29 | visibility: +visibilityRef.current 30 | }; 31 | 32 | onSave(data); 33 | }, []); 34 | 35 | useEffect(() => { 36 | if (!projectData) { 37 | return; 38 | } 39 | 40 | setProjectName(projectData.name); 41 | setVisibility(Boolean(projectData.visibility)); 42 | 43 | visibilityRef.current = Boolean(projectData.visibility); 44 | projectNameRef.current = projectData.name; 45 | }, [projectData]); 46 | 47 | return ( 48 | <> 49 |
50 |
54 | 84 | 85 |
86 | {isAuthenticated && ( 87 | { 90 | setVisibility(!visibility); 91 | visibilityRef.current = !visibility; 92 | }} 93 | /> 94 | )} 95 | 96 | 106 |
107 |
108 |
109 | 110 | ); 111 | }; 112 | 113 | export default Header; 114 | -------------------------------------------------------------------------------- /services/frontend/src/components/Project/ManifestSelect.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material"; 2 | import { useCallback, useState } from "react"; 3 | import { manifestTypes } from "../../constants"; 4 | import DcLogo from "../global/dc-logo"; 5 | import K8sLogo from "../global/k8s-logo"; 6 | 7 | interface IButtonProps { 8 | selected: boolean; 9 | } 10 | 11 | const Button = styled("button", { 12 | shouldForwardProp: (name) => name !== "selected" 13 | })` 14 | filter: grayscale(${({ selected }) => (selected ? "0%" : "100%")}); 15 | opacity: ${({ selected }) => (selected ? "100%" : "80%")}; 16 | 17 | &:hover { 18 | filter: grayscale(0%); 19 | } 20 | `; 21 | 22 | interface IManifestSelectProps { 23 | setManifest: any; 24 | } 25 | 26 | const ManifestSelect = (props: IManifestSelectProps) => { 27 | const { setManifest } = props; 28 | const [selected, setSelected] = useState(manifestTypes.DOCKER_COMPOSE); 29 | 30 | const handleK8s = useCallback(() => { 31 | setManifest(manifestTypes.KUBERNETES); 32 | setSelected(manifestTypes.KUBERNETES); 33 | }, []); 34 | 35 | const handleDC = useCallback(() => { 36 | setManifest(manifestTypes.DOCKER_COMPOSE); 37 | setSelected(manifestTypes.DOCKER_COMPOSE); 38 | }, []); 39 | 40 | return ( 41 | <> 42 | 49 | 50 | 57 | 58 | ); 59 | }; 60 | 61 | export default ManifestSelect; 62 | -------------------------------------------------------------------------------- /services/frontend/src/components/Projects/PreviewBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { TrashIcon } from "@heroicons/react/outline"; 4 | import { truncateStr } from "../../utils"; 5 | import { IProject } from "../../types"; 6 | import ModalConfirmDelete from "../modals/ConfirmDelete"; 7 | import { useDeleteProject } from "../../hooks/useProject"; 8 | 9 | interface IPreviewBlockProps { 10 | project: IProject; 11 | } 12 | 13 | const PreviewBlock = (props: IPreviewBlockProps) => { 14 | const { project } = props; 15 | const [isHovering, setIsHovering] = useState(false); 16 | const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); 17 | const mutation = useDeleteProject(project.uuid); 18 | const navigate = useNavigate(); 19 | 20 | const handleMouseOver = () => { 21 | setIsHovering(true); 22 | }; 23 | 24 | const handleMouseLeave = () => { 25 | setIsHovering(false); 26 | }; 27 | 28 | const handleClick = () => { 29 | navigate(`/projects/${project.uuid}`); 30 | }; 31 | 32 | const onDelete = (e: any) => { 33 | e.stopPropagation(); 34 | setShowDeleteConfirmModal(true); 35 | }; 36 | 37 | const onDeleteConfirmed = () => { 38 | mutation.mutate(); 39 | }; 40 | 41 | return ( 42 | <> 43 |
63 |
{truncateStr(project.name, 25)}
64 | 65 | {isHovering && ( 66 |
67 | 73 |
74 | )} 75 |
76 | 77 | {showDeleteConfirmModal && ( 78 | onDeleteConfirmed()} 80 | onHide={() => { 81 | setShowDeleteConfirmModal(false); 82 | }} 83 | /> 84 | )} 85 | 86 | ); 87 | }; 88 | 89 | export default PreviewBlock; 90 | -------------------------------------------------------------------------------- /services/frontend/src/components/Record.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Fragment, 3 | FunctionComponent, 4 | ReactElement, 5 | useCallback, 6 | useMemo 7 | } from "react"; 8 | import { IconButton, styled } from "@mui/material"; 9 | import { MinusSmIcon } from "@heroicons/react/solid"; 10 | import TextField from "./global/FormElements/TextField"; 11 | import Toggle from "./global/FormElements/Toggle"; 12 | import Records, { IRecordsProps } from "./Records"; 13 | 14 | export interface IFieldType { 15 | name: string; 16 | placeholder?: string; 17 | required?: boolean; 18 | type: "text" | "toggle" | "records"; 19 | options?: 20 | | { 21 | text: string; 22 | value: string; 23 | }[] 24 | | IRecordsProps; 25 | } 26 | 27 | export interface IRecordProps { 28 | fields: IFieldType[]; 29 | index: number; 30 | onRemove: (index: number) => void; 31 | direction?: "column" | "row"; 32 | renderLayout?: (elements: ReactElement[]) => ReactElement; 33 | renderField?: (element: ReactElement, field: IFieldType) => ReactElement; 34 | renderRemove?: (element: ReactElement) => ReactElement; 35 | } 36 | 37 | const Root = styled("div")` 38 | display: flex; 39 | flex-direction: row; 40 | justify-content: flex-start; 41 | align-items: flex-start; 42 | column-gap: ${({ theme }) => theme.spacing(1)}; 43 | width: 100%; 44 | 45 | @media (max-width: 768px) { 46 | column-gap: ${({ theme }) => theme.spacing(1)}; 47 | } 48 | 49 | @media (max-width: 640px) { 50 | flex-direction: column; 51 | } 52 | `; 53 | 54 | const RemoveButton = styled(IconButton)` 55 | border-radius: ${({ theme }) => theme.spacing(2)}; 56 | `; 57 | 58 | const Record: FunctionComponent = ( 59 | props: IRecordProps 60 | ): ReactElement => { 61 | const { fields, index, onRemove, renderLayout, renderField, renderRemove } = 62 | props; 63 | 64 | const handleRemove = useCallback(() => { 65 | onRemove(index); 66 | }, [index, onRemove]); 67 | 68 | const renderLayoutWrapper = useMemo( 69 | () => renderLayout || ((elements: ReactElement[]) => <>{elements}), 70 | [renderLayout] 71 | ); 72 | 73 | const renderFieldWrapper = useMemo( 74 | () => renderField || ((element: ReactElement) => element), 75 | [renderField] 76 | ); 77 | 78 | const renderRemoveWrapper = useMemo( 79 | () => renderRemove || ((element: ReactElement) => element), 80 | [renderRemove] 81 | ); 82 | 83 | return ( 84 | 85 | {renderLayoutWrapper( 86 | fields.map((field) => ( 87 | 88 | {renderFieldWrapper( 89 | <> 90 | {field.type === "text" && ( 91 | 102 | )} 103 | {field.type === "toggle" && ( 104 | 109 | )} 110 | {field.type === "records" && ( 111 | 112 | )} 113 | , 114 | field 115 | )} 116 | 117 | )) 118 | )} 119 | {renderRemoveWrapper( 120 |
121 | 122 | 123 | 124 |
125 | )} 126 |
127 | ); 128 | }; 129 | 130 | export default Record; 131 | -------------------------------------------------------------------------------- /services/frontend/src/components/Records.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, FunctionComponent, ReactElement, useCallback } from "react"; 2 | import { IconButton, styled } from "@mui/material"; 3 | import { 4 | ChevronDownIcon, 5 | ChevronUpIcon, 6 | PlusIcon 7 | } from "@heroicons/react/outline"; 8 | import Record, { IFieldType } from "./Record"; 9 | import { useFormikContext } from "formik"; 10 | import lodash from "lodash"; 11 | import { useAccordionState } from "../hooks"; 12 | import { useParams } from "react-router-dom"; 13 | 14 | export interface IRecordsProps { 15 | collapsible?: boolean; 16 | defaultOpen?: boolean; 17 | title: string; 18 | name: string; 19 | fields: (index: number) => IFieldType[]; 20 | newValue: any; 21 | renderLayout?: (elements: ReactElement[]) => ReactElement; 22 | renderField?: (element: ReactElement, field: IFieldType) => ReactElement; 23 | renderRemove?: (element: ReactElement) => ReactElement; 24 | renderBorder?: () => ReactElement; 25 | } 26 | 27 | interface IGroupProps { 28 | empty: boolean; 29 | } 30 | 31 | const Group = styled("div", { 32 | shouldForwardProp: (propName) => propName !== "empty" 33 | })` 34 | display: flex; 35 | flex-direction: column; 36 | align-items: ${({ empty }) => (empty ? "center" : "flex-end")}; 37 | width: 100%; 38 | @media (max-width: 640px) { 39 | row-gap: 0; 40 | } 41 | `; 42 | 43 | const GroupTitle = styled("label")` 44 | display: block; 45 | font-size: 0.75rem; 46 | line-height: 1rem; 47 | font-weight: 700; 48 | color: #374151; 49 | padding: 0; 50 | `; 51 | 52 | const RecordList = styled("div")` 53 | display: flex; 54 | flex-direction: column; 55 | justify-content: center; 56 | align-items: center; 57 | row-gap: ${({ theme }) => theme.spacing(1)}; 58 | width: 100%; 59 | `; 60 | 61 | const AddButton = styled(IconButton)` 62 | border-radius: ${({ theme }) => theme.spacing(2)}; 63 | margin-top: ${({ theme }) => theme.spacing(1)}; 64 | `; 65 | 66 | const Description = styled("p")` 67 | margin-top: ${({ theme }) => theme.spacing(1)}; 68 | text-align: center; 69 | color: #7a7a7a; 70 | font-size: 14px; 71 | width: 100%; 72 | `; 73 | 74 | const Top = styled("div")` 75 | display: flex; 76 | flex-direction: row; 77 | justify-content: space-between; 78 | align-items: center; 79 | width: 100%; 80 | 81 | &:hover { 82 | cursor: pointer; 83 | user-select: none; 84 | } 85 | `; 86 | 87 | const ExpandButton = styled(IconButton)` 88 | border-radius: ${({ theme }) => theme.spacing(2)}; 89 | `; 90 | 91 | const Records: FunctionComponent = ( 92 | props: IRecordsProps 93 | ): ReactElement => { 94 | const { 95 | collapsible = true, 96 | defaultOpen = false, 97 | title, 98 | name, 99 | fields, 100 | newValue, 101 | renderLayout, 102 | renderField, 103 | renderRemove, 104 | renderBorder 105 | } = props; 106 | 107 | const formik = useFormikContext(); 108 | const items = lodash.get(formik.values, name); 109 | 110 | const { uuid } = useParams<{ uuid: string }>(); 111 | 112 | const id = `${uuid}.${name}`; 113 | const { open, toggle } = useAccordionState(id, defaultOpen); 114 | 115 | const handleNew = useCallback(() => { 116 | formik.setFieldValue(`${name}[${items.length}]`, newValue); 117 | }, [formik]); 118 | 119 | const handleRemove = useCallback( 120 | (index: number) => { 121 | const newOptions = items.filter( 122 | (_: unknown, currentIndex: number) => currentIndex != index 123 | ); 124 | formik.setFieldValue(name, newOptions); 125 | }, 126 | [formik] 127 | ); 128 | 129 | if (!items) { 130 | throw new Error(`"${name}" is falsy.`); 131 | } 132 | 133 | if (!Array.isArray(items)) { 134 | throw new Error(`Expected "${name}" to be an array.`); 135 | } 136 | 137 | const empty = items && items.length === 0; 138 | 139 | return ( 140 | 141 | 142 | {title && {title}} 143 | {collapsible && ( 144 | 145 | {open && } 146 | {!open && } 147 | 148 | )} 149 | 150 | {(!collapsible || open) && !empty && ( 151 | 152 | {items.map((_: unknown, index: number) => ( 153 | 154 | 162 | {renderBorder && renderBorder()} 163 | 164 | ))} 165 | 166 | )} 167 | 168 | {(!collapsible || open) && empty && ( 169 | No items available 170 | )} 171 | 172 | {(!collapsible || open) && ( 173 | 174 | 175 | 176 | )} 177 | 178 | ); 179 | }; 180 | 181 | export default Records; 182 | -------------------------------------------------------------------------------- /services/frontend/src/components/ScrollView.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, HTMLProps, ReactElement, ReactNode } from "react"; 2 | import { styled } from "@mui/material"; 3 | 4 | export interface IScrollViewProps extends HTMLProps { 5 | children: ReactNode; 6 | height: string; 7 | } 8 | 9 | interface IRootProps extends HTMLProps { 10 | fixedHeight: string; 11 | } 12 | 13 | const Root = styled("div", { 14 | shouldForwardProp: (propName) => propName !== "height" 15 | })` 16 | overflow: auto; 17 | height: ${({ height }) => height}; 18 | 19 | ::-webkit-scrollbar { 20 | width: 4px; 21 | } 22 | 23 | ::-webkit-scrollbar-track { 24 | background: #f1f1f1; 25 | } 26 | 27 | ::-webkit-scrollbar-thumb { 28 | background-color: #999; 29 | border: #aaa; 30 | } 31 | 32 | ::-webkit-scrollbar-thumb:hover { 33 | background: #555; 34 | } 35 | ` as any; 36 | 37 | export const ScrollView: FunctionComponent = ( 38 | props: IScrollViewProps 39 | ): ReactElement => { 40 | const { height, children, ...otherProps } = props; 41 | return ( 42 | 43 | {children} 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /services/frontend/src/components/SuperForm.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement } from "react"; 2 | import { styled } from "@mui/material"; 3 | import { useSuperForm } from "../hooks"; 4 | import { IFormField, TFinalFormField } from "../types"; 5 | 6 | const Root = styled("div")` 7 | display: flex; 8 | flex-direction: column; 9 | row-gap: ${({ theme }) => theme.spacing(1)}; 10 | @media (max-width: 640px) { 11 | row-gap: 0; 12 | } 13 | `; 14 | 15 | export interface IRecordFormProps { 16 | fields: T[]; 17 | } 18 | 19 | export const SuperForm: FunctionComponent> = < 20 | T extends IFormField 21 | >( 22 | props: IRecordFormProps 23 | ): ReactElement => { 24 | const { fields } = props; 25 | const { renderField } = useSuperForm(); 26 | 27 | return {fields.map(renderField)}; 28 | }; 29 | -------------------------------------------------------------------------------- /services/frontend/src/components/SuperFormProvider.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement, ReactNode, useMemo } from "react"; 2 | import { SuperFormContext } from "../contexts"; 3 | import { IFormField } from "../types"; 4 | import TextField from "./global/FormElements/TextField"; 5 | import Toggle from "./global/FormElements/Toggle"; 6 | import { GridColumn } from "./GridColumn"; 7 | import { GridRow } from "./GridRow"; 8 | import Records from "./Records"; 9 | 10 | export interface ISuperFormProviderProps { 11 | children?: ReactNode; 12 | } 13 | 14 | const types: Record> = { 15 | text: TextField, 16 | "grid-row": GridRow, 17 | "grid-column": GridColumn, 18 | toggle: Toggle, 19 | records: Records 20 | }; 21 | 22 | export const SuperFormProvider: FunctionComponent = ( 23 | props: ISuperFormProviderProps 24 | ): ReactElement => { 25 | const { children } = props; 26 | 27 | const value = useMemo( 28 | () => ({ 29 | types, 30 | renderField: (field: IFormField) => { 31 | const Component = types[field.type]; 32 | return ; 33 | } 34 | }), 35 | [] 36 | ); 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /services/frontend/src/components/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { styled } from "@mui/material"; 3 | import { useTabContext } from "../hooks"; 4 | 5 | export interface ITabProps { 6 | value: string; 7 | title: string; 8 | hidden?: boolean; 9 | } 10 | 11 | interface IRootProps { 12 | active: boolean; 13 | hidden?: boolean; 14 | } 15 | 16 | const Root = styled("div", { 17 | shouldForwardProp: (name) => !["hidden", "active"].includes(name.toString()) 18 | })(({ hidden, active }) => ({ 19 | paddingLeft: "0.25rem", 20 | paddingRight: "0.25rem", 21 | paddingTop: "1rem", 22 | paddingBottom: "1rem", 23 | fontSize: "0.875rem", 24 | lineHeight: "1.25rem", 25 | fontWeight: 500, 26 | whiteSpace: "nowrap", 27 | borderBottomWidth: 2, 28 | cursor: "pointer", 29 | 30 | ...(active 31 | ? { color: "#4f46e5", borderColor: "#6366f1" } 32 | : { 33 | color: "#6B7280", 34 | borderColor: "transparent", 35 | 36 | "&:hover": { 37 | color: "#374151", 38 | borderColor: "#D1D5DB" 39 | } 40 | }), 41 | 42 | display: hidden ? "none" : undefined 43 | })); 44 | 45 | const Tab = (props: ITabProps) => { 46 | const { value, title, hidden } = props; 47 | const { value: open, onChange } = useTabContext(); 48 | 49 | const handleClick = useCallback((event) => { 50 | event.preventDefault(); 51 | onChange(value); 52 | }, []); 53 | 54 | return ( 55 | 58 | ); 59 | }; 60 | 61 | export default Tab; 62 | -------------------------------------------------------------------------------- /services/frontend/src/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement, ReactNode } from "react"; 2 | import { styled } from "@mui/material"; 3 | import { TabContext } from "../contexts"; 4 | 5 | export interface ITabsProps { 6 | value: string; 7 | onChange: (newValue: string) => void; 8 | children: ReactNode; 9 | } 10 | 11 | const Root = styled("div")` 12 | border-bottom-width: 1px; 13 | border-color: #e5e7eb; 14 | 15 | @media (min-width: 768px) { 16 | padding-left: 2rem; 17 | padding-right: 2rem; 18 | } 19 | `; 20 | 21 | const Nav = styled("nav")` 22 | display: flex; 23 | flex-direction: row; 24 | column-gap: ${({ theme }) => theme.spacing(3)}; 25 | margin-bottom: -1px; 26 | `; 27 | 28 | const Tabs: FunctionComponent = ( 29 | props: ITabsProps 30 | ): ReactElement => { 31 | const { children, value, onChange } = props; 32 | 33 | return ( 34 | 35 | 40 | 41 | ); 42 | }; 43 | 44 | export default Tabs; 45 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/DarkModeSwitch/index.tsx: -------------------------------------------------------------------------------- 1 | import { MoonIcon, SunIcon } from "@heroicons/react/outline"; 2 | import { useDarkMode } from "./userDarkMode"; 3 | 4 | const DarkModeSwitch = () => { 5 | const [isDark, setIsDark] = useDarkMode(); 6 | 7 | return ( 8 |
9 | 30 |
31 | ); 32 | }; 33 | 34 | export default DarkModeSwitch; 35 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/DarkModeSwitch/userDarkMode.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function usePrefersDarkMode() { 4 | const [value, setValue] = useState(true); 5 | 6 | useEffect(() => { 7 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 8 | setValue(mediaQuery.matches); 9 | 10 | const handler = () => setValue(mediaQuery.matches); 11 | mediaQuery.addEventListener("change", handler); 12 | return () => mediaQuery.removeEventListener("change", handler); 13 | }, []); 14 | 15 | return value; 16 | } 17 | 18 | export function useSafeLocalStorage( 19 | key: string, 20 | initialValue: string | undefined 21 | ) { 22 | const [valueProxy, setValueProxy] = useState(() => { 23 | try { 24 | const value = window.localStorage.getItem(key); 25 | return value ? JSON.parse(value) : initialValue; 26 | } catch { 27 | return initialValue; 28 | } 29 | }); 30 | 31 | const setValue = (value: string) => { 32 | try { 33 | window.localStorage.setItem(key, value); 34 | setValueProxy(value); 35 | } catch { 36 | setValueProxy(value); 37 | } 38 | }; 39 | 40 | return [valueProxy, setValue]; 41 | } 42 | 43 | export function useDarkMode() { 44 | const prefersDarkMode = usePrefersDarkMode(); 45 | const [isEnabled, setIsEnabled] = useSafeLocalStorage("dark-mode", undefined); 46 | const enabled = isEnabled === undefined ? prefersDarkMode : isEnabled; 47 | 48 | useEffect(() => { 49 | if (window === undefined) return; 50 | const root = window.document.documentElement; 51 | root.classList.remove(enabled ? "light" : "dark"); 52 | root.classList.add(enabled ? "dark" : "light"); 53 | }, [enabled]); 54 | 55 | return [enabled, setIsEnabled]; 56 | } 57 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/FormElements/TextField.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement } from "react"; 2 | import { styled } from "@mui/material"; 3 | import lodash from "lodash"; 4 | import { useFormikContext } from "formik"; 5 | 6 | export interface ITextFieldProps { 7 | name: string; 8 | help?: string; 9 | [key: string]: any; 10 | } 11 | 12 | const Root = styled("div")``; 13 | 14 | const TextField: FunctionComponent = ( 15 | props: ITextFieldProps 16 | ): ReactElement => { 17 | const { label, name, help, required, ...otherProps } = props; 18 | const formik = useFormikContext(); 19 | const error = 20 | lodash.get(formik.touched, name) && lodash.get(formik.errors, name); 21 | 22 | return ( 23 | 24 |
25 | {label && ( 26 | 29 | )} 30 | 31 | 43 | 44 |
45 | {error && {error}} 46 | {!error && help} 47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | export default TextField; 54 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/FormElements/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import lodash from "lodash"; 2 | import { useFormikContext } from "formik"; 3 | import { Fragment, FunctionComponent, ReactElement } from "react"; 4 | import { Button, styled } from "@mui/material"; 5 | 6 | export interface IToggleProps { 7 | name: string; 8 | label?: string; 9 | help?: string; 10 | options: { 11 | text: string; 12 | value: string; 13 | }[]; 14 | } 15 | 16 | interface IToggleButtonProps { 17 | index: number; 18 | total: number; 19 | } 20 | 21 | const Root = styled("div")` 22 | display: flex; 23 | flex-direction: column; 24 | row-gap: ${({ theme }) => theme.spacing(0.4)}; 25 | `; 26 | 27 | const Label = styled("label")` 28 | margin-bottom: 0.25rem; 29 | display: block; 30 | font-size: 0.75rem; 31 | line-height: 1rem; 32 | font-weight: 700; 33 | color: #374151; 34 | padding: 0; 35 | `; 36 | 37 | const Buttons = styled("div")` 38 | display: flex; 39 | flex-direction: row; 40 | @media (max-width: 640px) { 41 | margin-bottom: 0.5rem; 42 | } 43 | `; 44 | 45 | const ToggleButton = styled(Button)(({ index, total }) => ({ 46 | borderRadius: ` 47 | ${index === 0 ? "8px" : "0px"} 48 | ${index === total - 1 ? "8px" : "0px"} 49 | ${index === total - 1 ? "8px" : "0px"} 50 | ${index === 0 ? "8px" : "0px"} 51 | `, 52 | fontSize: 11, 53 | textTransform: "none" 54 | })); 55 | 56 | const ButtonBorder = styled("div")` 57 | border-right: 1px solid white; 58 | `; 59 | 60 | const Toggle: FunctionComponent = ( 61 | props: IToggleProps 62 | ): ReactElement => { 63 | const { label, name, options } = props; 64 | const formik = useFormikContext(); 65 | const value = lodash.get(formik.values, name); 66 | 67 | const handleChange = (newValue: string) => () => { 68 | formik.setFieldValue(name, newValue === value ? "" : newValue); 69 | }; 70 | 71 | return ( 72 | 73 | {label && } 74 | 75 | {options.map((option, index) => ( 76 | 77 | 87 | {option.text} 88 | 89 | {index + 1 < options.length && } 90 | 91 | ))} 92 | 93 | 94 | ); 95 | }; 96 | 97 | export default Toggle; 98 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from "./Spinner"; 2 | 3 | interface IPaginationProps { 4 | defaultCurrent: number; 5 | defaultPageSize: number; 6 | onChange: any; 7 | total: number; 8 | loading: boolean; 9 | } 10 | 11 | const Pagination = (props: IPaginationProps) => { 12 | const { defaultCurrent, onChange, total, loading } = props; 13 | 14 | return ( 15 |
16 | {`showing ${defaultCurrent} of ${total}`} 17 | 26 |
27 | ); 28 | }; 29 | 30 | export default Pagination; 31 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/Search.tsx: -------------------------------------------------------------------------------- 1 | import { SearchIcon } from "@heroicons/react/solid"; 2 | 3 | interface ISearchProps { 4 | onSearchChange: any; 5 | } 6 | 7 | const Search = (props: ISearchProps) => { 8 | const { onSearchChange } = props; 9 | 10 | return ( 11 |
12 |
18 | 24 | 27 |
28 |
29 |
31 | 39 |
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default Search; 46 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation } from "react-router-dom"; 2 | import { BookOpenIcon, PlusIcon } from "@heroicons/react/outline"; 3 | import { Link } from "react-router-dom"; 4 | import UserMenu from "./UserMenu"; 5 | import Logo from "./logo"; 6 | import { classNames } from "../../utils/styles"; 7 | 8 | interface ISideBarProps { 9 | state: any; 10 | isAuthenticated: boolean; 11 | } 12 | 13 | export default function SideBar(props: ISideBarProps) { 14 | const { pathname } = useLocation(); 15 | const { state, isAuthenticated } = props; 16 | const projRegex = /\/projects\/?$/; 17 | const navigation = [ 18 | { 19 | name: "Projects", 20 | href: "/projects", 21 | icon: BookOpenIcon, 22 | current: pathname.match(projRegex) || pathname == "/" ? true : false, 23 | visible: isAuthenticated 24 | }, 25 | { 26 | name: "New project", 27 | href: "/projects/new", 28 | icon: PlusIcon, 29 | current: false, 30 | visible: true 31 | } 32 | ]; 33 | 34 | const userName = state.user ? state.user.username : ""; 35 | 36 | return ( 37 | <> 38 |
39 |
40 |
41 | 42 | 43 | 44 |
45 | 46 |
47 | 71 | 72 | 76 |
77 |
78 |
79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface ISpinnerProps { 4 | className: string; 5 | } 6 | 7 | const Spinner = (props: ISpinnerProps) => { 8 | const { className } = props; 9 | 10 | return ( 11 | 17 | 25 | 30 | 31 | ); 32 | }; 33 | 34 | export default Spinner; 35 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router-dom"; 2 | import { UserCircleIcon } from "@heroicons/react/solid"; 3 | 4 | interface IUserMenuProps { 5 | username: string; 6 | current: boolean; 7 | } 8 | 9 | export default function UserMenu(props: IUserMenuProps) { 10 | const { username, current } = props; 11 | const navigate = useNavigate(); 12 | 13 | return ( 14 |
{ 16 | navigate("/profile"); 17 | }} 18 | className={` 19 | ${ 20 | current ? "bg-blue-800 text-white" : "text-blue-100 hover:bg-blue-600" 21 | }, 22 | flex md:border-t md:border-blue-800 p-4 md:w-full hover:cursor-pointer hover:bg-blue-600 23 | `} 24 | > 25 |
26 | 27 |
28 |

29 | {username ? <>{username} : <>Log in} 30 |

31 |
32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/VisibilitySwitch/index.tsx: -------------------------------------------------------------------------------- 1 | import { EyeIcon, EyeOffIcon } from "@heroicons/react/solid"; 2 | import { CallbackFunction } from "../../../types"; 3 | 4 | interface IVisibilitySwitchProps { 5 | onToggle: CallbackFunction; 6 | isVisible: boolean; 7 | } 8 | 9 | const VisibilitySwitch = (props: IVisibilitySwitchProps) => { 10 | const { isVisible, onToggle } = props; 11 | 12 | return ( 13 |
14 | 34 |
35 | ); 36 | }; 37 | 38 | export default VisibilitySwitch; 39 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/dc-logo.tsx: -------------------------------------------------------------------------------- 1 | const DcLogo = () => { 2 | return ( 3 | 10 | 11 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default DcLogo; 30 | -------------------------------------------------------------------------------- /services/frontend/src/components/global/logo.tsx: -------------------------------------------------------------------------------- 1 | const Logo = () => { 2 | return ( 3 | 10 | 14 | 23 | 32 | 33 | ); 34 | }; 35 | 36 | export default Logo; 37 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/ConfirmDelete.tsx: -------------------------------------------------------------------------------- 1 | import { XIcon, ExclamationIcon } from "@heroicons/react/outline"; 2 | 3 | interface IModalConfirmDeleteProps { 4 | onConfirm: any; 5 | onHide: any; 6 | } 7 | 8 | const ModalConfirmDelete = (props: IModalConfirmDeleteProps) => { 9 | const { onConfirm, onHide } = props; 10 | 11 | return ( 12 |
13 |
14 |
18 |
19 |
20 |
21 |

Confirm delete

22 | 30 |
31 | 32 |
33 |
34 |
35 |
40 |
41 |
42 |

43 | Careful! This action cannot be undone. 44 |

45 |
46 |
47 |
48 |
49 | 50 |
51 | 58 | 59 | 69 |
70 |
71 |
72 |
73 |
74 | ); 75 | }; 76 | 77 | export default ModalConfirmDelete; 78 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/network/CreateNetworkModal.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement, useMemo, useState } from "react"; 2 | import { Formik } from "formik"; 3 | import { Button, styled } from "@mui/material"; 4 | import General from "./General"; 5 | import IPAM from "./IPAM"; 6 | import { CallbackFunction } from "../../../../types"; 7 | import { getInitialValues, tabs, validationSchema } from "./form-utils"; 8 | import { reportErrorsAndSubmit } from "../../../../utils/forms"; 9 | import { ScrollView } from "../../../ScrollView"; 10 | import Tabs from "../../../Tabs"; 11 | import Tab from "../../../Tab"; 12 | 13 | interface ICreateNetworkModalProps { 14 | onCreateNetwork: CallbackFunction; 15 | } 16 | 17 | const Actions = styled("div")` 18 | display: flex; 19 | flex-direction: row; 20 | justify-content: flex-end; 21 | align-items: center; 22 | padding: ${({ theme }) => theme.spacing(1)}; 23 | `; 24 | 25 | const CreateButton = styled(Button)` 26 | text-transform: none; 27 | `; 28 | 29 | const CreateNetworkModal: FunctionComponent = ( 30 | props: ICreateNetworkModalProps 31 | ): ReactElement => { 32 | const { onCreateNetwork } = props; 33 | const [openTab, setOpenTab] = useState("General"); 34 | const initialValues = useMemo(() => getInitialValues(), []); 35 | 36 | return ( 37 | 43 | {(formik) => ( 44 | <> 45 | 46 | {tabs.map((tab) => ( 47 | 48 | ))} 49 | 50 | 51 | 52 | {openTab === "General" && } 53 | {openTab === "IPAM" && } 54 | 55 | 56 | 57 | 64 | Create 65 | 66 | 67 | 68 | )} 69 | 70 | ); 71 | }; 72 | 73 | export default CreateNetworkModal; 74 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/network/EditNetworkModal.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { Formik } from "formik"; 3 | import { Button, styled } from "@mui/material"; 4 | import General from "./General"; 5 | import IPAM from "./IPAM"; 6 | import { CallbackFunction } from "../../../../types"; 7 | import { getInitialValues, tabs, validationSchema } from "./form-utils"; 8 | import { reportErrorsAndSubmit } from "../../../../utils/forms"; 9 | import { ScrollView } from "../../../ScrollView"; 10 | import Tabs from "../../../Tabs"; 11 | import Tab from "../../../Tab"; 12 | 13 | interface IEditNetworkModalProps { 14 | onUpdateNetwork: CallbackFunction; 15 | network: any; 16 | } 17 | 18 | const Actions = styled("div")` 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: flex-end; 22 | align-items: center; 23 | padding: ${({ theme }) => theme.spacing(1)}; 24 | `; 25 | 26 | const SaveButton = styled(Button)` 27 | text-transform: none; 28 | `; 29 | 30 | const EditNetworkModal = (props: IEditNetworkModalProps) => { 31 | const { onUpdateNetwork, network } = props; 32 | const [openTab, setOpenTab] = useState("General"); 33 | const initialValues = useMemo(() => getInitialValues(network), [network]); 34 | 35 | return ( 36 | 42 | {(formik) => ( 43 | <> 44 | 45 | {tabs.map((tab) => ( 46 | 47 | ))} 48 | 49 | 50 | 54 | {openTab === "General" && } 55 | {openTab === "IPAM" && } 56 | 57 | 58 | 59 | 66 | Save 67 | 68 | 69 | 70 | )} 71 | 72 | ); 73 | }; 74 | 75 | export default EditNetworkModal; 76 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/network/EmptyNetworks.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement } from "react"; 2 | import { Button, styled } from "@mui/material"; 3 | import { PlusIcon } from "@heroicons/react/outline"; 4 | 5 | const Root = styled("div")` 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | align-items: center; 10 | padding: ${({ theme }) => theme.spacing(2, 5, 5, 5)}; 11 | text-align: center; 12 | `; 13 | 14 | const AddButton = styled(Button)` 15 | margin-top: ${({ theme }) => theme.spacing(1)}; 16 | text-transform: none; 17 | `; 18 | 19 | const Description = styled("p")` 20 | margin-top: ${({ theme }) => theme.spacing(1)}; 21 | text-align: center; 22 | color: #7a7a7a; 23 | font-size: 14px; 24 | `; 25 | 26 | export interface IEmptyNetworksProps { 27 | onCreate: () => void; 28 | } 29 | 30 | const EmptyNetworks: FunctionComponent = ( 31 | props: IEmptyNetworksProps 32 | ): ReactElement => { 33 | const { onCreate } = props; 34 | return ( 35 | 36 | No top-level networks available 37 | 38 | 45 | 46 | New network 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default EmptyNetworks; 53 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/network/General.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material"; 2 | import TextField from "../../../global/FormElements/TextField"; 3 | import Records from "../../../Records"; 4 | 5 | const Root = styled("div")` 6 | display: flex; 7 | flex-direction: column; 8 | row-gap: ${({ theme }) => theme.spacing(1)}; 9 | @media (max-width: 640px) { 10 | row-gap: 0; 11 | } 12 | `; 13 | 14 | const Group = styled("div")` 15 | display: grid; 16 | grid-template-columns: repeat(2, 1fr); 17 | grid-column-gap: 0px; 18 | grid-row-gap: 0px; 19 | @media (max-width: 640px) { 20 | grid-template-columns: repeat(1, 1fr); 21 | } 22 | column-gap: ${({ theme }) => theme.spacing(1)}; 23 | width: 100%; 24 | `; 25 | 26 | const General = () => { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | [ 41 | { 42 | name: `labels[${index}].key`, 43 | placeholder: "Key", 44 | required: true, 45 | type: "text" 46 | }, 47 | { 48 | name: `labels[${index}].value`, 49 | placeholder: "Value", 50 | required: false, 51 | type: "text" 52 | } 53 | ]} 54 | newValue={{ key: "", value: "" }} 55 | /> 56 | 57 | ); 58 | }; 59 | 60 | export default General; 61 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/network/IPAM.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, ReactElement } from "react"; 2 | import { styled } from "@mui/material"; 3 | import TextField from "../../../global/FormElements/TextField"; 4 | import Records from "../../../Records"; 5 | 6 | const Root = styled("div")` 7 | display: flex; 8 | flex-direction: column; 9 | row-gap: ${({ theme }) => theme.spacing(1)}; 10 | @media (max-width: 640px) { 11 | row-gap: 0; 12 | } 13 | `; 14 | 15 | const Group = styled("div")` 16 | display: grid; 17 | grid-template-columns: repeat(2, 1fr); 18 | grid-column-gap: 0px; 19 | grid-row-gap: 0px; 20 | @media (max-width: 640px) { 21 | grid-template-columns: repeat(1, 1fr); 22 | } 23 | column-gap: ${({ theme }) => theme.spacing(1)}; 24 | width: 100%; 25 | `; 26 | 27 | const Field = styled("div")` 28 | width: 100%; 29 | `; 30 | 31 | const Remove = styled("div")` 32 | margin-top: ${({ theme }) => theme.spacing(2)}; 33 | `; 34 | 35 | const Configuration = styled("div")` 36 | display: flex; 37 | flex-direction: column; 38 | row-gap: ${({ theme }) => theme.spacing(1)}; 39 | border-left: 4px solid #e0e8ff; 40 | padding-left: 10px; 41 | `; 42 | 43 | const ConfigurationTop = styled("div")` 44 | display: flex; 45 | flex-direction: row; 46 | @media (max-width: 640px) { 47 | flex-direction: column; 48 | } 49 | column-gap: ${({ theme }) => theme.spacing(1)}; 50 | `; 51 | 52 | const ConfigurationBorder = styled("div")` 53 | height: 1px; 54 | margin: 8px 0px 0px 0px; 55 | `; 56 | 57 | const IPAM: FunctionComponent = (): ReactElement => { 58 | return ( 59 | 60 | 61 | 62 | 63 | 64 | [ 68 | { 69 | name: `configurations[${index}].subnet`, 70 | label: "Subnet", 71 | type: "text" 72 | }, 73 | { 74 | name: `configurations[${index}].ipRange`, 75 | label: "IP Range", 76 | type: "text" 77 | }, 78 | { 79 | name: `configurations[${index}].gateway`, 80 | label: "Gateway", 81 | type: "text" 82 | }, 83 | { 84 | name: `configurations[${index}].auxAddresses`, 85 | type: "records", 86 | // TODO: Remove placeholder from the main object. 87 | placeholder: "", 88 | options: { 89 | defaultOpen: true, 90 | name: `configurations[${index}].auxAddresses`, 91 | modal: "configuration", 92 | title: "Aux addresses", 93 | referred: "aux address", 94 | fields: (index2: number) => [ 95 | { 96 | name: `configurations[${index}].auxAddresses[${index2}].hostName`, 97 | label: "Host name", 98 | type: "text" 99 | }, 100 | { 101 | name: `configurations[${index}].auxAddresses[${index2}].ipAddress`, 102 | label: "IP address", 103 | type: "text" 104 | } 105 | ], 106 | newValue: { 107 | hostName: "", 108 | ipAddress: "" 109 | }, 110 | renderField: (element: ReactElement): ReactElement => ( 111 | {element} 112 | ), 113 | renderRemove: (element: ReactElement): ReactElement => ( 114 | {element} 115 | ) 116 | } 117 | } 118 | ]} 119 | newValue={{ 120 | subnet: "", 121 | ipRange: "", 122 | gateway: "", 123 | auxAddresses: [] 124 | }} 125 | renderLayout={(elements: ReactElement[]): ReactElement => ( 126 | 127 | 128 | {elements[0]} 129 | {elements[1]} 130 | {elements[2]} 131 | 132 | {elements[3]} 133 | 134 | )} 135 | renderField={(element: ReactElement): ReactElement => ( 136 | {element} 137 | )} 138 | renderRemove={(element: ReactElement): ReactElement => ( 139 | {element} 140 | )} 141 | renderBorder={() => } 142 | /> 143 | 144 | [ 148 | { 149 | name: `options[${index}].key`, 150 | placeholder: "Key", 151 | required: true, 152 | type: "text" 153 | }, 154 | { 155 | name: `options[${index}].value`, 156 | placeholder: "Value", 157 | required: true, 158 | type: "text" 159 | } 160 | ]} 161 | newValue={{ key: "", value: "" }} 162 | renderField={(element: ReactElement): ReactElement => ( 163 | {element} 164 | )} 165 | /> 166 | 167 | ); 168 | }; 169 | 170 | export default IPAM; 171 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/network/NetworkList.tsx: -------------------------------------------------------------------------------- 1 | import { MinusSmIcon, PlusIcon } from "@heroicons/react/outline"; 2 | import { IconButton, Button, styled } from "@mui/material"; 3 | import { FunctionComponent, ReactElement } from "react"; 4 | import { truncateStr } from "../../../../utils"; 5 | 6 | export interface INetworkListProps { 7 | networks: Record; 8 | selectedUuid: string; 9 | onEdit: (networkUuid: string) => void; 10 | onNew: () => void; 11 | onRemove: (networkUuid: string) => void; 12 | } 13 | 14 | interface IListItemProps { 15 | selected: boolean; 16 | } 17 | 18 | const Root = styled("div")` 19 | padding: ${({ theme }) => theme.spacing(0)}; 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: space-between; 23 | border-right: solid #eaeaea 1px; 24 | `; 25 | 26 | const Top = styled("div")` 27 | display: flex; 28 | flex-direction: column; 29 | `; 30 | 31 | const Bottom = styled("div")` 32 | padding: ${({ theme }) => theme.spacing(1, 2)}; 33 | `; 34 | 35 | const ListItem = styled("div")` 36 | display: flex; 37 | flex-direction: row; 38 | justify-content: space-between; 39 | align-items: center; 40 | column-gap: ${({ theme }) => theme.spacing(1)}; 41 | padding: ${({ theme }) => theme.spacing(1, 1, 1, 2)}; 42 | cursor: pointer; 43 | background-color: ${({ selected }) => selected && "#f5f5f5"}; 44 | 45 | &:hover { 46 | background-color: #f5f5f5; 47 | } 48 | `; 49 | 50 | const ListItemText = styled("h5")` 51 | font-weight: 400; 52 | font-size: 14px; 53 | `; 54 | 55 | const RemoveButton = styled(IconButton)` 56 | width: 24px; 57 | max-height: 16px; 58 | `; 59 | 60 | const NewButton = styled(Button)` 61 | text-transform: none; 62 | `; 63 | 64 | const NetworkList: FunctionComponent = ( 65 | props: INetworkListProps 66 | ): ReactElement => { 67 | const { onNew, onRemove, onEdit, networks, selectedUuid } = props; 68 | 69 | const handleEdit = (networkUuid: string) => () => onEdit(networkUuid); 70 | 71 | const handleRemove = (e: any, networkUuid: string) => { 72 | e.stopPropagation(); 73 | onRemove(networkUuid); 74 | }; 75 | 76 | return ( 77 | 78 | 79 | {Object.keys(networks).map((networkUuid: string) => ( 80 | 85 | 86 | {truncateStr(networks[networkUuid].canvasConfig.node_name, 10)} 87 | 88 | handleRemove(e, networkUuid)} 92 | > 93 | 94 | 95 | 96 | ))} 97 | 98 | 99 | 100 | 107 | 108 | New network 109 | 110 | 111 | 112 | ); 113 | }; 114 | 115 | export default NetworkList; 116 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/network/form-utils.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | import { 3 | IEditNetworkForm, 4 | IIPAM, 5 | INetworkNodeItem, 6 | IPAMConfig 7 | } from "../../../../types"; 8 | import { pruneArray, pruneObject } from "../../../../utils/forms"; 9 | 10 | export const validationSchema = yup.object({ 11 | entryName: yup 12 | .string() 13 | .max(256, "Entry name should be 256 characters or less") 14 | .required("Entry name is required"), 15 | 16 | networkName: yup 17 | .string() 18 | .max(256, "Network name should be 256 characters or less") 19 | .required("Network name is required"), 20 | 21 | driver: yup.string().max(256, "Driver should be 256 characters or less"), 22 | 23 | configurations: yup.array( 24 | yup.object({ 25 | subnet: yup.string(), 26 | ipRange: yup.string(), 27 | gateway: yup.string(), 28 | auxAddresses: yup.array( 29 | yup.object({ 30 | hostName: yup.string().required("Host name is required"), 31 | ipAddress: yup.string().required("IP address is required") 32 | }) 33 | ) 34 | }) 35 | ), 36 | 37 | options: yup.array( 38 | yup.object({ 39 | key: yup.string().required("Key is required"), 40 | value: yup.string().required("Value is required") 41 | }) 42 | ), 43 | 44 | labels: yup.array( 45 | yup.object({ 46 | key: yup.string().required("Key is required"), 47 | value: yup.string() 48 | }) 49 | ) 50 | }); 51 | 52 | export const tabs = [ 53 | { 54 | name: "General", 55 | href: "#", 56 | current: true, 57 | hidden: false 58 | }, 59 | { 60 | name: "IPAM", 61 | href: "#", 62 | current: false, 63 | hidden: false 64 | } 65 | ]; 66 | 67 | export const initialValues: IEditNetworkForm = { 68 | entryName: "", 69 | networkName: "", 70 | driver: "", 71 | configurations: [], 72 | options: [], 73 | labels: [] 74 | }; 75 | 76 | export const getInitialValues = (node?: INetworkNodeItem): IEditNetworkForm => { 77 | if (!node) { 78 | return { 79 | ...initialValues 80 | }; 81 | } 82 | 83 | const { canvasConfig, networkConfig } = node; 84 | const { node_name = "" } = canvasConfig; 85 | const { name = "", ipam, labels } = networkConfig; 86 | 87 | return { 88 | ...initialValues, 89 | entryName: node_name, 90 | networkName: name, 91 | driver: ipam?.driver ?? "", 92 | configurations: 93 | ipam?.config?.map((item) => ({ 94 | subnet: item.subnet ?? "", 95 | ipRange: item.ip_range ?? "", 96 | gateway: item.gateway ?? "", 97 | auxAddresses: Object.entries(item.aux_addresses ?? []).map( 98 | ([hostName, ipAddress]) => ({ 99 | hostName, 100 | ipAddress 101 | }) 102 | ) 103 | })) ?? [], 104 | options: Object.keys(ipam?.options ?? {}).map((key) => { 105 | return { 106 | key, 107 | value: ipam?.options?.[key].toString() ?? "" 108 | }; 109 | }), 110 | labels: labels 111 | ? Object.entries(labels as any).map(([key, value]: any) => ({ 112 | key, 113 | value 114 | })) 115 | : [] 116 | }; 117 | }; 118 | 119 | export const getFinalValues = ( 120 | values: IEditNetworkForm, 121 | previous?: INetworkNodeItem 122 | ): INetworkNodeItem => { 123 | const { labels, driver, configurations, options } = values; 124 | 125 | return { 126 | key: previous?.key ?? "network", 127 | type: "NETWORK", 128 | position: { 129 | left: 0, 130 | top: 0 131 | }, 132 | inputs: previous?.inputs ?? [], 133 | outputs: previous?.outputs ?? [], 134 | canvasConfig: { 135 | node_name: values.entryName 136 | }, 137 | networkConfig: { 138 | name: values.networkName, 139 | ipam: pruneObject({ 140 | driver: driver ? driver : undefined, 141 | config: pruneArray( 142 | configurations.map((configuration) => 143 | pruneObject({ 144 | subnet: configuration.subnet ? configuration.subnet : undefined, 145 | ip_range: configuration.ipRange 146 | ? configuration.ipRange 147 | : undefined, 148 | gateway: configuration.gateway 149 | ? configuration.gateway 150 | : undefined, 151 | aux_addresses: (() => { 152 | if (configuration.auxAddresses.length === 0) { 153 | return undefined; 154 | } 155 | 156 | /* We do not have to worry about empty `hostName` and `ipAddress` 157 | * values because Yup would report such values as error. 158 | */ 159 | return Object.fromEntries( 160 | configuration.auxAddresses.map((auxAddress) => [ 161 | auxAddress.hostName, 162 | auxAddress.ipAddress 163 | ]) 164 | ); 165 | })() 166 | }) 167 | ) 168 | ) as IPAMConfig[], 169 | options: (() => { 170 | if (options.length === 0) { 171 | return undefined; 172 | } 173 | 174 | /* We do not have to worry about empty `key` and `value` 175 | * values because Yup would report such values as error. 176 | */ 177 | return Object.fromEntries( 178 | options.map((option) => [option.key, option.value]) 179 | ); 180 | })() 181 | }) as IIPAM, 182 | labels: pruneObject( 183 | Object.fromEntries(labels.map((label) => [label.key, label.value])) 184 | ) as Record 185 | } 186 | }; 187 | }; 188 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/network/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { styled } from "@mui/material"; 3 | import CreateNetworkModal from "./CreateNetworkModal"; 4 | import { CallbackFunction, IEditNetworkForm } from "../../../../types"; 5 | import EditNetworkModal from "./EditNetworkModal"; 6 | import { attachUUID, toaster } from "../../../../utils"; 7 | import { getFinalValues } from "./form-utils"; 8 | import EmptyNetworks from "./EmptyNetworks"; 9 | import NetworkList from "./NetworkList"; 10 | import Modal from "../../../Modal"; 11 | 12 | interface IModalNetworkProps { 13 | networks: Record; 14 | onCreateNetwork: CallbackFunction; 15 | onUpdateNetwork: CallbackFunction; 16 | onDeleteNetwork: CallbackFunction; 17 | onHide: CallbackFunction; 18 | } 19 | 20 | const Container = styled("div")` 21 | display: flex; 22 | flex-direction: row; 23 | `; 24 | 25 | const NetworkFormContainer = styled("div")` 26 | display: flex; 27 | flex-direction: column; 28 | `; 29 | 30 | const ModalNetwork = (props: IModalNetworkProps) => { 31 | const { 32 | networks, 33 | onCreateNetwork, 34 | onUpdateNetwork, 35 | onDeleteNetwork, 36 | onHide 37 | } = props; 38 | const [selectedNetwork, setSelectedNetwork] = useState(); 39 | const [showCreate, setShowCreate] = useState(false); 40 | 41 | const handleCreate = (values: IEditNetworkForm) => { 42 | const finalValues = getFinalValues(values); 43 | const uniqueKey = attachUUID(finalValues.key); 44 | const network = { 45 | ...finalValues, 46 | key: uniqueKey 47 | }; 48 | onCreateNetwork(network); 49 | setSelectedNetwork(network); 50 | 51 | toaster(`Created "${values.entryName}" network successfully`, "success"); 52 | }; 53 | 54 | const handleUpdate = (values: IEditNetworkForm) => { 55 | const finalValues = getFinalValues(values, selectedNetwork); 56 | onUpdateNetwork(finalValues); 57 | setSelectedNetwork(finalValues); 58 | 59 | toaster(`Updated "${values.entryName}" network successfully`, "success"); 60 | }; 61 | 62 | const handleRemove = useCallback( 63 | (networkUuid: string) => { 64 | onDeleteNetwork(networkUuid); 65 | if (selectedNetwork?.key === networkUuid) { 66 | setSelectedNetwork(null); 67 | } 68 | }, 69 | [onDeleteNetwork, selectedNetwork] 70 | ); 71 | 72 | const handleNew = useCallback(() => { 73 | setShowCreate(true); 74 | setSelectedNetwork(null); 75 | }, []); 76 | 77 | const handleEdit = useCallback( 78 | (networkUuid: string) => { 79 | setSelectedNetwork(networks[networkUuid]); 80 | }, 81 | [networks] 82 | ); 83 | 84 | const networkKeys = Object.keys(networks); 85 | 86 | return ( 87 | 91 | {networkKeys.length === 0 && !showCreate && ( 92 | 93 | )} 94 | 95 | {(networkKeys.length > 0 || showCreate) && ( 96 | 97 | {networkKeys.length > 0 && ( 98 | 105 | )} 106 | 107 | 108 | {!selectedNetwork && ( 109 | 110 | )} 111 | 112 | {selectedNetwork && ( 113 | 117 | )} 118 | 119 | 120 | )} 121 | 122 | ); 123 | }; 124 | 125 | export default ModalNetwork; 126 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/service/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline"; 2 | import { IconButton, styled } from "@mui/material"; 3 | import { FunctionComponent, ReactElement, ReactNode } from "react"; 4 | import { useAccordionState } from "../../../../hooks"; 5 | 6 | export interface IAccordionProps { 7 | id: string; 8 | title: string; 9 | defaultOpen?: boolean; 10 | children: ReactNode; 11 | } 12 | 13 | const Root = styled("div")` 14 | display: flex; 15 | flex-direction: column; 16 | `; 17 | 18 | const Top = styled("div")` 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: space-between; 22 | align-items: center; 23 | 24 | &:hover { 25 | cursor: pointer; 26 | user-select: none; 27 | } 28 | `; 29 | 30 | const Title = styled("h5")` 31 | font-size: 0.85rem; 32 | color: #374151; 33 | font-weight: 700; 34 | width: 100%; 35 | text-align: left; 36 | `; 37 | 38 | const ExpandButton = styled(IconButton)` 39 | border-radius: ${({ theme }) => theme.spacing(2)}; 40 | `; 41 | 42 | const Bottom = styled("div")` 43 | display: flex; 44 | flex-direction: column; 45 | row-gap: ${({ theme }) => theme.spacing(1)}; 46 | `; 47 | 48 | const Accordion: FunctionComponent = ( 49 | props: IAccordionProps 50 | ): ReactElement => { 51 | const { id, defaultOpen = false, children, title } = props; 52 | 53 | const { open, toggle } = useAccordionState(id, defaultOpen); 54 | 55 | return ( 56 | 57 | 58 | {title} 59 | 60 | {open && } 61 | {!open && } 62 | 63 | 64 | {open && {children}} 65 | 66 | ); 67 | }; 68 | 69 | export default Accordion; 70 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/service/Build.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material"; 2 | import TextField from "../../../global/FormElements/TextField"; 3 | import Records from "../../../Records"; 4 | 5 | const Root = styled("div")` 6 | display: flex; 7 | flex-direction: column; 8 | row-gap: ${({ theme }) => theme.spacing(1)}; 9 | @media (max-width: 640px) { 10 | row-gap: 0; 11 | } 12 | `; 13 | 14 | const Build = () => { 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | [ 31 | { 32 | name: `build.arguments[${index}].key`, 33 | placeholder: "Key", 34 | required: true, 35 | type: "text" 36 | }, 37 | { 38 | name: `build.arguments[${index}].value`, 39 | placeholder: "Value", 40 | type: "text" 41 | } 42 | ]} 43 | newValue={{ 44 | key: "", 45 | value: "" 46 | }} 47 | /> 48 | 49 | [ 53 | { 54 | name: `build.labels[${index}].key`, 55 | placeholder: "Key", 56 | required: true, 57 | type: "text" 58 | }, 59 | { 60 | name: `build.labels[${index}].value`, 61 | placeholder: "Value", 62 | type: "text" 63 | } 64 | ]} 65 | newValue={{ key: "", value: "" }} 66 | /> 67 | 68 | [ 72 | { 73 | name: `build.sshAuthentications[${index}].id`, 74 | placeholder: "ID", 75 | required: true, 76 | type: "text" 77 | }, 78 | { 79 | name: `build.sshAuthentications[${index}].path`, 80 | placeholder: "Path", 81 | type: "text" 82 | } 83 | ]} 84 | newValue={{ 85 | id: "", 86 | path: "" 87 | }} 88 | /> 89 | 90 | [ 94 | { 95 | name: `build.cacheFrom[${index}]`, 96 | placeholder: "Location", 97 | required: true, 98 | type: "text" 99 | } 100 | ]} 101 | newValue={""} 102 | /> 103 | 104 | [ 108 | { 109 | name: `build.cacheTo[${index}]`, 110 | placeholder: "Location", 111 | required: true, 112 | type: "text" 113 | } 114 | ]} 115 | newValue={""} 116 | /> 117 | 118 | [ 122 | { 123 | name: `build.extraHosts[${index}].hostName`, 124 | placeholder: "Host name", 125 | required: true, 126 | type: "text" 127 | }, 128 | { 129 | name: `build.extraHosts[${index}].ipAddress`, 130 | placeholder: "IP address", 131 | required: true, 132 | type: "text" 133 | } 134 | ]} 135 | newValue={{ 136 | hostName: "", 137 | ipAddress: "" 138 | }} 139 | /> 140 | 141 | ); 142 | }; 143 | 144 | export default Build; 145 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/service/Create.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { 3 | CallbackFunction, 4 | IEditServiceForm, 5 | IServiceNodeItem 6 | } from "../../../../types"; 7 | import { 8 | getFinalValues, 9 | getInitialValues, 10 | tabs, 11 | validationSchema 12 | } from "./form-utils"; 13 | import { toaster } from "../../../../utils"; 14 | import FormModal from "../../../FormModal"; 15 | 16 | interface IModalServiceProps { 17 | onHide: CallbackFunction; 18 | onAddEndpoint: CallbackFunction; 19 | } 20 | 21 | const CreateServiceModal = (props: IModalServiceProps) => { 22 | const { onHide, onAddEndpoint } = props; 23 | 24 | const handleCreate = useCallback( 25 | (finalValues: IServiceNodeItem, values: IEditServiceForm) => { 26 | onHide(); 27 | onAddEndpoint(finalValues); 28 | toaster( 29 | `Created "${values.serviceName}" service successfully`, 30 | "success" 31 | ); 32 | }, 33 | [onAddEndpoint, onHide] 34 | ); 35 | 36 | return ( 37 | 46 | ); 47 | }; 48 | 49 | export default CreateServiceModal; 50 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/service/Data.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material"; 2 | import Records from "../../../Records"; 3 | 4 | const Root = styled("div")` 5 | display: flex; 6 | flex-direction: column; 7 | row-gap: ${({ theme }) => theme.spacing(1)}; 8 | @media (max-width: 640px) { 9 | row-gap: 0; 10 | } 11 | `; 12 | 13 | const Volumes = () => { 14 | return ( 15 | 16 | [ 20 | { 21 | name: `volumes[${index}].name`, 22 | placeholder: "Name", 23 | required: true, 24 | type: "text" 25 | }, 26 | { 27 | name: `volumes[${index}].containerPath`, 28 | placeholder: "Container path", 29 | type: "text" 30 | }, 31 | { 32 | name: `volumes[${index}].accessMode`, 33 | placeholder: "Access mode", 34 | type: "text" 35 | } 36 | ]} 37 | newValue={{ 38 | name: "", 39 | containerPath: "", 40 | accessMode: "" 41 | }} 42 | /> 43 | 44 | ); 45 | }; 46 | 47 | export default Volumes; 48 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/service/Edit.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | import type { 4 | CallbackFunction, 5 | IEditServiceForm, 6 | IServiceNodeItem 7 | } from "../../../../types"; 8 | import { 9 | getInitialValues, 10 | getFinalValues, 11 | validationSchema, 12 | tabs 13 | } from "./form-utils"; 14 | import { toaster } from "../../../../utils"; 15 | import FormModal from "../../../FormModal"; 16 | 17 | export interface IModalServiceProps { 18 | node: IServiceNodeItem; 19 | onHide: CallbackFunction; 20 | onUpdateEndpoint: CallbackFunction; 21 | } 22 | 23 | const ModalServiceEdit = (props: IModalServiceProps) => { 24 | const { node, onHide, onUpdateEndpoint } = props; 25 | const [selectedNode, setSelectedNode] = useState(); 26 | 27 | useEffect(() => { 28 | if (node) { 29 | setSelectedNode(node); 30 | } 31 | }, [node]); 32 | 33 | const handleUpdate = ( 34 | finalValues: IServiceNodeItem, 35 | values: IEditServiceForm 36 | ) => { 37 | onUpdateEndpoint(finalValues); 38 | toaster(`Updated "${values.serviceName}" service successfully`, "success"); 39 | }; 40 | 41 | return ( 42 | 52 | ); 53 | }; 54 | 55 | export default ModalServiceEdit; 56 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/service/Environment.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material"; 2 | import Records from "../../../Records"; 3 | 4 | const Root = styled("div")` 5 | display: flex; 6 | flex-direction: column; 7 | row-gap: ${({ theme }) => theme.spacing(1)}; 8 | @media (max-width: 640px) { 9 | row-gap: 0; 10 | } 11 | `; 12 | 13 | const Environment = () => { 14 | return ( 15 | 16 | [ 21 | { 22 | name: `environmentVariables[${index}].key`, 23 | placeholder: "Key", 24 | required: true, 25 | type: "text" 26 | }, 27 | { 28 | name: `environmentVariables[${index}].value`, 29 | placeholder: "Value", 30 | required: false, 31 | type: "text" 32 | } 33 | ]} 34 | newValue={{ 35 | key: "", 36 | value: "" 37 | }} 38 | /> 39 | 40 | ); 41 | }; 42 | 43 | export default Environment; 44 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/volume/CreateVolumeModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | import { 4 | getFinalValues, 5 | getInitialValues, 6 | tabs, 7 | validationSchema 8 | } from "./form-utils"; 9 | import { 10 | CallbackFunction, 11 | IEditVolumeForm, 12 | IVolumeNodeItem 13 | } from "../../../../types"; 14 | import { toaster } from "../../../../utils"; 15 | import FormModal from "../../../FormModal"; 16 | 17 | interface ICreateVolumeModalProps { 18 | onHide: CallbackFunction; 19 | onAddEndpoint: CallbackFunction; 20 | } 21 | 22 | const CreateVolumeModal = (props: ICreateVolumeModalProps) => { 23 | const { onHide, onAddEndpoint } = props; 24 | 25 | const handleCreate = useCallback( 26 | (finalValues: IVolumeNodeItem, values: IEditVolumeForm) => { 27 | onAddEndpoint(finalValues); 28 | toaster(`Created "${values.entryName}" volume successfully`, "success"); 29 | }, 30 | [onAddEndpoint] 31 | ); 32 | 33 | return ( 34 | 43 | ); 44 | }; 45 | 46 | export default CreateVolumeModal; 47 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/volume/EditVolumeModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import type { 3 | CallbackFunction, 4 | IEditVolumeForm, 5 | IVolumeNodeItem 6 | } from "../../../../types"; 7 | import { 8 | getFinalValues, 9 | getInitialValues, 10 | tabs, 11 | validationSchema 12 | } from "./form-utils"; 13 | import { toaster } from "../../../../utils"; 14 | import FormModal from "../../../FormModal"; 15 | 16 | interface IEditVolumeModal { 17 | node: IVolumeNodeItem; 18 | onHide: CallbackFunction; 19 | onUpdateEndpoint: CallbackFunction; 20 | } 21 | 22 | const EditVolumeModal = (props: IEditVolumeModal) => { 23 | const { node, onHide, onUpdateEndpoint } = props; 24 | const [selectedNode, setSelectedNode] = useState(); 25 | 26 | useEffect(() => { 27 | if (node) { 28 | setSelectedNode(node); 29 | } 30 | }, [node]); 31 | 32 | const handleUpdate = ( 33 | finalValues: IVolumeNodeItem, 34 | values: IEditVolumeForm 35 | ) => { 36 | onUpdateEndpoint(finalValues); 37 | toaster(`Updated "${values.entryName}" volume successfully`, "success"); 38 | }; 39 | 40 | return ( 41 | 51 | ); 52 | }; 53 | 54 | export default EditVolumeModal; 55 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/volume/General.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material"; 2 | import TextField from "../../../global/FormElements/TextField"; 3 | import Records from "../../../Records"; 4 | 5 | const Root = styled("div")` 6 | display: flex; 7 | flex-direction: column; 8 | row-gap: ${({ theme }) => theme.spacing(1)}; 9 | @media (max-width: 640px) { 10 | row-gap: 0; 11 | } 12 | `; 13 | 14 | const Group = styled("div")` 15 | display: grid; 16 | grid-template-columns: repeat(2, 1fr); 17 | grid-column-gap: 0px; 18 | grid-row-gap: 0px; 19 | @media (max-width: 640px) { 20 | grid-template-columns: repeat(1, 1fr); 21 | } 22 | column-gap: ${({ theme }) => theme.spacing(1)}; 23 | width: 100%; 24 | `; 25 | 26 | const General = () => { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | [ 41 | { 42 | name: `labels[${index}].key`, 43 | placeholder: "Key", 44 | required: true, 45 | type: "text" 46 | }, 47 | { 48 | name: `labels[${index}].value`, 49 | placeholder: "Value", 50 | required: true, 51 | type: "text" 52 | } 53 | ]} 54 | newValue={{ key: "", value: "" }} 55 | /> 56 | 57 | ); 58 | }; 59 | 60 | export default General; 61 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/docker-compose/volume/form-utils.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | import { IEditVolumeForm, IVolumeNodeItem } from "../../../../types"; 3 | import { pruneObject } from "../../../../utils/forms"; 4 | import General from "./General"; 5 | 6 | export const validationSchema = yup.object({ 7 | entryName: yup 8 | .string() 9 | .max(256, "Entry name should be 256 characters or less") 10 | .required("Entry name is required"), 11 | volumeName: yup 12 | .string() 13 | .max(256, "Volume name should be 256 characters or less") 14 | .required("Volume name is required"), 15 | labels: yup.array( 16 | yup.object({ 17 | key: yup.string().required("Key is required") 18 | }) 19 | ) 20 | }); 21 | 22 | const initialValues: IEditVolumeForm = { 23 | entryName: "", 24 | volumeName: "", 25 | labels: [] 26 | }; 27 | 28 | export const getInitialValues = (node?: IVolumeNodeItem): IEditVolumeForm => { 29 | if (!node) { 30 | return { 31 | ...initialValues 32 | }; 33 | } 34 | 35 | const { canvasConfig, volumeConfig } = node; 36 | const { node_name = "" } = canvasConfig; 37 | const { name = "", labels } = volumeConfig; 38 | 39 | return { 40 | ...initialValues, 41 | entryName: node_name, 42 | volumeName: name, 43 | labels: labels 44 | ? Object.entries(labels as any).map(([key, value]: any) => ({ 45 | key, 46 | value 47 | })) 48 | : [] 49 | }; 50 | }; 51 | 52 | export const getFinalValues = ( 53 | values: IEditVolumeForm, 54 | previous?: IVolumeNodeItem 55 | ): IVolumeNodeItem => { 56 | const { labels } = values; 57 | 58 | return { 59 | key: previous?.key ?? "volume", 60 | type: "VOLUME", 61 | position: previous?.position ?? { left: 0, top: 0 }, 62 | inputs: previous?.inputs ?? [], 63 | outputs: previous?.outputs ?? [], 64 | canvasConfig: { 65 | node_name: values.entryName 66 | }, 67 | volumeConfig: { 68 | name: values.volumeName, 69 | labels: pruneObject( 70 | Object.fromEntries(labels.map((label) => [label.key, label.value])) 71 | ) as Record 72 | } 73 | }; 74 | }; 75 | 76 | export const tabs = [ 77 | { 78 | title: "General", 79 | value: "general", 80 | component: General 81 | } 82 | ]; 83 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/import/form-utils.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export interface IImportForm { 4 | url: string; 5 | visibility: string[]; 6 | } 7 | 8 | export interface IImportFinalValues { 9 | url: string; 10 | visibility: number; 11 | } 12 | 13 | const initialValues: IImportForm = { 14 | url: "", 15 | visibility: [] 16 | }; 17 | 18 | export const validationSchema = yup.object({ 19 | url: yup 20 | .string() 21 | .max(256, "url should be 500 characters or less") 22 | .required("url is required") 23 | }); 24 | 25 | export const getInitialValues = (values?: any): IImportForm => { 26 | if (!values) { 27 | return { 28 | ...initialValues 29 | }; 30 | } 31 | 32 | const { url, visibility } = values; 33 | 34 | return { 35 | url: url ?? (initialValues.url as string), 36 | visibility: visibility ?? [] 37 | }; 38 | }; 39 | 40 | export const getFinalValues = (values: IImportForm): IImportFinalValues => { 41 | const { url, visibility } = values; 42 | 43 | return { 44 | url: url ?? "", 45 | visibility: visibility.length ? 1 : 0 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /services/frontend/src/components/modals/import/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | import { Formik } from "formik"; 3 | import { styled } from "@mui/material"; 4 | import { XIcon } from "@heroicons/react/outline"; 5 | import { CallbackFunction } from "../../../types"; 6 | import { IImportForm } from "./form-utils"; 7 | import { 8 | getFinalValues, 9 | getInitialValues, 10 | validationSchema 11 | } from "./form-utils"; 12 | import TextField from "../../global/FormElements/TextField"; 13 | import { toaster } from "../../../utils"; 14 | import { reportErrorsAndSubmit } from "../../../utils/forms"; 15 | import { ScrollView } from "../../ScrollView"; 16 | 17 | interface IModalImportProps { 18 | onHide: CallbackFunction; 19 | onImport: CallbackFunction; 20 | importing: boolean; 21 | } 22 | 23 | const FormContainer = styled("div")` 24 | display: flex; 25 | flex-direction: column; 26 | justify-content: space-between; 27 | `; 28 | 29 | const FormRowBlock = styled("div")` 30 | margin: 6px 0 0 0; 31 | display: flex; 32 | flex-direction: row; 33 | gap: ${({ theme }) => theme.spacing(1)}; 34 | `; 35 | 36 | const ModalImport = (props: IModalImportProps) => { 37 | const { onHide, onImport, importing } = props; 38 | 39 | const handleCreate = useCallback( 40 | (values: IImportForm) => { 41 | const result = getFinalValues(values); 42 | onImport(result); 43 | toaster(`Importing...`, "success"); 44 | }, 45 | [onImport, onHide] 46 | ); 47 | 48 | const initialValues = useMemo(() => getInitialValues(), []); 49 | 50 | return ( 51 |
52 |
53 |
57 |
58 |
59 |
60 |

Import

61 | 69 |
70 | 71 | 77 | {(formik) => ( 78 | 79 |
80 | 84 | 85 | 86 | 87 | 93 | 94 | 101 | 102 | 103 | {importing && <>importing} 104 | 105 |
106 | 107 |
108 | 115 |
116 |
117 | )} 118 |
119 |
120 |
121 |
122 |
123 | ); 124 | }; 125 | 126 | export default ModalImport; 127 | -------------------------------------------------------------------------------- /services/frontend/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const API_SERVER_URL = process.env.REACT_APP_API_SERVER; 2 | export const REACT_APP_GITHUB_CLIENT_ID = 3 | process.env.REACT_APP_GITHUB_CLIENT_ID; 4 | export const REACT_APP_GITHUB_SCOPE = process.env.REACT_APP_GITHUB_SCOPE; 5 | export const PROJECTS_FETCH_LIMIT = 300; 6 | export const LOCAL_STORAGE = "CtkLocalStorage"; 7 | export const manifestTypes = { 8 | DOCKER_COMPOSE: "DOCKER_COMPOSE", 9 | KUBERNETES: "KUBERNETES" 10 | }; 11 | -------------------------------------------------------------------------------- /services/frontend/src/contexts/SuperFormContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { ISuperFormContext } from "../types"; 3 | 4 | export const SuperFormContext = createContext(null); 5 | -------------------------------------------------------------------------------- /services/frontend/src/contexts/TabContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { ITabContext } from "../types"; 3 | 4 | export const TabContext = createContext(null); 5 | -------------------------------------------------------------------------------- /services/frontend/src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./TabContext"; 2 | export * from "./SuperFormContext"; 3 | -------------------------------------------------------------------------------- /services/frontend/src/events/eventBus.ts: -------------------------------------------------------------------------------- 1 | const eventBus = { 2 | on( 3 | event: string, 4 | callback: { (data: any): void; (data: any): void; (arg: any): any } 5 | ) { 6 | document.addEventListener(event, (e) => callback(e)); 7 | }, 8 | dispatch( 9 | event: string, 10 | data: { message: { id: string } | { data: any } | { node: any } } 11 | ) { 12 | document.dispatchEvent(new CustomEvent(event, { detail: data })); 13 | }, 14 | remove( 15 | event: string, 16 | callback: { (): void; (this: Document, ev: any): any } 17 | ) { 18 | document.removeEventListener(event, callback); 19 | } 20 | }; 21 | 22 | export default eventBus; 23 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/auth.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE } from "../constants"; 2 | 3 | export const useLocalStorageAuth = () => { 4 | const localStorageData = localStorage.getItem(LOCAL_STORAGE); 5 | 6 | if (localStorageData) { 7 | const authData = JSON.parse(localStorageData); 8 | return authData; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useTitle"; 2 | export * from "./useAccordionState"; 3 | export * from "./useTabContext"; 4 | export * from "./useSuperForm"; 5 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useAccordionState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | export interface IAccordionState { 4 | open: boolean; 5 | toggle: () => void; 6 | } 7 | 8 | export const useAccordionState = ( 9 | id: string, 10 | defaultOpen: boolean 11 | ): IAccordionState => { 12 | const [open, setOpen] = useState(() => { 13 | let configuration: Record = {}; 14 | const item = localStorage.getItem("accordions"); 15 | if (item) { 16 | configuration = JSON.parse(item); 17 | } 18 | 19 | if (configuration[id] === undefined) { 20 | configuration[id] = defaultOpen; 21 | localStorage.setItem( 22 | "accordions", 23 | JSON.stringify(configuration, null, 4) 24 | ); 25 | } 26 | 27 | return configuration[id]; 28 | }); 29 | 30 | const handleToggle = useCallback(() => { 31 | const item = localStorage.getItem("accordions"); 32 | if (!item) { 33 | throw new Error( 34 | "Cannot find 'accordions' in local storage, which should exist at this point. Refreshing should fix the issue, but something/somebody deleted 'accordions' from local storage." 35 | ); 36 | } 37 | const configuration = JSON.parse(item); 38 | setOpen((open) => { 39 | const result = !open; 40 | configuration[id] = result; 41 | localStorage.setItem( 42 | "accordions", 43 | JSON.stringify(configuration, null, 4) 44 | ); 45 | return result; 46 | }); 47 | }, []); 48 | 49 | return { 50 | toggle: handleToggle, 51 | open 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useImportProject.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { IImportFinalValues } from "../components/modals/import/form-utils"; 3 | import { API_SERVER_URL } from "../constants"; 4 | import { getLocalStorageJWTKeys } from "../utils"; 5 | 6 | export const importProject = async (values: IImportFinalValues) => { 7 | const jwtKeys = getLocalStorageJWTKeys(); 8 | const response = await axios({ 9 | method: "post", 10 | url: `${API_SERVER_URL}/projects/import/`, 11 | data: { ...values }, 12 | headers: { 13 | "Content-Type": "application/json", 14 | Authorization: `Bearer ${jwtKeys.access_token}` 15 | } 16 | }); 17 | return response.data; 18 | }; 19 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useLocalStorageJWTKeys.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { LOCAL_STORAGE } from "../constants"; 3 | 4 | const useLocalStorageJWTKeys = () => { 5 | const jwtKeys = localStorage.getItem(LOCAL_STORAGE); 6 | return useMemo(() => { 7 | if (jwtKeys) { 8 | return JSON.parse(jwtKeys); 9 | } 10 | 11 | return null; 12 | }, [jwtKeys]); 13 | }; 14 | 15 | export default useLocalStorageJWTKeys; 16 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useProject.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import _ from "lodash"; 3 | import { useQuery, useMutation, useQueryClient } from "react-query"; 4 | import { API_SERVER_URL } from "../constants"; 5 | import { getLocalStorageJWTKeys, toaster } from "../utils"; 6 | import { IProject, IProjectPayload } from "../types"; 7 | import useLocalStorageJWTKeys from "./useLocalStorageJWTKeys"; 8 | 9 | interface IProjectsReturn { 10 | count: number; 11 | next: string | null; 12 | previous: string | null; 13 | results: IProject[]; 14 | } 15 | 16 | export const createProject = async (project: IProjectPayload) => { 17 | const jwtKeys = getLocalStorageJWTKeys(); 18 | const requestConfig = { 19 | method: "post", 20 | url: `${API_SERVER_URL}/projects/`, 21 | headers: { 22 | "Content-Type": "application/json" 23 | }, 24 | data: project 25 | }; 26 | 27 | if (jwtKeys) { 28 | requestConfig.headers = { 29 | ...requestConfig.headers, 30 | ...{ Authorization: `Bearer ${jwtKeys.access_token}` } 31 | }; 32 | } 33 | 34 | const response = await axios(requestConfig); 35 | return response.data; 36 | }; 37 | 38 | const deleteProjectByUuid = async (uuid: string) => { 39 | const jwtKeys = getLocalStorageJWTKeys(); 40 | const requestConfig = { 41 | method: "delete", 42 | url: `${API_SERVER_URL}/projects/${uuid}/`, 43 | headers: { 44 | "Content-Type": "application/json" 45 | } 46 | }; 47 | 48 | if (jwtKeys) { 49 | requestConfig.headers = { 50 | ...requestConfig.headers, 51 | ...{ Authorization: `Bearer ${jwtKeys.access_token}` } 52 | }; 53 | } 54 | 55 | const response = await axios(requestConfig); 56 | return response.data; 57 | }; 58 | 59 | const updateProjectByUuid = async (uuid: string, data: string) => { 60 | const jwtKeys = getLocalStorageJWTKeys(); 61 | const requestConfig = { 62 | method: "put", 63 | url: `${API_SERVER_URL}/projects/${uuid}/`, 64 | headers: { 65 | "Content-Type": "application/json" 66 | }, 67 | data: data 68 | }; 69 | 70 | if (jwtKeys) { 71 | requestConfig.headers = { 72 | ...requestConfig.headers, 73 | ...{ Authorization: `Bearer ${jwtKeys.access_token}` } 74 | }; 75 | } 76 | 77 | const response = await axios(requestConfig); 78 | return response.data; 79 | }; 80 | 81 | export const useProject = (uuid: string | undefined) => { 82 | const jwtKeys = useLocalStorageJWTKeys(); 83 | 84 | return useQuery( 85 | ["projects", uuid], 86 | async () => { 87 | if (!uuid) { 88 | return; 89 | } 90 | 91 | const requestConfig = { 92 | method: "get", 93 | url: `${API_SERVER_URL}/projects/${uuid}/`, 94 | headers: { 95 | "Content-Type": "application/json" 96 | } 97 | }; 98 | 99 | if (jwtKeys) { 100 | requestConfig.headers = { 101 | ...requestConfig.headers, 102 | ...{ Authorization: `Bearer ${jwtKeys.access_token}` } 103 | }; 104 | } 105 | 106 | return (await axios(requestConfig)).data; 107 | }, 108 | { 109 | staleTime: Infinity, 110 | retry: 1 111 | } 112 | ); 113 | }; 114 | 115 | export const useUpdateProject = (uuid: string | undefined) => { 116 | const queryClient = useQueryClient(); 117 | 118 | return useMutation( 119 | async (projectData: IProjectPayload) => { 120 | if (!uuid) { 121 | return; 122 | } 123 | 124 | try { 125 | const data = await updateProjectByUuid( 126 | uuid, 127 | JSON.stringify(projectData) 128 | ); 129 | return data; 130 | } catch (err: any) { 131 | if (err.response.status === 404) { 132 | toaster("You are not the owner of this project!", "error"); 133 | } else { 134 | toaster(err.message, "error"); 135 | } 136 | } 137 | }, 138 | { 139 | onSuccess: (projectData) => { 140 | toaster("Project saved!", "success"); 141 | queryClient.setQueryData(["projects", uuid], projectData); 142 | } 143 | } 144 | ); 145 | }; 146 | 147 | export const useDeleteProject = (uuid: string | undefined) => { 148 | const queryClient = useQueryClient(); 149 | 150 | return useMutation( 151 | async () => { 152 | if (!uuid) { 153 | return; 154 | } 155 | 156 | try { 157 | const data = await deleteProjectByUuid(uuid); 158 | return data; 159 | } catch (err: any) { 160 | if (err.response.status === 404) { 161 | toaster("Resource could not be found!", "error"); 162 | } else { 163 | toaster(err.message, "error"); 164 | } 165 | } 166 | }, 167 | { 168 | onSuccess: () => { 169 | queryClient.cancelQueries("projects"); 170 | const previousProjects = queryClient.getQueryData( 171 | "projects" 172 | ) as IProjectsReturn; 173 | 174 | if (previousProjects) { 175 | const filtered = _.filter(previousProjects.results, (project) => { 176 | return project.uuid !== uuid; 177 | }); 178 | previousProjects.count = filtered.length; 179 | previousProjects.results = filtered; 180 | queryClient.setQueryData("projects", previousProjects); 181 | } else { 182 | queryClient.invalidateQueries(["projects"]); 183 | } 184 | toaster("Project deleted!", "success"); 185 | } 186 | } 187 | ); 188 | }; 189 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useProjects.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useQuery } from "react-query"; 3 | import { API_SERVER_URL } from "../constants"; 4 | import { getLocalStorageJWTKeys } from "../utils"; 5 | 6 | export const fetchProjects = async (limit: number, offset: number) => { 7 | const jwtKeys = getLocalStorageJWTKeys(); 8 | 9 | const response = await axios({ 10 | method: "get", 11 | url: `${API_SERVER_URL}/projects/?limit=${limit}&offset=${offset}`, 12 | headers: { 13 | "Content-Type": "application/json", 14 | Authorization: `Bearer ${jwtKeys.access_token}` 15 | } 16 | }); 17 | return response.data; 18 | }; 19 | 20 | export const useProjects = (limit: number, offset: number) => { 21 | return useQuery( 22 | ["projects", limit, offset], 23 | () => fetchProjects(limit, offset), 24 | { 25 | keepPreviousData: true, 26 | staleTime: Infinity 27 | } 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useSocialAuth.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { API_SERVER_URL } from "../constants"; 3 | 4 | export const socialAuth = async (code: string) => { 5 | const response = await axios({ 6 | method: "post", 7 | url: `${API_SERVER_URL}/auth/github/`, 8 | data: { 9 | code: code 10 | }, 11 | headers: { 12 | "Content-Type": "application/json" 13 | } 14 | }); 15 | return response.data; 16 | }; 17 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useSuperForm.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { SuperFormContext } from "../contexts"; 3 | import { ISuperFormContext } from "../types"; 4 | 5 | export const useSuperForm = (): ISuperFormContext => { 6 | const context = useContext(SuperFormContext); 7 | if (!context) { 8 | throw new Error("Cannot find super form context!"); 9 | } 10 | 11 | return context; 12 | }; 13 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useTabContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { TabContext } from "../contexts"; 3 | import { ITabContext } from "../types"; 4 | 5 | export const useTabContext = (): ITabContext => { 6 | const context = useContext(TabContext); 7 | if (!context) { 8 | throw new Error("Cannot find tab context."); 9 | } 10 | return context; 11 | }; 12 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useTitle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export const useTitle = (title: string) => { 4 | useEffect(() => { 5 | document.title = title; 6 | }, [title]); 7 | }; 8 | -------------------------------------------------------------------------------- /services/frontend/src/hooks/useWindowDimensions.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | function getWindowDimensions() { 4 | const { innerWidth: width, innerHeight: height } = window; 5 | return { 6 | width, 7 | height 8 | }; 9 | } 10 | 11 | export default function useWindowDimensions() { 12 | const [windowDimensions, setWindowDimensions] = useState( 13 | getWindowDimensions() 14 | ); 15 | 16 | useEffect(() => { 17 | function handleResize() { 18 | setWindowDimensions(getWindowDimensions()); 19 | } 20 | 21 | window.addEventListener("resize", handleResize); 22 | return () => window.removeEventListener("resize", handleResize); 23 | }, []); 24 | 25 | return windowDimensions; 26 | } 27 | -------------------------------------------------------------------------------- /services/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | height: 100%; 8 | overflow: hidden; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 10 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 11 | sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .canvas { 17 | position: relative; 18 | height: 100%; 19 | width: 100%; 20 | } 21 | 22 | .jsplumb-box { 23 | background-size: 16px 16px; 24 | background-image: 25 | linear-gradient(to right, #80808014 1px, transparent 1px), 26 | linear-gradient(to bottom, #80808014 1px, transparent 1px); 27 | position: relative; 28 | width: 100%; 29 | height: calc(100vh - 64px); /* 64px is the bar above the canvas */ 30 | overflow: hidden; 31 | cursor: move; 32 | user-select: none; 33 | } 34 | 35 | .node-item { 36 | border-radius: 1em; 37 | width: 150px; 38 | height: 60px; 39 | z-index: 30; 40 | position: absolute; 41 | background-color: #fff; 42 | } 43 | 44 | .node-item img { 45 | width: 26px; 46 | height: 26px; 47 | } 48 | 49 | .jtk-connector { 50 | z-index: 4; 51 | } 52 | 53 | path, 54 | .jtk-endpoint { 55 | z-index: 20; 56 | cursor: pointer; 57 | } 58 | 59 | .node-item.jtk-drag { 60 | box-shadow: 0px 0px 5px 2px rgba(75, 0, 255, 0.37); 61 | } 62 | 63 | .jtk-drag-select * { 64 | -webkit-touch-callout: none; 65 | -webkit-user-select: none; 66 | -khtml-user-select: none; 67 | -moz-user-select: none; 68 | -ms-user-select: none; 69 | user-select: none; 70 | } 71 | 72 | .endpoint { 73 | width: 14px; 74 | height: 14px; 75 | } 76 | 77 | .remove-conn-btn { 78 | background-color: #61B7CF; 79 | } 80 | 81 | .remove-conn-btn:hover { 82 | background-color: #ce4551; 83 | } 84 | 85 | .code-column { 86 | background-color: #1F2937; 87 | } 88 | 89 | .cke_reset_all .CodeMirror-scroll * { 90 | white-space: pre; 91 | } 92 | 93 | * { 94 | scrollbar-width: thin; 95 | scrollbar-color: #464646 #282c34; 96 | } 97 | 98 | *::-webkit-scrollbar { 99 | width: 12px; 100 | } 101 | 102 | *::-webkit-scrollbar-track { 103 | background: #282c34; 104 | } 105 | 106 | *::-webkit-scrollbar-thumb { 107 | background-color: #464646; 108 | border-radius: 20px; 109 | border: 5px solid #282c34; 110 | } 111 | 112 | @layer components { 113 | .btn-util { 114 | @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-indigo-500; 115 | } 116 | .lbl-util { 117 | @apply block text-xs font-bold text-gray-700 mb-1 118 | } 119 | .btn-util-red { 120 | @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-red-500; 121 | } 122 | .btn-util-selected { 123 | @apply text-white bg-indigo-500 hover:bg-indigo-500 focus:ring-indigo-500; 124 | } 125 | .input-util { 126 | @apply shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md px-2 py-1 127 | } 128 | .checkbox-util { 129 | @apply shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block sm:text-sm border-gray-300 rounded-md px-2 py-1 130 | } 131 | } 132 | 133 | code { 134 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 135 | monospace; 136 | } 137 | -------------------------------------------------------------------------------- /services/frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { BrowserRouter as Router } from "react-router-dom"; 4 | import App from "./App"; 5 | import reportWebVitals from "./reportWebVitals"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | 11 | 12 | , 13 | document.getElementById("root") 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /services/frontend/src/partials/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from "react-router-dom"; 2 | 3 | export type ProtectedRouteProps = { 4 | isAuthenticated: boolean; 5 | authenticationPath: string; 6 | outlet: JSX.Element; 7 | }; 8 | 9 | export default function ProtectedRoute({ 10 | isAuthenticated, 11 | authenticationPath, 12 | outlet 13 | }: ProtectedRouteProps) { 14 | if (isAuthenticated) { 15 | return outlet; 16 | } else { 17 | return ; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /services/frontend/src/partials/useClickOutside.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | const useOutsideClick = (ref: any, callback: any) => { 4 | const handleClick = (e: any) => { 5 | if (ref.current && !ref.current.contains(e.target)) { 6 | callback(); 7 | } 8 | }; 9 | 10 | useEffect(() => { 11 | document.addEventListener("click", handleClick); 12 | 13 | return () => { 14 | document.removeEventListener("click", handleClick); 15 | }; 16 | }); 17 | }; 18 | 19 | export default useOutsideClick; 20 | -------------------------------------------------------------------------------- /services/frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /services/frontend/src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | const AUTH_LOGIN_SUCCESS = "auth-login-success"; 2 | const AUTH_LOGOUT_SUCCESS = "auth-logout-success"; 3 | const AUTH_SELF = "auth-self"; 4 | 5 | export const initialState = { 6 | user: {} 7 | }; 8 | 9 | export const reducer = (state: any, action: any) => { 10 | switch (action.type) { 11 | case AUTH_LOGIN_SUCCESS: 12 | return { 13 | ...state, 14 | user: { ...action.payload.user } 15 | }; 16 | case AUTH_SELF: 17 | return { 18 | ...state, 19 | user: { ...action.payload } 20 | }; 21 | case AUTH_LOGOUT_SUCCESS: 22 | return { 23 | ...state, 24 | user: null 25 | }; 26 | default: 27 | throw new Error(); 28 | } 29 | }; 30 | 31 | export const authLoginSuccess = (data: any) => ({ 32 | type: AUTH_LOGIN_SUCCESS, 33 | payload: data 34 | }); 35 | 36 | export const authLogoutSuccess = () => ({ 37 | type: AUTH_LOGOUT_SUCCESS 38 | }); 39 | 40 | export const authSelf = (data: any) => ({ 41 | type: AUTH_SELF, 42 | payload: data 43 | }); 44 | -------------------------------------------------------------------------------- /services/frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /services/frontend/src/services/auth.ts: -------------------------------------------------------------------------------- 1 | import { API_SERVER_URL } from "../constants"; 2 | import { getLocalStorageJWTKeys } from "./utils"; 3 | 4 | export const signup = ( 5 | username: string, 6 | email: string, 7 | password1: string, 8 | password2: string 9 | ) => 10 | fetch(`${API_SERVER_URL}/auth/registration/`, { 11 | method: "POST", 12 | headers: { 13 | "Content-Type": "application/json" 14 | }, 15 | body: JSON.stringify({ username, email, password1, password2 }) 16 | }); 17 | 18 | export const logIn = (username: string, password: string) => 19 | fetch(`${API_SERVER_URL}/auth/login/`, { 20 | method: "POST", 21 | headers: { 22 | "Content-Type": "application/json" 23 | }, 24 | body: JSON.stringify({ username, password }) 25 | }); 26 | 27 | export const self = () => { 28 | const jwtKeys = getLocalStorageJWTKeys(); 29 | return fetch(`${API_SERVER_URL}/auth/self/`, { 30 | method: "GET", 31 | headers: { 32 | "Content-Type": "application/json", 33 | Authorization: `Bearer ${jwtKeys.access_token}` 34 | } 35 | }); 36 | }; 37 | 38 | export const refresh = () => { 39 | const jwtKeys = getLocalStorageJWTKeys(); 40 | const body = { refresh: jwtKeys.refresh_token }; 41 | 42 | return fetch(`${API_SERVER_URL}/auth/token/refresh/`, { 43 | method: "POST", 44 | headers: { 45 | "Content-Type": "application/json" 46 | }, 47 | body: JSON.stringify(body) 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /services/frontend/src/services/generate.ts: -------------------------------------------------------------------------------- 1 | import { manifestTypes } from "../constants"; 2 | import { API_SERVER_URL } from "../constants"; 3 | 4 | export const generateHttp = (data: string, manifest: string) => { 5 | let endpoint = `${API_SERVER_URL}/generate/`; 6 | if (manifest === manifestTypes.DOCKER_COMPOSE) { 7 | endpoint += "docker-compose"; 8 | } 9 | 10 | if (manifest === manifestTypes.KUBERNETES) { 11 | endpoint += "kubernetes"; 12 | } 13 | 14 | return fetch(endpoint, { 15 | method: "POST", 16 | headers: { 17 | "Content-Type": "application/json" 18 | }, 19 | body: JSON.stringify(data) 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /services/frontend/src/services/helpers.ts: -------------------------------------------------------------------------------- 1 | export const checkHttpStatus = (response: any) => { 2 | if ([200, 201, 202].includes(response.status)) { 3 | return response.json(); 4 | } 5 | 6 | if (response.status === 204) { 7 | return response; 8 | } 9 | 10 | throw response; 11 | }; 12 | 13 | export const checkHttpSuccess = (response: any) => { 14 | if ([200, 201, 202].includes(response.status)) { 15 | return response.json(); 16 | } 17 | 18 | if (response.status === 204) { 19 | return response; 20 | } 21 | 22 | throw response; 23 | }; 24 | -------------------------------------------------------------------------------- /services/frontend/src/services/utils.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE } from "../constants"; 2 | 3 | export const getLocalStorageJWTKeys = () => { 4 | const jwtKeys = localStorage.getItem(LOCAL_STORAGE); 5 | 6 | if (jwtKeys) { 7 | return JSON.parse(jwtKeys); 8 | } 9 | 10 | return null; 11 | }; 12 | -------------------------------------------------------------------------------- /services/frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /services/frontend/src/types/enums.ts: -------------------------------------------------------------------------------- 1 | export enum NodeGroupType { 2 | Services = "services" 3 | } 4 | -------------------------------------------------------------------------------- /services/frontend/src/utils/clickOutside.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | /** 4 | * Use it from the component that needs outside clicks. 5 | * 6 | * import { useClickOutside } from "../../utils/clickOutside"; 7 | * 8 | * const drop = createRef(); 9 | * useClickOutside(drop, () => { 10 | * // do stuff... 11 | * }); 12 | * 13 | * @param ref 14 | * @param onClickOutside 15 | */ 16 | export const useClickOutside = (ref: any, onClickOutside: any) => { 17 | useEffect(() => { 18 | const listener = (event: any) => { 19 | if (!ref.current || ref.current.contains(event.target)) { 20 | return; 21 | } 22 | 23 | onClickOutside(event); 24 | }; 25 | 26 | document.addEventListener("mousedown", listener); 27 | document.addEventListener("touchstart", listener); 28 | 29 | return () => { 30 | document.removeEventListener("mousedown", listener); 31 | document.removeEventListener("touchstart", listener); 32 | }; 33 | }, [ref, onClickOutside]); 34 | }; 35 | -------------------------------------------------------------------------------- /services/frontend/src/utils/data/libraries.ts: -------------------------------------------------------------------------------- 1 | import { NodeGroupType } from "../../types/enums"; 2 | import { INodeGroup } from "../../types"; 3 | 4 | export const nodeLibraries: INodeGroup[] = [ 5 | { 6 | id: 1, 7 | name: NodeGroupType.Services, 8 | description: "Services", 9 | nodeTypes: [ 10 | { 11 | id: 1, 12 | name: "service", 13 | type: "SERVICE", 14 | description: "Service node", 15 | noInputs: 1, 16 | noOutputs: 1, 17 | isActive: true 18 | }, 19 | { 20 | id: 2, 21 | name: "volume", 22 | type: "VOLUME", 23 | description: "Volume node", 24 | noInputs: 0, 25 | noOutputs: 0, 26 | isActive: true 27 | } 28 | ] 29 | } 30 | ]; 31 | -------------------------------------------------------------------------------- /services/frontend/src/utils/data/startConfig.ts: -------------------------------------------------------------------------------- 1 | export const StartConfigString = "[]"; 2 | -------------------------------------------------------------------------------- /services/frontend/src/utils/generators.ts: -------------------------------------------------------------------------------- 1 | import { IGeneratePayload } from "../types"; 2 | 3 | export const generatePayload = (payload: any): IGeneratePayload => { 4 | const nodes = payload["nodes"]; 5 | const networks = payload["networks"] || {}; 6 | const base: IGeneratePayload = { 7 | data: { 8 | version: payload["version"], 9 | networks: {}, 10 | services: {}, 11 | volumes: {} 12 | } 13 | }; 14 | 15 | Object.keys(networks).forEach((key) => { 16 | base.data.networks[networks[key].canvasConfig.node_name] = 17 | networks[key].networkConfig; 18 | }); 19 | 20 | Object.keys(nodes).forEach((key) => { 21 | if (nodes[key].type === "SERVICE") { 22 | base.data.services[nodes[key].canvasConfig.node_name] = 23 | nodes[key].serviceConfig; 24 | } 25 | 26 | if (nodes[key].type === "VOLUME") { 27 | base.data.volumes[nodes[key].canvasConfig.node_name] = 28 | nodes[key].volumeConfig; 29 | } 30 | }); 31 | 32 | return base; 33 | }; 34 | -------------------------------------------------------------------------------- /services/frontend/src/utils/options.ts: -------------------------------------------------------------------------------- 1 | import { EndpointOptions, DotEndpoint } from "@jsplumb/core"; 2 | import { BezierConnector } from "@jsplumb/connector-bezier"; 3 | import { AnchorId, PaintStyle } from "@jsplumb/common"; 4 | import { BrowserJsPlumbDefaults } from "@jsplumb/browser-ui"; 5 | 6 | const connectorPaintStyle: PaintStyle = { 7 | strokeWidth: 2, 8 | stroke: "#61B7CF" 9 | }; 10 | 11 | const connectorHoverStyle: PaintStyle = { 12 | strokeWidth: 3, 13 | stroke: "#216477" 14 | }; 15 | 16 | const endpointHoverStyle: PaintStyle = { 17 | fill: "#216477", 18 | stroke: "#216477" 19 | }; 20 | 21 | export const defaultOptions: BrowserJsPlumbDefaults = { 22 | dragOptions: { 23 | cursor: "move" 24 | } 25 | }; 26 | 27 | export const sourceEndpoint: EndpointOptions = { 28 | endpoint: { 29 | type: DotEndpoint.type, 30 | options: { 31 | radius: 8 32 | } 33 | }, 34 | paintStyle: { 35 | stroke: "#097963", 36 | fill: "#16A085", 37 | strokeWidth: 1 38 | }, 39 | source: true, 40 | connector: { 41 | type: BezierConnector.type, 42 | options: { 43 | curviness: 50 44 | } 45 | }, 46 | connectorStyle: connectorPaintStyle, 47 | hoverPaintStyle: endpointHoverStyle, 48 | connectorHoverStyle: connectorHoverStyle, 49 | maxConnections: -1 50 | }; 51 | 52 | export const targetEndpoint: EndpointOptions = { 53 | endpoint: { 54 | type: DotEndpoint.type, 55 | options: { 56 | radius: 6 57 | } 58 | }, 59 | paintStyle: { 60 | stroke: "#7d0fc8", 61 | fill: "#ad35ff", 62 | strokeWidth: 1 63 | }, 64 | hoverPaintStyle: endpointHoverStyle, 65 | maxConnections: -1, 66 | target: true 67 | }; 68 | 69 | export const inputAnchors: AnchorId[] = ["TopLeft", "BottomLeft", "Left"]; 70 | export const outputAnchors: AnchorId[] = ["TopRight", "BottomRight", "Right"]; 71 | -------------------------------------------------------------------------------- /services/frontend/src/utils/position.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import { IServiceNodeItem } from "../types"; 3 | import { getNodeKeyFromConnectionId } from "./index"; 4 | 5 | interface INodeItemWithParent extends IServiceNodeItem { 6 | parent: string; 7 | } 8 | 9 | const nodeWidth = 150; 10 | const nodeHeight = 60; 11 | 12 | export const getHierarchyTree = ( 13 | nodes: IServiceNodeItem[] 14 | ): d3.HierarchyPointNode => { 15 | const data = nodes.map((node): INodeItemWithParent => { 16 | return { 17 | ...node, 18 | parent: node.inputs[0] ? getNodeKeyFromConnectionId(node.inputs[0]) : "" 19 | }; 20 | }); 21 | 22 | const parents = data.filter((x) => !x.parent); 23 | 24 | if (parents.length > 1) { 25 | parents.forEach((x) => { 26 | x.parent = "root_parent"; 27 | }); 28 | data.push({ 29 | key: "root_parent", 30 | parent: "" 31 | } as INodeItemWithParent); 32 | } 33 | 34 | const hierarchy = d3 35 | .stratify() 36 | .id(function (d: INodeItemWithParent) { 37 | return d.key; 38 | }) 39 | .parentId(function (d: INodeItemWithParent) { 40 | return d.parent; 41 | })(data); 42 | 43 | const tree = d3.tree().nodeSize([nodeHeight, nodeWidth])( 44 | hierarchy 45 | ); 46 | 47 | return tree; 48 | }; 49 | 50 | export const getNodesPositions = ( 51 | nodes: IServiceNodeItem[] 52 | ): [IServiceNodeItem[], number, number] => { 53 | const nodeWithPosition: IServiceNodeItem[] = []; 54 | const tree = getHierarchyTree(nodes); 55 | let x0 = Infinity; 56 | let x1 = -x0; 57 | 58 | tree.each((d) => { 59 | if (d.x > x1) x1 = d.x; 60 | if (d.x < x0) x0 = d.x; 61 | }); 62 | 63 | const descendants = tree.descendants(); 64 | 65 | descendants.forEach((x) => { 66 | if (x.data.key !== "root_parent") { 67 | nodeWithPosition.push({ 68 | ...x.data, 69 | position: { left: x.y, top: x.x + nodeHeight } 70 | }); 71 | } 72 | }); 73 | 74 | return [nodeWithPosition, 0, 0]; 75 | }; 76 | -------------------------------------------------------------------------------- /services/frontend/src/utils/styles.tsx: -------------------------------------------------------------------------------- 1 | export const classNames = (...classes: string[]) => { 2 | return classes.filter(Boolean).join(" "); 3 | }; 4 | -------------------------------------------------------------------------------- /services/frontend/src/utils/theme.tsx: -------------------------------------------------------------------------------- 1 | import { createTheme } from "@mui/material"; 2 | 3 | export const lightTheme = createTheme({ 4 | palette: { 5 | primary: { 6 | main: "#4f46e5" 7 | } 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /services/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | darkMode: "class", 3 | content: [ 4 | "./src/**/*.{js,jsx,ts,tsx}", 5 | ], 6 | theme: { 7 | fontFamily: { 8 | display: ['Gilroy', 'sans-serif'], 9 | body: ['Graphik', 'sans-serif'], 10 | }, 11 | extend: { 12 | typography: (theme) => ({ 13 | DEFAULT: { 14 | css: { 15 | pre: { 16 | 'color': '#fff', 17 | 'line-height': '1.4', 18 | 'margin-top': '0', 19 | 'padding': '', 20 | 'padding-top': '0.4rem', 21 | 'padding-right': '1em', 22 | 'padding-bottom': '0.4rem', 23 | 'padding-left': '1em' 24 | }, 25 | code: false, 26 | 'pre code': false, 27 | 'code::before': false, 28 | 'code::after': false, 29 | p: { 30 | 'margin-top': '1em', 31 | 'margin-bottom': '1em' 32 | } 33 | } 34 | } 35 | }) 36 | }, 37 | height: theme => ({ 38 | auto: 'auto', 39 | ...theme('spacing'), 40 | full: '100%', 41 | screen: 'calc(var(--vh) * 100)', 42 | }), 43 | minHeight: theme => ({ 44 | ...theme('spacing'), 45 | full: '100%', 46 | screen: 'calc(var(--vh) * 100)', 47 | }) 48 | }, 49 | plugins: [ 50 | require('@tailwindcss/forms'), 51 | require('@tailwindcss/typography') 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /services/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "./src/**/*" 25 | ], 26 | } 27 | --------------------------------------------------------------------------------