Merge pull request #118 from ctk-hq/feat/k8s

Feat/k8s
master
Artem Golub 2 years ago committed by GitHub
commit a039052621
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,6 +1,6 @@
# Container ToolKit
Visually generate docker compose manifests and deploy apps to AWS ECS (coming soon).
Visually generate docker compose & kubernetes manifests.
![Alt text](https://ctk-public.s3.amazonaws.com/ui.png?raw=true "UI")
@ -27,12 +27,6 @@ make dev_server
... this command will bring up the backend, the database, sync migrations,
## Project roadmap
- Kubernetes manifest generation.
- ECS deployment.
- K8S deployment.
## Docs
- https://docs.jsplumbtoolkit.com/community/

@ -12,7 +12,7 @@ endif
build-image :
docker build -t $(ORGANIZATION)/$(CONTAINER):$(VERSION) .
deploy :
run :
docker run --rm --name $(CONTAINER) \
--env AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
--env AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \

@ -33,9 +33,11 @@ services:
- postgres
volumes:
- ./services/backend/src:/home/server/
- ./services/backend/configs:/home/configs/
- django-static:/static/
ports:
- "9001:9001"
- "9000:9000"
environment:
- DB_REMOTE=False
- APP_URL=
@ -47,15 +49,8 @@ services:
context: ./
dockerfile: ./services/frontend/Dockerfile
image: corpulent/ctk-frontend:1.0.0
depends_on:
- backend
links:
- backend
volumes:
- ./services/frontend/configs/nginx/uwsgi_params:/home/config/uwsgi/uwsgi_params
- ./services/frontend/configs/nginx/localhost.conf:/etc/nginx/conf.d/default.conf
- ./services/frontend/configs/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./services/frontend/configs/nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./services/frontend/build:/usr/share/nginx/html/
- django-static:/home/server/static/
ports:
- "80:80"
- "8080:8080"

@ -13,11 +13,15 @@ RUN apt-get update && \
postgresql-contrib \
wget \
nano \
curl \
lsof \
curl \
supervisor && \
rm -rf /var/lib/apt/lists/*
RUN wget https://github.com/kubernetes/kompose/releases/download/v1.26.1/kompose_1.26.1_amd64.deb
RUN apt install ./kompose_1.26.1_amd64.deb && \
rm kompose_1.26.1_amd64.deb
RUN useradd uwsgi && adduser uwsgi root
RUN useradd supervisor && adduser supervisor root
@ -26,19 +30,12 @@ RUN pip install --upgrade pip && \
pip install -r ./requirements.txt && \
rm ./requirements.txt
RUN touch /var/log/backend_out.log && \
touch /var/log/django.log
RUN chmod g+w -R /var/log/
EXPOSE 9000 9001
COPY ./services/backend/src ./server
COPY ./services/backend/configs/supervisor/api.conf /etc/supervisor/conf.d/api.conf
COPY ./services/backend/configs/uwsgi ./config/uwsgi
COPY ./services/backend/configs/uwsgi ./configs/uwsgi
EXPOSE 9000 9001
RUN rm -rf /tmp/uwsgi && \
mkdir -p /tmp/uwsgi && \
ln -s ./config/uwsgi/uwsgi.ini /tmp/uwsgi/
HEALTHCHECK CMD curl --fail http://localhost:9000/v1 || exit 1
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"]
CMD ["/usr/local/bin/uwsgi", "--ini", "/home/configs/uwsgi/uwsgi.ini"]

@ -3,6 +3,6 @@ nodaemon=true
[program:app]
priority=1
user = uwsgi
command = /usr/local/bin/uwsgi --ini /tmp/uwsgi/uwsgi.ini
user=uwsgi
command=/usr/local/bin/uwsgi --ini /home/config/uwsgi/uwsgi.ini
autorestart=false

@ -1,6 +1,10 @@
[uwsgi]
ini = :base
socket = 0.0.0.0:9000
# use socket option a third-party router (nginx),
# use http option to set uwsgi to accept incoming
# HTTP requests and route them by itself
http = 0.0.0.0:9000
master = true
processes = 5
@ -8,7 +12,7 @@ processes = 5
[base]
chdir = /home/server
module = server.wsgi:application
module = main.wsgi:application
chmod-socket=666
uid = uwsgi
gid = uwsgi

@ -1,6 +1,5 @@
django==4.0.4
django-cors-headers==3.11.0
django-axes==5.32.0
djangorestframework==3.13.1
djangorestframework-simplejwt==5.1.0
django-storages==1.13.1

@ -17,7 +17,9 @@ api_urls = [
path("projects/", project.ProjectListCreateAPIView.as_view()),
path("projects/import/", project.ProjectImportAPIView.as_view()),
path("projects/<str:uuid>/", project.ProjectGenericAPIView.as_view()),
path("generate/", generate.GenerateGenericAPIView.as_view()),
path("generate/", generate.GenerateDockerComposeView.as_view()),
path("generate/docker-compose", generate.GenerateDockerComposeView.as_view()),
path("generate/kubernetes", generate.GenerateK8sView.as_view()),
path("auth/self/", user.UserGenericAPIView.as_view()),
path("auth/", include("dj_rest_auth.urls")),
path("auth/github/", auth.GitHubLogin.as_view(), name="github_login"),

@ -1,11 +1,32 @@
import contextlib
import re
import io
import shutil
import json
import subprocess as sp
from ruamel.yaml import YAML
from pathlib import Path
from rest_framework import generics, status
from rest_framework.response import Response
from .utils import (
generate, clean_dict, get_random_string, read_dir)
from .utils import generate
def generate_docker_compose(data):
version = data.get('version', '3')
services = data.get('services', None)
volumes = data.get('volumes', None)
networks = data.get('networks', None)
return generate(
services,
volumes,
networks,
version=version,
return_format='yaml')
class GenerateGenericAPIView(generics.GenericAPIView):
class GenerateDockerComposeView(generics.GenericAPIView):
permission_classes = []
def get(self, request):
@ -13,18 +34,71 @@ class GenerateGenericAPIView(generics.GenericAPIView):
def post(self, request, format=None):
request_data = json.loads(request.data)
version = request_data['data'].get('version', '3')
services = request_data['data'].get('services', None)
volumes = request_data['data'].get('volumes', None)
networks = request_data['data'].get('networks', None)
code = generate(
services,
volumes,
networks,
version=version,
return_format='yaml')
code = generate_docker_compose(request_data["data"])
resp = {'code': code}
return Response(resp, status=status.HTTP_200_OK)
class GenerateK8sView(generics.GenericAPIView):
permission_classes = []
def get(self, request):
return Response({}, status=status.HTTP_404_NOT_FOUND)
def post(self, request, format=None):
resp = {
'code': "",
'error': ""
}
workdir = f"/tmp/{get_random_string(8)}"
request_data = json.loads(request.data)
omitted = clean_dict(
request_data["data"],
["env_file", "build", "secrets", "profiles"])
docker_compose_code = generate_docker_compose(omitted)
path = Path(workdir)
path.mkdir(exist_ok=True)
with open(f"{path}/docker-compose.yaml", 'w') as f:
f.write(docker_compose_code)
process = sp.Popen([
"kompose",
"--suppress-warnings",
"--file",
f"{path}/docker-compose.yaml", "convert"
], cwd=workdir, stdout=sp.PIPE, stderr=sp.PIPE)
process.wait()
_, out = process.communicate()
if out:
out = out.decode("utf-8")
parts = out.split(" ")
parts.pop()
parts.pop(0)
final_list = [re.sub(r'\[.*?;.*?m', '', x) for x in parts if any(x)]
resp["error"] = " ".join(final_list)
workdir_files = read_dir(workdir)
workdir_files.remove("docker-compose.yaml")
for file in workdir_files:
with open(f"{workdir}/{file}") as f:
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
yaml.explicit_start = True
data = yaml.load(f)
with contextlib.suppress(KeyError):
del data["metadata"]["annotations"]
with contextlib.suppress(KeyError):
del data["spec"]["template"]["metadata"]["annotations"]
buf = io.BytesIO()
yaml.dump(data, buf)
resp["code"] += buf.getvalue().decode()
if file != workdir_files[-1]:
resp["code"] += "\n"
shutil.rmtree(workdir)
return Response(resp, status=status.HTTP_200_OK)

@ -1,5 +1,8 @@
import io
import os
import contextlib
import random
import string
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import DoubleQuotedScalarString
@ -58,11 +61,11 @@ def sequence_indent_one(s):
return ret_val
def get_version(verion):
def get_version(version):
try:
return int(verion)
return int(version)
except ValueError:
return float(verion)
return float(version)
def generate(services, volumes, networks, version="3", return_format='yaml'):
if return_format != 'yaml':
@ -95,8 +98,23 @@ def generate(services, volumes, networks, version="3", return_format='yaml'):
if volumes:
ret_yaml.dump({'volumes': volumes}, s)
s.write('\n')
s.seek(0)
return s.read()
def clean_dict(dic, omit=None):
if type(dic) is dict:
for key, item in dic.copy().items():
if omit and key in omit:
del dic[key]
elif type(item) is dict:
dic[key] = clean_dict(item, omit)
return dic
def get_random_string(length):
letters = string.ascii_lowercase
return ''.join(random.choice(letters) for _ in range(length))
return s
def read_dir(path):
return [f for f in os.listdir(path) if os.path.isfile(f"{path}/{f}")]

@ -6,4 +6,4 @@ class ViewGenericAPIView(generics.GenericAPIView):
permission_classes = []
def get(self, request):
return Response({}, status=status.HTTP_404_NOT_FOUND)
return Response({}, status=status.HTTP_200_OK)

@ -56,7 +56,6 @@ INSTALLED_APPS = [
"dj_rest_auth.registration",
"storages",
"corsheaders",
"axes",
"organizations",
"api",
]
@ -69,8 +68,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"axes.middleware.AxesMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware"
]
ROOT_URLCONF = "main.urls"
@ -162,8 +160,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
"axes.backends.AxesBackend",
"allauth.account.auth_backends.AuthenticationBackend"
]
REST_FRAMEWORK = {

@ -7,6 +7,6 @@ RUN npm run build
FROM nginx:stable-alpine
COPY --from=build /build/build /usr/share/nginx/html
COPY --from=build /build/configs/nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
COPY --from=build /build/configs/nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

@ -0,0 +1,20 @@
# vi:syntax=nginx
server {
listen 8080;
listen [::]:8080;
server_name localhost;
charset utf-8;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

@ -1,38 +0,0 @@
# vi:syntax=nginx
upstream django {
server backend:9000;
}
server {
listen 80;
listen [::]:80;
server_name localhost;
charset utf-8;
location /static {
alias /home/server/static;
}
location /admin {
uwsgi_pass django;
include /home/config/uwsgi/uwsgi_params;
}
location /api {
uwsgi_pass django;
include /home/config/uwsgi/uwsgi_params;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

@ -1,43 +0,0 @@
worker_processes 4;
pid /run/nginx.pid;
events {
worker_connections 768;
# multi_accept on;
}
http {
# Basic Settings
tcp_nopush on;
tcp_nodelay on;
#keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log ;
sendfile on;
#tcp_nopush on;
client_max_body_size 20M;
keepalive_timeout 0;
uwsgi_read_timeout 86400;
uwsgi_send_timeout 86400;
# Gzip Settings
gzip on;
gzip_disable "msie6";
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Virtual Host Configs
include /etc/nginx/conf.d/*.conf;
#include /etc/nginx/sites-enabled/*;
}

@ -1,15 +0,0 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

@ -80,7 +80,7 @@
"@types/d3": "^7.1.0",
"@types/jest": "^27.4.0",
"@types/lodash": "^4.14.178",
"@types/node": "^16.11.22",
"@types/node": "^16.11.56",
"@types/react": "^17.0.45",
"@types/react-dom": "^17.0.11",
"@types/uuid": "^8.3.4",

@ -0,0 +1,155 @@
import { useEffect, useMemo, useRef, useState } from "react";
import YAML from "yaml";
import { debounce } from "lodash";
import { manifestTypes } from "../../constants";
import { generatePayload } from "../../utils/generators";
import { checkHttpStatus } from "../../services/helpers";
import { generateHttp } from "../../services/generate";
import { toaster } from "../../utils";
import eventBus from "../../events/eventBus";
import ManifestSelect from "./ManifestSelect";
import CodeEditor from "../CodeEditor";
import useWindowDimensions from "../../hooks/useWindowDimensions";
const CodeBox = () => {
const versionRef = useRef<string>();
const manifestRef = useRef<string>();
const [language, setLanguage] = useState("yaml");
const [version, setVersion] = useState("3");
const [copyText, setCopyText] = useState("Copy");
const [generatedCode, setGeneratedCode] = useState<string>("");
const [formattedCode, setFormattedCode] = useState<string>("");
const [manifest, setManifest] = useState(manifestTypes.DOCKER_COMPOSE);
const { height } = useWindowDimensions();
versionRef.current = version;
manifestRef.current = manifest;
const getCode = (payload: any, manifest: string) => {
generateHttp(JSON.stringify(payload), manifest)
.then(checkHttpStatus)
.then((data) => {
if (data["code"]) {
setGeneratedCode(data["code"]);
} else {
setGeneratedCode("");
}
if (data["error"]) {
setGeneratedCode("");
toaster(`error ${data["error"]}`, "error");
}
});
};
const debouncedOnGraphUpdate = useMemo(
() =>
debounce((payload, manifest) => {
getCode(payload, manifest);
}, 600),
[]
);
const versionChange = (e: any) => {
setVersion(e.target.value);
};
const copy = () => {
navigator.clipboard.writeText(formattedCode);
setCopyText("Copied");
setTimeout(() => {
setCopyText("Copy");
}, 300);
};
useEffect(() => {
if (language === "json") {
setFormattedCode(
JSON.stringify(YAML.parseAllDocuments(generatedCode), null, 2)
);
}
if (language === "yaml") {
setFormattedCode(generatedCode);
}
}, [language, generatedCode]);
useEffect(() => {
eventBus.dispatch("GENERATE", {
message: {
id: ""
}
});
}, [version, manifest]);
useEffect(() => {
eventBus.on("FETCH_CODE", (data) => {
const graphData = data.detail.message;
graphData.version = versionRef.current;
debouncedOnGraphUpdate(generatePayload(graphData), manifestRef.current);
});
return () => {
eventBus.remove("FETCH_CODE", () => undefined);
};
}, []);
return (
<>
<div
className={`absolute top-0 left-0 right-0 z-10 flex justify-end p-1 space-x-2 group-hover:visible invisible`}
>
<select
id="version"
onChange={versionChange}
value={version}
className="input-util w-min pr-8"
>
<option value="1">v 1</option>
<option value="2">v 2</option>
<option value="3">v 3</option>
</select>
<button
className={`btn-util ${
language === "json" ? `btn-util-selected` : ``
}`}
onClick={() => setLanguage("json")}
>
json
</button>
<button
className={`btn-util ${
language === "yaml" ? `btn-util-selected` : ``
}`}
onClick={() => setLanguage("yaml")}
>
yaml
</button>
<button className="btn-util" type="button" onClick={copy}>
{copyText}
</button>
</div>
<div
className={`absolute top-10 left-0 right-0 z-10 flex justify-end p-1 space-x-2 group-hover:visible invisible`}
>
<ManifestSelect setManifest={setManifest} />
</div>
<CodeEditor
data={formattedCode}
language={language}
onChange={() => {
return;
}}
disabled={true}
lineWrapping={false}
height={height - 64}
/>
</>
);
};
export default CodeBox;

@ -0,0 +1,113 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CallbackFunction, IProject } from "../../types";
import Spinner from "../global/Spinner";
import VisibilitySwitch from "../global/VisibilitySwitch";
interface IHeaderProps {
onSave: CallbackFunction;
isLoading: boolean;
projectData: IProject;
isAuthenticated: boolean;
}
const Header = (props: IHeaderProps) => {
const { onSave, isLoading, projectData, isAuthenticated } = props;
const [visibility, setVisibility] = useState(false);
const [projectName, setProjectName] = useState("Untitled");
const visibilityRef = useRef(false);
const projectNameRef = useRef("Untitled");
const handleNameChange = useCallback((e: any) => {
setProjectName(e.target.value);
projectNameRef.current = e.target.value;
}, []);
const handleSave = useCallback(() => {
const data: any = {
name: projectNameRef.current,
visibility: +visibilityRef.current
};
onSave(data);
}, []);
useEffect(() => {
if (!projectData) {
return;
}
setProjectName(projectData.name);
setVisibility(Boolean(projectData.visibility));
visibilityRef.current = Boolean(projectData.visibility);
projectNameRef.current = projectData.name;
}, [projectData]);
return (
<>
<div className="px-4 py-3 border-b border-gray-200">
<form
className="flex flex-col space-y-2 md:space-y-0 md:flex-row md:justify-between items-center"
autoComplete="off"
>
<input
className={`
bg-gray-100
appearance-none
w-full
md:w-1/2
lg:w-1/3
block
text-gray-700
border
border-gray-100
dark:bg-gray-900
dark:text-white
dark:border-gray-900
rounded
py-2
px-3
leading-tight
focus:outline-none
focus:border-indigo-400
focus:ring-0
`}
type="text"
placeholder="Project name"
autoComplete="off"
id="name"
name="name"
onChange={handleNameChange}
value={projectName}
/>
<div className="flex flex-col space-y-2 w-full justify-end mb-4 md:flex-row md:space-y-0 md:space-x-2 md:mb-0">
{isAuthenticated && (
<VisibilitySwitch
isVisible={visibility}
onToggle={() => {
setVisibility(!visibility);
visibilityRef.current = !visibility;
}}
/>
)}
<button
onClick={() => handleSave()}
type="button"
className="btn-util text-white bg-green-600 hover:bg-green-700 sm:w-auto"
>
<div className="flex justify-center items-center space-x-2 mx-auto">
{isLoading && <Spinner className="w-4 h-4 text-green-300" />}
<span>Save</span>
</div>
</button>
</div>
</form>
</div>
</>
);
};
export default Header;

@ -0,0 +1,61 @@
import { styled } from "@mui/material";
import { useCallback, useState } from "react";
import { manifestTypes } from "../../constants";
import DcLogo from "../global/dc-logo";
import K8sLogo from "../global/k8s-logo";
interface IButtonProps {
selected: boolean;
}
const Button = styled("button", {
shouldForwardProp: (name) => name !== "selected"
})<IButtonProps>`
filter: grayscale(${({ selected }) => (selected ? "0%" : "100%")});
opacity: ${({ selected }) => (selected ? "100%" : "80%")};
&:hover {
filter: grayscale(0%);
}
`;
interface IManifestSelectProps {
setManifest: any;
}
const ManifestSelect = (props: IManifestSelectProps) => {
const { setManifest } = props;
const [selected, setSelected] = useState(manifestTypes.DOCKER_COMPOSE);
const handleK8s = useCallback(() => {
setManifest(manifestTypes.KUBERNETES);
setSelected(manifestTypes.KUBERNETES);
}, []);
const handleDC = useCallback(() => {
setManifest(manifestTypes.DOCKER_COMPOSE);
setSelected(manifestTypes.DOCKER_COMPOSE);
}, []);
return (
<>
<Button
selected={selected === manifestTypes.KUBERNETES}
type="button"
onClick={handleK8s}
>
<K8sLogo />
</Button>
<Button
selected={selected === manifestTypes.DOCKER_COMPOSE}
type="button"
onClick={handleDC}
>
<DcLogo />
</Button>
</>
);
};
export default ManifestSelect;

@ -1,25 +1,21 @@
import { useEffect, useState, useRef, useMemo } from "react";
import { useEffect, useState, useRef } from "react";
import { useParams } from "react-router-dom";
import { debounce, Dictionary, omit } from "lodash";
import YAML from "yaml";
import { Dictionary, omit, remove } from "lodash";
import { GlobeAltIcon, CubeIcon, FolderAddIcon } from "@heroicons/react/solid";
import {
IProjectPayload,
IServiceNodeItem,
IVolumeNodeItem,
IServiceNodePosition,
IProject,
IEditServiceFormDependsOn
IProjectPayload
} from "../../types";
import eventBus from "../../events/eventBus";
import { useMutation } from "react-query";
import {
createProject,
useProject,
useUpdateProject,
createProject
useUpdateProject
} from "../../hooks/useProject";
import useWindowDimensions from "../../hooks/useWindowDimensions";
import { generatePayload } from "../../utils/generators";
import { nodeLibraries } from "../../utils/data/libraries";
import {
getClientNodeItem,
@ -28,8 +24,6 @@ import {
getClientNodesAndConnections,
getMatchingSetIndex
} from "../../utils";
import { checkHttpStatus } from "../../services/helpers";
import { generateHttp } from "../../services/generate";
import { Canvas } from "../Canvas";
import Spinner from "../global/Spinner";
import ModalConfirmDelete from "../modals/ConfirmDelete";
@ -38,10 +32,10 @@ import ModalServiceEdit from "../modals/docker-compose/service/Edit";
import ModalNetwork from "../modals/docker-compose/network";
import CreateVolumeModal from "../modals/docker-compose/volume/CreateVolumeModal";
import EditVolumeModal from "../modals/docker-compose/volume/EditVolumeModal";
import CodeEditor from "../CodeEditor";
import { useTitle } from "../../hooks";
import VisibilitySwitch from "../global/VisibilitySwitch";
import _ from "lodash";
import CodeBox from "./CodeBox";
import Header from "./Header";
import { useMutation } from "react-query";
interface IProjectProps {
isAuthenticated: boolean;
@ -56,10 +50,8 @@ export default function Project(props: IProjectProps) {
useRef<Dictionary<IServiceNodeItem | IVolumeNodeItem>>();
const stateConnectionsRef = useRef<[[string, string]] | []>();
const stateNetworksRef = useRef({});
const stateProjectRef = useRef();
const [isVisible, setIsVisible] = useState(false);
const [generatedCode, setGeneratedCode] = useState<string>();
const [formattedCode, setFormattedCode] = useState<string>("");
const [showModalCreateService, setShowModalCreateService] = useState(false);
const [showVolumesModal, setShowVolumesModal] = useState(false);
const [showNetworksModal, setShowNetworksModal] = useState(false);
@ -74,14 +66,9 @@ export default function Project(props: IProjectProps) {
const [volumeToDelete, setVolumeToDelete] = useState<IVolumeNodeItem | null>(
null
);
const [language, setLanguage] = useState("yaml");
const [version, setVersion] = useState("3");
const [copyText, setCopyText] = useState("Copy");
const [nodes, setNodes] = useState<Record<string, any>>({});
const [connections, setConnections] = useState<[[string, string]] | []>([]);
const [networks, setNetworks] = useState<Record<string, any>>({});
const [projectName, setProjectName] = useState("Untitled");
const [canvasPosition, setCanvasPosition] = useState({
top: 0,
left: 0,
@ -109,10 +96,7 @@ export default function Project(props: IProjectProps) {
stateNodesRef.current = nodes;
stateConnectionsRef.current = connections;
stateNetworksRef.current = networks;
const handleNameChange = (e: any) => {
setProjectName(e.target.value);
};
stateProjectRef.current = data;
const onNodeUpdate = (positionData: IServiceNodePosition) => {
if (stateNodesRef.current) {
@ -124,36 +108,6 @@ export default function Project(props: IProjectProps) {
}
};
const onSave = () => {
const payload: IProjectPayload = {
name: projectName,
visibility: +isVisible,
data: {
canvas: {
position: canvasPosition,
nodes: nodes,
connections: connections,
networks: networks
}
}
};
if (uuid) {
updateProjectMutation.mutate(payload);
} else {
createProjectMutation.mutate(payload);
}
};
const copy = () => {
navigator.clipboard.writeText(formattedCode);
setCopyText("Copied");
setTimeout(() => {
setCopyText("Copy");
}, 300);
};
useEffect(() => {
if (!data) {
return;
@ -167,42 +121,41 @@ export default function Project(props: IProjectProps) {
nodesAsList,
nodeLibraries
);
setProjectName(data.name);
setIsVisible(Boolean(data.visibility));
setNodes(clientNodeItems);
setConnections(canvasData.canvas.connections);
setNetworks(canvasData.canvas.networks);
setCanvasPosition(canvasData.canvas.position);
}, [data]);
const debouncedOnGraphUpdate = useMemo(
() =>
debounce((payload) => {
generateHttp(JSON.stringify(payload))
.then(checkHttpStatus)
.then((data) => {
if (data["code"].length) {
for (let i = 0; i < data["code"].length; ++i) {
data["code"][i] = data["code"][i].replace(/(\r\n|\n|\r)/gm, "");
}
const onSave = (partial: any) => {
const base: IProjectPayload = {
name: data?.name ?? "",
visibility: data?.visibility ?? 0,
data: {
canvas: {
position: canvasPosition,
nodes: stateNodesRef.current,
connections: stateConnectionsRef.current,
networks: stateNetworksRef.current
}
}
};
const code = data["code"].join("\n");
setGeneratedCode(code);
}
})
.catch(() => undefined)
.finally(() => undefined);
}, 600),
[]
);
const payload = { ...base, ...partial };
if (uuid) {
updateProjectMutation.mutate(payload);
} else {
createProjectMutation.mutate(payload);
}
};
const onGraphUpdate = (graphData: any) => {
const data = { ...graphData };
data.version = version;
data.networks = stateNetworksRef.current;
const payload = generatePayload(data);
debouncedOnGraphUpdate(payload);
eventBus.dispatch("FETCH_CODE", {
message: data
});
};
const onCanvasUpdate = (updatedCanvasPosition: any) => {
@ -325,7 +278,7 @@ export default function Project(props: IProjectProps) {
dependsOnKeys.forEach((key: string) => {
if (key === targetServiceName) {
if (Array.isArray(sourceDependsOn)) {
_.remove(sourceDependsOn, (key) => key === targetServiceName);
remove(sourceDependsOn, (key) => key === targetServiceName);
}
if (sourceDependsOn && sourceDependsOn.constructor === Object) {
@ -404,32 +357,6 @@ export default function Project(props: IProjectProps) {
eventBus.dispatch("NODE_DELETED", { message: { node: node } });
};
const versionChange = (e: any) => {
setVersion(e.target.value);
};
useEffect(() => {
if (!generatedCode) {
return;
}
if (language === "json") {
setFormattedCode(JSON.stringify(YAML.parse(generatedCode), null, 2));
}
if (language === "yaml") {
setFormattedCode(generatedCode);
}
}, [language, generatedCode]);
useEffect(() => {
eventBus.dispatch("GENERATE", {
message: {
id: ""
}
});
}, [version]);
if (!isFetching) {
if (!error) {
return (
@ -495,70 +422,15 @@ export default function Project(props: IProjectProps) {
) : null}
<div className="md:pl-16 flex flex-col flex-1">
<div className="px-4 py-3 border-b border-gray-200">
<form
className="flex flex-col space-y-2 md:space-y-0 md:flex-row md:justify-between items-center"
autoComplete="off"
>
<input
className={`
bg-gray-100
appearance-none
w-full
md:w-1/2
lg:w-1/3
block
text-gray-700
border
border-gray-100
dark:bg-gray-900
dark:text-white
dark:border-gray-900
rounded
py-2
px-3
leading-tight
focus:outline-none
focus:border-indigo-400
focus:ring-0
`}
type="text"
placeholder="Project name"
autoComplete="off"
id="name"
name="name"
onChange={handleNameChange}
value={projectName}
/>
<div className="flex flex-col space-y-2 w-full justify-end mb-4 md:flex-row md:space-y-0 md:space-x-2 md:mb-0">
{isAuthenticated && (
<VisibilitySwitch
isVisible={isVisible}
onToggle={() => {
setIsVisible(!isVisible);
}}
/>
)}
<button
onClick={() => onSave()}
type="button"
className="btn-util text-white bg-green-600 hover:bg-green-700 sm:w-auto"
>
<div className="flex justify-center items-center space-x-2 mx-auto">
{updateProjectMutation.isLoading && (
<Spinner className="w-4 h-4 text-green-300" />
)}
{createProjectMutation.isLoading && (
<Spinner className="w-4 h-4 text-green-300" />
)}
<span>Save</span>
</div>
</button>
</div>
</form>
</div>
<Header
onSave={onSave}
isLoading={
updateProjectMutation.isLoading ||
createProjectMutation.isLoading
}
projectData={data}
isAuthenticated={isAuthenticated}
/>
<div className="flex flex-grow relative">
<div
@ -631,51 +503,7 @@ export default function Project(props: IProjectProps) {
</div>
<div className="group code-column w-1/2 md:w-1/3 absolute top-0 right-0 sm:relative z-40 md:z-30">
<div
className={`absolute top-0 left-0 right-0 z-10 flex justify-end p-1 space-x-2 group-hover:visible invisible`}
>
<select
id="version"
onChange={versionChange}
value={version}
className="input-util w-min pr-8"
>
<option value="1">v 1</option>
<option value="2">v 2</option>
<option value="3">v 3</option>
</select>
<button
className={`btn-util ${
language === "json" ? `btn-util-selected` : ``
}`}
onClick={() => setLanguage("json")}
>
json
</button>
<button
className={`btn-util ${
language === "yaml" ? `btn-util-selected` : ``
}`}
onClick={() => setLanguage("yaml")}
>
yaml
</button>
<button className="btn-util" type="button" onClick={copy}>
{copyText}
</button>
</div>
<CodeEditor
data={formattedCode}
language={language}
onChange={() => {
return;
}}
disabled={true}
lineWrapping={false}
height={height - 64}
/>
<CodeBox />
</div>
</div>
</div>

@ -0,0 +1,29 @@
const DcLogo = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_213_31)">
<path
d="M512 1024C794.77 1024 1024 794.77 1024 512C1024 229.23 794.77 0 512 0C229.23 0 0 229.23 0 512C0 794.77 229.23 1024 512 1024Z"
fill="#0091E2"
/>
<path
d="M827.3 461.5C825.7 460.2 811.2 449.3 780.6 449.3C772.5 449.3 764.4 449.9 756.4 451.4C750.5 410.7 716.9 390.9 715.4 390L707.2 385.2L701.8 393C695 403.5 690.1 415 687.2 427.2C681.7 450.4 685 472.2 696.8 490.8C682.6 498.7 659.7 500.7 655.1 500.8H277C267.1 500.8 259.1 508.8 259.1 518.7C258.7 551.8 264.3 584.7 275.6 615.8C288.6 650 308 675.1 333.2 690.5C361.4 707.8 407.3 717.7 459.4 717.7C482.9 717.8 506.4 715.6 529.5 711.3C561.6 705.4 592.5 694.2 620.9 678.1C644.3 664.5 665.4 647.3 683.3 627C713.2 593.1 731.1 555.3 744.4 521.8H749.7C782.5 521.8 802.7 508.7 813.8 497.7C821.2 490.7 827 482.2 830.7 472.7L833 465.8L827.3 461.5V461.5ZM312 489.9H362.7C365.1 489.9 367.1 487.9 367.1 485.5V440.4C367.1 438 365.1 436 362.7 435.9H312C309.6 435.9 307.6 437.9 307.6 440.3V485.5C307.6 488 309.6 489.9 312 489.9M381.9 489.9H432.6C435 489.9 437 487.9 437 485.5V440.4C437 438 435 436 432.6 435.9H381.9C379.4 435.9 377.4 437.9 377.4 440.4V485.5C377.4 488 379.4 489.9 381.9 489.9ZM452.7 490H503.4C505.8 490 507.8 488 507.8 485.6V440.5C507.8 438.1 505.8 436.1 503.4 436H452.7C450.3 436 448.3 438 448.3 440.4V485.6C448.3 488 450.3 489.9 452.7 490M522.8 490H573.5C575.9 490 577.9 488 578 485.6V440.5C578 438 576 436 573.5 436H522.8C520.4 436 518.4 438 518.4 440.4V485.6C518.4 488 520.3 490 522.8 490M381.8 425H432.5C434.9 425 436.9 423 436.9 420.5V375.4C436.9 373 434.9 371 432.5 371H381.8C379.3 371 377.4 373 377.3 375.4V420.5C377.4 423 379.4 425 381.8 425ZM452.7 425H503.4C505.8 425 507.8 423 507.8 420.5V375.4C507.8 373 505.8 371 503.4 371H452.7C450.3 371 448.3 373 448.3 375.4V420.5C448.3 423 450.3 425 452.7 425ZM522.8 425H573.5C576 425 577.9 423 578 420.5V375.4C578 372.9 576 371 573.5 371H522.8C520.4 371 518.4 373 518.4 375.4V420.5C518.4 423 520.3 425 522.8 425ZM522.8 360.1H573.5C576 360.1 578 358.1 578 355.6V310.4C578 308 576 306 573.5 306H522.8C520.4 306 518.4 308 518.4 310.4V355.6C518.4 358.1 520.3 360.1 522.8 360.1ZM593.4 490H644.1C646.5 490 648.5 488 648.5 485.6V440.5C648.5 438 646.5 436.1 644.1 436H593.4C591 436 589 438 589 440.4V485.6C589 488 591 490 593.4 490"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_213_31">
<rect width="1024" height="1024" fill="white" />
</clipPath>
</defs>
</svg>
);
};
export default DcLogo;

File diff suppressed because one or more lines are too long

@ -433,13 +433,13 @@ export const useJsPlumb = (
useEffect(() => {
eventBus.on("GENERATE", () => {
if (!instance) return;
if (!instanceRef.current) return;
if (stateRef.current) {
onGraphUpdate({
nodes: stateRef.current,
connections: getConnections(
instance.getConnections({}, true) as Connection[]
instanceRef.current.getConnections({}, true) as Connection[]
)
});
}

@ -4,3 +4,7 @@ export const REACT_APP_GITHUB_CLIENT_ID =
export const REACT_APP_GITHUB_SCOPE = process.env.REACT_APP_GITHUB_SCOPE;
export const PROJECTS_FETCH_LIMIT = 300;
export const LOCAL_STORAGE = "CtkLocalStorage";
export const manifestTypes = {
DOCKER_COMPOSE: "DOCKER_COMPOSE",
KUBERNETES: "KUBERNETES"
};

@ -7,7 +7,7 @@ const eventBus = {
},
dispatch(
event: string,
data: { message: { id: string } | { id: string } | { node: any } }
data: { message: { id: string } | { data: any } | { node: any } }
) {
document.dispatchEvent(new CustomEvent(event, { detail: data }));
},

@ -137,6 +137,7 @@ export const useUpdateProject = (uuid: string | undefined) => {
},
{
onSuccess: (projectData) => {
toaster("Project saved!", "success");
queryClient.setQueryData(["projects", uuid], projectData);
}
}
@ -157,9 +158,9 @@ export const useDeleteProject = (uuid: string | undefined) => {
return data;
} catch (err: any) {
if (err.response.status === 404) {
// console.error("Resource could not be found!");
toaster("Resource could not be found!", "error");
} else {
// console.error(err.message);
toaster(err.message, "error");
}
}
},
@ -180,6 +181,7 @@ export const useDeleteProject = (uuid: string | undefined) => {
} else {
queryClient.invalidateQueries(["projects"]);
}
toaster("Project deleted!", "success");
}
}
);

@ -1,7 +1,17 @@
import { manifestTypes } from "../constants";
import { API_SERVER_URL } from "../constants";
export const generateHttp = (data: string) => {
return fetch(`${API_SERVER_URL}/generate/`, {
export const generateHttp = (data: string, manifest: string) => {
let endpoint = `${API_SERVER_URL}/generate/`;
if (manifest === manifestTypes.DOCKER_COMPOSE) {
endpoint += "docker-compose";
}
if (manifest === manifestTypes.KUBERNETES) {
endpoint += "kubernetes";
}
return fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json"

@ -21,6 +21,7 @@ export interface IServiceNodePosition {
export interface IProject {
id: number;
name: string;
visibility: number;
uuid: string;
data: string;
created_at: string;

Loading…
Cancel
Save