diff --git a/.gitignore b/.gitignore index 318af05..cc16153 100644 --- a/.gitignore +++ b/.gitignore @@ -109,9 +109,10 @@ app/drucker_pb2_grpc.py # sqlite app/db.sqlite3 +app/db.test.sqlite3 # DB migration -app/migration/versions/ +app/migrations/ # Kube config app/kube-config/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e6907cd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Change Log + +## [v0.3.2](https://github.com/drucker/drucker-dashboard/tree/v0.3.2) (2018-08-22) +[Full Changelog](https://github.com/drucker/drucker-dashboard/compare/v0.3.1...v0.3.2) + +**Merged pull requests:** + +- Fix MultipleResultsFound error [\#15](https://github.com/drucker/drucker-dashboard/pull/15) ([keigohtr](https://github.com/keigohtr)) +- \[Hotfix\] Add `JWT\_TOKEN\_KEY` when fetch `rawMultiRequest` [\#13](https://github.com/drucker/drucker-dashboard/pull/13) ([keigohtr](https://github.com/keigohtr)) +- fix invalid datetime [\#12](https://github.com/drucker/drucker-dashboard/pull/12) ([yuki-mt](https://github.com/yuki-mt)) +- Add Service/Model list view [\#11](https://github.com/drucker/drucker-dashboard/pull/11) ([keigohtr](https://github.com/keigohtr)) + +## [v0.3.1](https://github.com/drucker/drucker-dashboard/tree/v0.3.1) (2018-08-15) +[Full Changelog](https://github.com/drucker/drucker-dashboard/compare/v0.3.0...v0.3.1) + +**Merged pull requests:** + +- Add a function that allows you to select a model when booting a new service [\#10](https://github.com/drucker/drucker-dashboard/pull/10) ([keigohtr](https://github.com/keigohtr)) +- Add convert function and fix wrong cpu values [\#9](https://github.com/drucker/drucker-dashboard/pull/9) ([jkw552403](https://github.com/jkw552403)) +- \[Hotfix\] Change code generator [\#8](https://github.com/drucker/drucker-dashboard/pull/8) ([keigohtr](https://github.com/keigohtr)) +- Add `progress\_deadline\_seconds` option [\#6](https://github.com/drucker/drucker-dashboard/pull/6) ([keigohtr](https://github.com/keigohtr)) + +## [v0.3.0](https://github.com/drucker/drucker-dashboard/tree/v0.3.0) (2018-08-08) +[Full Changelog](https://github.com/drucker/drucker-dashboard/compare/v0.2.0...v0.3.0) + +**Merged pull requests:** + +- Add version info [\#5](https://github.com/drucker/drucker-dashboard/pull/5) ([keigohtr](https://github.com/keigohtr)) +- Add `commit\_message` to be a rolling-update trigger [\#4](https://github.com/drucker/drucker-dashboard/pull/4) ([keigohtr](https://github.com/keigohtr)) +- Add favicon [\#3](https://github.com/drucker/drucker-dashboard/pull/3) ([keigohtr](https://github.com/keigohtr)) +- Change text [\#2](https://github.com/drucker/drucker-dashboard/pull/2) ([keigohtr](https://github.com/keigohtr)) +- Add LDAP authentication [\#1](https://github.com/drucker/drucker-dashboard/pull/1) ([sugyan](https://github.com/sugyan)) + +## [v0.2.0](https://github.com/drucker/drucker-dashboard/tree/v0.2.0) (2018-07-25) + + +\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 0a4027a..e69de29 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,8 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -import os -import sys - -sd = os.path.abspath(os.path.dirname(__file__)) -sys.path.append(sd) diff --git a/app/apis/__init__.py b/app/apis/__init__.py index 0752e9d..fbc088c 100644 --- a/app/apis/__init__.py +++ b/app/apis/__init__.py @@ -1,12 +1,12 @@ import traceback -from flask_restplus import Api, Resource +from flask_restplus import Api +from app import logger from apis.api_kubernetes import kube_info_namespace from apis.api_application import app_info_namespace from apis.api_service import srv_info_namespace from apis.api_model import mdl_info_namespace -from apis.common import logger +from apis.api_misc import misc_info_namespace from auth import Auth -from utils.env_loader import config from kubernetes.client.rest import ApiException from models import db @@ -19,15 +19,6 @@ ) -@api.route('/settings') -class Settings(Resource): - def get(self): - result = { - 'auth': 'auth' in config - } - return result - - @api.errorhandler(ApiException) def api_exception_handler(error): logger.error(str(error)) @@ -49,3 +40,4 @@ def default_error_handler(error): api.add_namespace(app_info_namespace, path='/api/applications') api.add_namespace(srv_info_namespace, path='/api/applications') api.add_namespace(mdl_info_namespace, path='/api/applications') +api.add_namespace(misc_info_namespace, path='/api') diff --git a/app/apis/api_application.py b/app/apis/api_application.py index e80425e..ce9aad1 100644 --- a/app/apis/api_application.py +++ b/app/apis/api_application.py @@ -4,12 +4,12 @@ from flask_restplus import Namespace, fields, Resource, reqparse +from app import logger from models import db from models.application import Application from models.service import Service - from core.drucker_dashboard_client import DruckerDashboardClient -from apis.common import logger, DatetimeToTimestamp +from apis.common import DatetimeToTimestamp app_info_namespace = Namespace('applications', description='Application Endpoint.') @@ -102,13 +102,17 @@ def post(self): description=description) db.session.add(aobj) db.session.flush() - sobj = Service(application_id=aobj.application_id, - service_name=service_name, - display_name=display_name, - service_level=service_level, - host=host, - description=description) - db.session.add(sobj) + sobj = db.session.query(Service).filter( + Service.service_name == service_name).one_or_none() + if sobj is None: + sobj = Service(application_id=aobj.application_id, + service_name=service_name, + display_name=display_name, + service_level=service_level, + host=host, + description=description) + db.session.add(sobj) + db.session.flush() response_body = {"status": True, "message": "Success."} db.session.commit() db.session.close() diff --git a/app/apis/api_kubernetes.py b/app/apis/api_kubernetes.py index 0d1df6b..b8d82b7 100644 --- a/app/apis/api_kubernetes.py +++ b/app/apis/api_kubernetes.py @@ -1,18 +1,19 @@ -import os, uuid, shutil +import os, uuid, shutil, json, pathlib from datetime import datetime, timedelta from flask_restplus import Namespace, fields, Resource, reqparse from werkzeug.datastructures import FileStorage +from app import logger from models import db from models.kubernetes import Kubernetes from models.application import Application from models.service import Service from models.model import Model - +from core.drucker_dashboard_client import DruckerDashboardClient from utils.env_loader import DIR_KUBE_CONFIG, DRUCKER_GRPC_VERSION -from apis.common import DatetimeToTimestamp +from apis.common import DatetimeToTimestamp, kubernetes_cpu_to_float kube_info_namespace = Namespace('kubernetes', description='Kubernetes Endpoint.') @@ -97,7 +98,7 @@ ), 'service_level': fields.String( required=True, - description='Service level. Choose from [development/beta/staging/production].', + description='Service level. Choose from [development/beta/staging/sandbox/production].', example='development' ), 'service_name': fields.String( @@ -242,7 +243,7 @@ kube_deploy_parser = reqparse.RequestParser() kube_deploy_parser.add_argument('app_name', type=str, default='drucker-sample', required=True, help='Application name. This must be unique.', location='form') -kube_deploy_parser.add_argument('service_level', type=str, required=True, choices=('development','beta','staging','production'), help='Service level. Choose from [development/beta/staging/production].', location='form') +kube_deploy_parser.add_argument('service_level', type=str, required=True, choices=('development','beta','staging','sandbox','production'), help='Service level. Choose from [development/beta/staging/sandbox/production].', location='form') kube_deploy_parser.add_argument('service_port', type=int, default=5000, required=True, help='Service port.', location='form') kube_deploy_parser.add_argument('replicas_default', type=int, default=1, required=True, help='Number of first replica.', location='form') @@ -266,6 +267,9 @@ kube_deploy_parser.add_argument('service_git_branch', type=str, default='master', required=True, help='Git branch.', location='form') kube_deploy_parser.add_argument('service_boot_script', type=str, default='start.sh', required=True, help='Boot shellscript for your service.', location='form') +kube_deploy_parser.add_argument('service_model_assignment', type=int, required=False, help='Model assignment when service boots.', location='form') + + def update_dbs_kubernetes(kubernetes_id:int, applist:set=None, description:str=None): """ Update dbs of kubernetes entry. @@ -305,7 +309,7 @@ def update_dbs_kubernetes(kubernetes_id:int, applist:set=None, description:str=N else: aobj.confirm_date = datetime.utcnow() db.session.flush() - """Servuce""" + """Service""" for i in ret.items: if i.metadata.labels.get("drucker-worker", "False") == "False": continue @@ -320,9 +324,7 @@ def update_dbs_kubernetes(kubernetes_id:int, applist:set=None, description:str=N continue sobj = db.session.query(Service).filter( - Service.application_id == aobj.application_id, - Service.service_name == service_name, - Service.service_level == service_level).one_or_none() + Service.service_name == service_name).one_or_none() if sobj is None: display_name = uuid.uuid4().hex sobj = Service(application_id=aobj.application_id, @@ -338,6 +340,7 @@ def update_dbs_kubernetes(kubernetes_id:int, applist:set=None, description:str=N else: sobj.confirm_date = datetime.utcnow() db.session.flush() + dump_drucker_on_kubernetes(kobj.kubernetes_id, aobj.application_id, sobj.service_id) for application_name in applist: aobj = db.session.query(Application).filter( Application.application_name == application_name, @@ -351,8 +354,6 @@ def update_dbs_kubernetes(kubernetes_id:int, applist:set=None, description:str=N Application.confirm_date <= process_date, Application.kubernetes_id == kubernetes_id).delete() db.session.flush() - db.session.commit() - db.session.close() def create_or_update_drucker_on_kubernetes( @@ -376,6 +377,7 @@ def create_or_update_drucker_on_kubernetes( service_git_url = args['service_git_url'] service_git_branch = args['service_git_branch'] service_boot_script = args['service_boot_script'] + service_model_assignment = args['service_model_assignment'] mode_create = False if service_name is None: @@ -385,6 +387,8 @@ def create_or_update_drucker_on_kubernetes( volume_name = "host-volume" ssh_volume = "ssh-volume" ssh_dir = "/root/.ssh" + num_retry = 5 + progress_deadline_seconds = int(num_retry*policy_wait_seconds*replicas_maximum/(policy_max_surge+policy_max_unavailable)) kobj = db.session.query(Kubernetes).filter( Kubernetes.kubernetes_id == kubernetes_id).one_or_none() @@ -402,6 +406,82 @@ def create_or_update_drucker_on_kubernetes( from kubernetes import client, config config.load_kube_config(config_path) + pod_env = [ + client.V1EnvVar( + name="DRUCKER_SERVICE_UPDATE_FLAG", + value=commit_message + ), + client.V1EnvVar( + name="DRUCKER_APPLICATION_NAME", + value=app_name + ), + client.V1EnvVar( + name="DRUCKER_SERVICE_LEVEL", + value=service_level + ), + client.V1EnvVar( + name="DRUCKER_SERVICE_NAME", + value=service_name + ), + client.V1EnvVar( + name="DRUCKER_SERVICE_PORT", + value="{0}".format(service_port) + ), + client.V1EnvVar( + name="DRUCKER_SERVICE_INFRA", + value="kubernetes" + ), + client.V1EnvVar( + name="DRUCKER_SERVICE_GIT_URL", + value=service_git_url + ), + client.V1EnvVar( + name="DRUCKER_SERVICE_GIT_BRANCH", + value=service_git_branch + ), + client.V1EnvVar( + name="DRUCKER_SERVICE_BOOT_SHELL", + value=service_boot_script + ), + client.V1EnvVar( + name="DRUCKER_SERVICE_MODEL_DIR", + value=pod_model_dir + ), + client.V1EnvVar( + name="DRUCKER_DB_MODE", + value="mysql" + ), + client.V1EnvVar( + name="DRUCKER_DB_MYSQL_HOST", + value=db_mysql_host + ), + client.V1EnvVar( + name="DRUCKER_DB_MYSQL_PORT", + value=db_mysql_port + ), + client.V1EnvVar( + name="DRUCKER_DB_MYSQL_DBNAME", + value=db_mysql_dbname + ), + client.V1EnvVar( + name="DRUCKER_DB_MYSQL_USER", + value=db_mysql_user + ), + client.V1EnvVar( + name="DRUCKER_DB_MYSQL_PASSWORD", + value=db_mysql_password + ), + ] + if service_model_assignment is not None: + mobj = Model.query.filter_by( + model_id=service_model_assignment).one() + pod_env.append( + client.V1EnvVar( + name="DRUCKER_SERVICE_MODEL_FILE", + value=mobj.model_path + ) + ) + """Namespace""" core_vi = client.CoreV1Api() try: @@ -428,6 +508,7 @@ def create_or_update_drucker_on_kubernetes( ), spec=client.V1DeploymentSpec( min_ready_seconds=policy_wait_seconds, + progress_deadline_seconds=progress_deadline_seconds, replicas=replicas_default, revision_history_limit=3, selector=client.V1LabelSelector( @@ -468,72 +549,7 @@ def create_or_update_drucker_on_kubernetes( containers=[ client.V1Container( command=["sh","/usr/local/src/entrypoint.sh"], - env=[ - client.V1EnvVar( - name="DRUCKER_SERVICE_UPDATE_FLAG", - value=commit_message - ), - client.V1EnvVar( - name="DRUCKER_APPLICATION_NAME", - value=app_name - ), - client.V1EnvVar( - name="DRUCKER_SERVICE_LEVEL", - value=service_level - ), - client.V1EnvVar( - name="DRUCKER_SERVICE_NAME", - value=service_name - ), - client.V1EnvVar( - name="DRUCKER_SERVICE_PORT", - value="{0}".format(service_port) - ), - client.V1EnvVar( - name="DRUCKER_SERVICE_INFRA", - value="kubernetes" - ), - client.V1EnvVar( - name="DRUCKER_SERVICE_GIT_URL", - value=service_git_url - ), - client.V1EnvVar( - name="DRUCKER_SERVICE_GIT_BRANCH", - value=service_git_branch - ), - client.V1EnvVar( - name="DRUCKER_SERVICE_BOOT_SHELL", - value=service_boot_script - ), - client.V1EnvVar( - name="DRUCKER_SERVICE_MODEL_DIR", - value=pod_model_dir - ), - client.V1EnvVar( - name="DRUCKER_DB_MODE", - value="mysql" - ), - client.V1EnvVar( - name="DRUCKER_DB_MYSQL_HOST", - value=db_mysql_host - ), - client.V1EnvVar( - name="DRUCKER_DB_MYSQL_PORT", - value=db_mysql_port - ), - client.V1EnvVar( - name="DRUCKER_DB_MYSQL_DBNAME", - value=db_mysql_dbname - ), - client.V1EnvVar( - name="DRUCKER_DB_MYSQL_USER", - value=db_mysql_user - ), - client.V1EnvVar( - name="DRUCKER_DB_MYSQL_PASSWORD", - value=db_mysql_password - ), - ], + env=pod_env, image=container_image, image_pull_policy="Always", name=service_name, @@ -747,6 +763,145 @@ def create_or_update_drucker_on_kubernetes( body=v2_beta1_horizontal_pod_autoscaler ) """ + return service_name + +def switch_drucker_service_model_assignment( + application_id:int, service_id:int, model_id:int): + """ + Switch model assignment. + :param application_id: + :param service_id: + :param model_id: + :return: + """ + mobj = db.session.query(Model).filter( + Model.application_id==application_id,Model.model_id==model_id).one() + model_path = mobj.model_path + sobj = db.session.query(Service).filter( + Service.application_id == application_id, Service.service_id==service_id).one() + host = sobj.host + drucker_dashboard_application = DruckerDashboardClient(logger=logger, host=host) + response_body = drucker_dashboard_application. \ + run_switch_service_model_assignment(model_path=model_path) + if not response_body.get("status", True): + raise Exception(response_body.get("message", "Error.")) + sobj.model_id = model_id + db.session.flush() + + aobj = db.session.query(Application).filter(Application.application_id == application_id).one() + kubernetes_id = aobj.kubernetes_id + if kubernetes_id is not None: + kobj = db.session.query(Kubernetes).filter(Kubernetes.kubernetes_id == kubernetes_id).one() + config_path = kobj.config_path + from kubernetes import client, config + config.load_kube_config(config_path) + + apps_v1 = client.AppsV1Api() + v1_deployment = apps_v1.read_namespaced_deployment( + name="{0}-deployment".format(sobj.service_name), + namespace=sobj.service_level + ) + for env_ent in v1_deployment.spec.template.spec.containers[0].env: + if env_ent.name == "DRUCKER_SERVICE_UPDATE_FLAG": + env_ent.value = "Model switched to model_id={0} at {1:%Y%m%d%H%M%S}".format(model_id, datetime.utcnow()) + break + api_response = apps_v1.patch_namespaced_deployment( + body=v1_deployment, + name="{0}-deployment".format(sobj.service_name), + namespace=sobj.service_level + ) + return response_body + +def dump_drucker_on_kubernetes( + kubernetes_id:int, application_id:int, service_id:int): + kobj = Kubernetes.query.filter_by( + kubernetes_id=kubernetes_id).one_or_none() + if kobj is None: + raise Exception("No such kubernetes_id.") + aobj = Application.query.filter_by( + application_id=application_id).one_or_none() + if aobj is None: + raise Exception("No such application_id.") + sobj = Service.query.filter_by( + service_id=service_id).one_or_none() + if sobj is None: + raise Exception("No such service_id.") + + config_path = kobj.config_path + from kubernetes import client, config + config.load_kube_config(config_path) + save_dir = pathlib.Path(DIR_KUBE_CONFIG, aobj.application_name) + if not os.path.isdir(save_dir): + os.mkdir(save_dir) + api_client = client.ApiClient() + + apps_v1 = client.AppsV1Api() + v1_deployment = apps_v1.read_namespaced_deployment( + name="{0}-deployment".format(sobj.service_name), + namespace=sobj.service_level, + exact=True, + export=True + ) + json.dump(api_client.sanitize_for_serialization(v1_deployment), + pathlib.Path( + DIR_KUBE_CONFIG, + aobj.application_name, + "{0}-deployment.json".format(sobj.service_name)).open("w"), + ensure_ascii = False, indent = 2) + core_vi = client.CoreV1Api() + v1_service = core_vi.read_namespaced_service( + name="{0}-service".format(sobj.service_name), + namespace=sobj.service_level, + exact=True, + export=True + ) + json.dump(api_client.sanitize_for_serialization(v1_service), + pathlib.Path( + DIR_KUBE_CONFIG, + aobj.application_name, + "{0}-service.json".format(sobj.service_name)).open("w"), + ensure_ascii = False, indent = 2) + extensions_v1_beta = client.ExtensionsV1beta1Api() + v1_beta1_ingress = extensions_v1_beta.read_namespaced_ingress( + name="{0}-ingress".format(sobj.service_name), + namespace=sobj.service_level, + exact=True, + export=True + ) + json.dump(api_client.sanitize_for_serialization(v1_beta1_ingress), + pathlib.Path( + DIR_KUBE_CONFIG, + aobj.application_name, + "{0}-ingress.json".format(sobj.service_name)).open("w"), + ensure_ascii = False, indent = 2) + autoscaling_v1 = client.AutoscalingV1Api() + v1_horizontal_pod_autoscaler = autoscaling_v1.read_namespaced_horizontal_pod_autoscaler( + name="{0}-autoscaling".format(sobj.service_name), + namespace=sobj.service_level, + exact=True, + export=True + ) + json.dump(api_client.sanitize_for_serialization(v1_horizontal_pod_autoscaler), + pathlib.Path( + DIR_KUBE_CONFIG, + aobj.application_name, + "{0}-autoscaling.json".format(sobj.service_name)).open("w"), + ensure_ascii = False, indent = 2) + """ + autoscaling_v2_beta1 = client.AutoscalingV2beta1Api() + v2_beta1_horizontal_pod_autoscaler = autoscaling_v2_beta1.read_namespaced_horizontal_pod_autoscaler( + name="{0}-autoscaling".format(sobj.service_name), + namespace=sobj.service_level, + exact=True, + export=True + ) + json.dump(api_client.sanitize_for_serialization(v2_beta1_horizontal_pod_autoscaler), + pathlib.Path( + DIR_KUBE_CONFIG, + aobj.application_name, + "{0}-autoscaling.json".format(sobj.service_name)).open("w"), + ensure_ascii = False, indent = 2) + """ @kube_info_namespace.route('/') @@ -761,6 +916,8 @@ def put(self): """update_dbs_of_all_kubernetes_app""" for kobj in Kubernetes.query.all(): update_dbs_kubernetes(kobj.kubernetes_id) + db.session.commit() + db.session.close() response_body = {"status": True, "message": "Success."} return response_body @@ -804,9 +961,9 @@ def post(self): v1 = client.ExtensionsV1beta1Api() v1.list_ingress_for_all_namespaces(watch=False) update_dbs_kubernetes(newkube.kubernetes_id) - response_body = {"status": True, "message": "Success."} db.session.commit() db.session.close() + response_body = {"status": True, "message": "Success."} except Exception as error: os.remove(newkube.config_path) raise error @@ -824,6 +981,8 @@ def get(self, kubernetes_id:int): def put(self, kubernetes_id:int): """update_dbs_of_kubernetes_app""" update_dbs_kubernetes(kubernetes_id) + db.session.commit() + db.session.close() response_body = {"status": True, "message": "Success."} return response_body @@ -879,8 +1038,8 @@ def patch(self, kubernetes_id:int): @kube_info_namespace.marshal_with(success_or_not) def delete(self, kubernetes_id:int): """delete_kubernetes_id""" - res = db.session.query(Application).filter(Application.kubernetes_id == kubernetes_id).one_or_none() - if res is not None: + aobj = db.session.query(Application).filter(Application.kubernetes_id == kubernetes_id).all() + for res in aobj: application_id = res.application_id db.session.query(Model).filter(Model.application_id == application_id).delete() db.session.query(Service).filter(Service.application_id == application_id).delete() @@ -893,6 +1052,9 @@ def delete(self, kubernetes_id:int): @kube_info_namespace.route('//applications') class ApiKubernetesIdApplication(Resource): + kube_app_deploy = kube_deploy_parser.copy() + kube_app_deploy.remove_argument('service_model_assignment') + from apis.api_application import app_info @kube_info_namespace.marshal_list_with(app_info) def get(self, kubernetes_id:int): @@ -900,13 +1062,16 @@ def get(self, kubernetes_id:int): return Application.query.filter_by( kubernetes_id=kubernetes_id).all() - @kube_info_namespace.expect(kube_deploy_parser) + @kube_info_namespace.expect(kube_app_deploy) @kube_info_namespace.marshal_with(success_or_not) def post(self, kubernetes_id:int): """add_kubernetes_application""" - args = kube_deploy_parser.parse_args() - create_or_update_drucker_on_kubernetes(kubernetes_id, args) + args = self.kube_app_deploy.parse_args() + args["service_model_assignment"] = None + service_name = create_or_update_drucker_on_kubernetes(kubernetes_id, args) update_dbs_kubernetes(kubernetes_id, description=args['commit_message']) + db.session.commit() + db.session.close() response_body = {"status": True, "message": "Success."} return response_body @@ -933,6 +1098,8 @@ def put(self, kubernetes_id:int, application_id:int): Application.kubernetes_id == kubernetes_id).one_or_none() applist = set((aobj.application_name,)) update_dbs_kubernetes(kubernetes_id, applist) + db.session.commit() + db.session.close() response_body = {"status": True, "message": "Success."} return response_body @@ -945,9 +1112,24 @@ def post(self, kubernetes_id:int, application_id:int): Application.kubernetes_id == kubernetes_id, Application.application_id == application_id).one_or_none() args["app_name"] = aobj.application_name - create_or_update_drucker_on_kubernetes(kubernetes_id, args) + if args["service_model_assignment"] is not None: + mobj = Model.query.filter_by( + application_id=application_id, + model_id=args["service_model_assignment"]).one_or_none() + if mobj is None: + args["service_model_assignment"] = None + service_name = create_or_update_drucker_on_kubernetes(kubernetes_id, args) applist = set((aobj.application_name,)) update_dbs_kubernetes(kubernetes_id, applist, description=args['commit_message']) + + sobj = db.session.query(Service).filter( + Service.application_id == aobj.application_id, + Service.service_name == service_name).one_or_none() + if sobj is not None and sobj.model_id != args["service_model_assignment"]: + sobj.model_id = args["service_model_assignment"] + db.session.flush() + db.session.commit() + db.session.close() response_body = {"status": True, "message": "Success."} return response_body @@ -956,11 +1138,14 @@ class ApiKubernetesIdApplicationIdServiceId(Resource): kube_srv_update = kube_deploy_parser.copy() kube_srv_update.remove_argument('app_name') kube_srv_update.remove_argument('service_level') + kube_srv_update.remove_argument('service_model_assignment') @kube_info_namespace.marshal_with(kube_service_config_info) def get(self, kubernetes_id:int, application_id:int, service_id:int): """get kubernetes service info""" - sobj = Service.query.filter_by(service_id=service_id).one_or_none() + sobj = Service.query.filter_by( + application_id=application_id, + service_id=service_id).one_or_none() if sobj is None: raise Exception("No such data.") kobj = Kubernetes.query.filter_by(kubernetes_id=kubernetes_id).one_or_none() @@ -1024,9 +1209,9 @@ def get(self, kubernetes_id:int, application_id:int, service_id:int): response_body["policy_max_unavailable"] = v1_deployment.spec.strategy.rolling_update.max_unavailable response_body["policy_wait_seconds"] = v1_deployment.spec.min_ready_seconds response_body["container_image"] = v1_deployment.spec.template.spec.containers[0].image - response_body["resource_request_cpu"] = v1_deployment.spec.template.spec.containers[0].resources.requests["cpu"] + response_body["resource_request_cpu"] = kubernetes_cpu_to_float(v1_deployment.spec.template.spec.containers[0].resources.requests["cpu"]) response_body["resource_request_memory"] = v1_deployment.spec.template.spec.containers[0].resources.requests["memory"] - response_body["resource_limit_cpu"] = v1_deployment.spec.template.spec.containers[0].resources.limits["cpu"] + response_body["resource_limit_cpu"] = kubernetes_cpu_to_float(v1_deployment.spec.template.spec.containers[0].resources.limits["cpu"]) response_body["resource_limit_memory"] = v1_deployment.spec.template.spec.containers[0].resources.limits["memory"] response_body["host_model_dir"] = v1_deployment.spec.template.spec.volumes[0].host_path.path response_body["pod_model_dir"] = v1_deployment.spec.template.spec.containers[0].volume_mounts[0].mount_path @@ -1050,8 +1235,11 @@ def patch(self, kubernetes_id:int, application_id:int, service_id:int): args["app_name"] = aobj.application_name args["service_level"] = sobj.service_level args['commit_message'] = "Request a rolling-update at {0:%Y%m%d%H%M%S}".format(datetime.utcnow()) + args["service_model_assignment"] = None create_or_update_drucker_on_kubernetes(kubernetes_id, args, sobj.service_name) applist = set((aobj.application_name,)) update_dbs_kubernetes(kubernetes_id, applist) + db.session.commit() + db.session.close() response_body = {"status": True, "message": "Success."} - return response_body \ No newline at end of file + return response_body diff --git a/app/apis/api_misc.py b/app/apis/api_misc.py new file mode 100644 index 0000000..083d890 --- /dev/null +++ b/app/apis/api_misc.py @@ -0,0 +1,12 @@ +from flask_restplus import Resource, Namespace +from utils.env_loader import config + +misc_info_namespace = Namespace('misc', description='Misc Endpoint.') + +@misc_info_namespace.route('/settings') +class Settings(Resource): + def get(self): + result = { + 'auth': 'auth' in config + } + return result diff --git a/app/apis/api_model.py b/app/apis/api_model.py index 7c3c38d..c61ac50 100644 --- a/app/apis/api_model.py +++ b/app/apis/api_model.py @@ -4,12 +4,12 @@ from werkzeug.datastructures import FileStorage +from app import logger from models import db from models.service import Service from models.model import Model - from core.drucker_dashboard_client import DruckerDashboardClient -from apis.common import logger, DatetimeToTimestamp +from apis.common import DatetimeToTimestamp mdl_info_namespace = Namespace('models', description='Model Endpoint.') @@ -109,3 +109,19 @@ def patch(self, application_id:int, model_id:int): db.session.commit() db.session.close() return response_body + + @mdl_info_namespace.marshal_with(success_or_not) + def delete(self, application_id:int, model_id:int): + """delete_model""" + mobj = db.session.query(Model).filter( + Model.application_id==application_id, + Model.model_id==model_id).one_or_none() + if mobj is None: + raise Exception("No such model_id.") + db.session.query(Model).filter( + Model.application_id==application_id, + Model.model_id==model_id).delete() + response_body = {"status": True, "message": "Success."} + db.session.commit() + db.session.close() + return response_body diff --git a/app/apis/api_service.py b/app/apis/api_service.py index 48994f8..c8a9415 100644 --- a/app/apis/api_service.py +++ b/app/apis/api_service.py @@ -6,11 +6,9 @@ from models.kubernetes import Kubernetes from models.application import Application from models.service import Service -from models.model import Model -from core.drucker_dashboard_client import DruckerDashboardClient -from apis.common import logger, DatetimeToTimestamp -from apis.api_kubernetes import update_dbs_kubernetes +from apis.common import DatetimeToTimestamp +from apis.api_kubernetes import update_dbs_kubernetes, switch_drucker_service_model_assignment srv_info_namespace = Namespace('services', description='Service Endpoint.') @@ -47,7 +45,7 @@ ), 'service_level': fields.String( required=True, - description='Service level. [development/beta/staging/production]', + description='Service level. [development/beta/staging/sandbox/production]', example='development' ), 'register_date': DatetimeToTimestamp( @@ -104,42 +102,8 @@ def put(self, application_id:int, service_id:int): """switch_service_model_assignment""" args = self.switch_model_parser.parse_args() model_id = args['model_id'] - - mobj = db.session.query(Model).filter( - Model.application_id==application_id,Model.model_id==model_id).one() - model_path = mobj.model_path - sobj = db.session.query(Service).filter( - Service.application_id == application_id, Service.service_id==service_id).one() - host = sobj.host - drucker_dashboard_application = DruckerDashboardClient(logger=logger, host=host) - response_body = drucker_dashboard_application.run_switch_service_model_assignment(model_path) - if not response_body.get("status", True): - raise Exception(response_body.get("message", "Error.")) - sobj.model_id = model_id - db.session.flush() - - aobj = db.session.query(Application).filter(Application.application_id == application_id).one() - kubernetes_id = aobj.kubernetes_id - if kubernetes_id is not None: - kobj = db.session.query(Kubernetes).filter(Kubernetes.kubernetes_id == kubernetes_id).one() - config_path = kobj.config_path - from kubernetes import client, config - config.load_kube_config(config_path) - - apps_v1 = client.AppsV1Api() - v1_deployment = apps_v1.read_namespaced_deployment( - name="{0}-deployment".format(sobj.service_name), - namespace=sobj.service_level - ) - for env_ent in v1_deployment.spec.template.spec.containers[0].env: - if env_ent.name == "DRUCKER_SERVICE_UPDATE_FLAG": - env_ent.value = "Model switched to model_id={0} at {1:%Y%m%d%H%M%S}".format(model_id, datetime.datetime.utcnow()) - break - api_response = apps_v1.patch_namespaced_deployment( - body=v1_deployment, - name="{0}-deployment".format(sobj.service_name), - namespace=sobj.service_level - ) + response_body = switch_drucker_service_model_assignment( + application_id, service_id, model_id) db.session.commit() db.session.close() return response_body @@ -221,4 +185,4 @@ def delete(self, application_id:int, service_id:int): response_body = {"status": True, "message": "Success."} db.session.commit() db.session.close() - return response_body + return response_body \ No newline at end of file diff --git a/app/apis/common.py b/app/apis/common.py index ab305e3..e023daf 100644 --- a/app/apis/common.py +++ b/app/apis/common.py @@ -1,9 +1,29 @@ from flask_restplus import fields -from logger.logger_jsonlogger import SystemLogger - -logger = SystemLogger(logger_name="drucker_dashboard") class DatetimeToTimestamp(fields.Raw): def format(self, value): return value.timestamp() + + +decimal_suffixes = { + 'm': 1.0e-3, + 'k': 1.0e+3, + 'M': 1.0e+6, + 'G': 1.0e+9, + 'T': 1.0e+12, + 'P': 1.0e+15, + 'E': 1.0e+18, +} + + +def kubernetes_cpu_to_float(cpu_str: str): + """ Convert the Kubernetes CPU value to float """ + suffix = cpu_str[-1] + if suffix.isnumeric(): + return float(cpu_str) + elif suffix in decimal_suffixes.keys(): + value = float(cpu_str[:-1]) + return value * decimal_suffixes[suffix] + else: + raise ValueError(f'Please check the input CPU value: `{cpu_str}`') diff --git a/app/app.py b/app/app.py index 35c071b..b9fed41 100644 --- a/app/app.py +++ b/app/app.py @@ -2,21 +2,26 @@ # -*- coding: utf-8 -*- # # DO NOT EDIT HERE!! +import sys import os +import pathlib -from flask import Flask, jsonify + +root_path = pathlib.Path(os.path.abspath(__file__)).parent +sys.path.append(str(root_path)) + + +from flask import Flask from flask_cors import CORS +from logger.logger_jsonlogger import SystemLogger -from models import db, db_url -from apis import api -from auth import auth -from utils.env_loader import DIR_KUBE_CONFIG, config +logger = SystemLogger(logger_name="drucker_dashboard") app = Flask(__name__) -def configure_app(flask_app: Flask) -> None: - flask_app.config['SQLALCHEMY_DATABASE_URI'] = db_url() +def configure_app(flask_app: Flask, db_url: str) -> None: + flask_app.config['SQLALCHEMY_DATABASE_URI'] = db_url flask_app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True flask_app.config['DEBUG'] = bool(os.getenv("FLASK_DEBUG", "True")) flask_app.config['SWAGGER_UI_DOC_EXPANSION'] = 'list' @@ -24,15 +29,20 @@ def configure_app(flask_app: Flask) -> None: def initialize_app(flask_app: Flask) -> None: + from models import db, db_url + from apis import api + from auth import auth + from utils.env_loader import DIR_KUBE_CONFIG, config + if not os.path.isdir(DIR_KUBE_CONFIG): os.mkdir(DIR_KUBE_CONFIG) - configure_app(flask_app) + configure_app(flask_app, db_url()) api.init_app(flask_app) if 'auth' in config: auth.init_app(flask_app, api) - CORS(app) + CORS(flask_app) db.init_app(flask_app) db.create_all(app=flask_app) diff --git a/app/auth/__init__.py b/app/auth/__init__.py index a555cec..2aac368 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -4,8 +4,8 @@ from flask_jwt_simple import JWTManager, create_jwt, get_jwt_identity, jwt_required from jwt.exceptions import PyJWTError from flask_jwt_simple.exceptions import InvalidHeaderError, NoAuthorizationError -from apis import logger from auth.ldap import LdapAuthenticator +from app import logger from utils.env_loader import config @@ -37,7 +37,7 @@ def init_app(self, app, api, **kwargs): authenticator = LdapAuthenticator(auth_conf['ldap']) # Add endpoints - @app.route('/login', methods=['POST']) + @app.route('/api/login', methods=['POST']) def login(): params = request.get_json() username = params.get('username', None) @@ -49,7 +49,7 @@ def login(): else: return jsonify({'message': 'Authentication failed'}), 401 - @app.route('/credential', methods=['GET']) + @app.route('/api/credential', methods=['GET']) @jwt_required def credential(): user = get_jwt_identity() diff --git a/app/auth/ldap.py b/app/auth/ldap.py index b5b623e..19c3199 100644 --- a/app/auth/ldap.py +++ b/app/auth/ldap.py @@ -1,5 +1,5 @@ import ldap -from apis import logger +from app import logger class LdapAuthenticator(object): diff --git a/app/drucker-grpc-proto b/app/drucker-grpc-proto index b86b433..9d90884 160000 --- a/app/drucker-grpc-proto +++ b/app/drucker-grpc-proto @@ -1 +1 @@ -Subproject commit b86b4339dc8ed2de4e63c7a3f6452b5fced5c9ad +Subproject commit 9d908849757be5ceb51c8353bdc706f76dc78ea0 diff --git a/app/manage.py b/app/manage.py new file mode 100644 index 0000000..f0ffc95 --- /dev/null +++ b/app/manage.py @@ -0,0 +1,18 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +from flask import Flask +from flask_script import Manager +from flask_migrate import Migrate, MigrateCommand + +from app import initialize_app +from models import db + + +app = Flask(__name__) +initialize_app(app) +migrate = Migrate(app, db) +manager = Manager(app) +manager.add_command('db', MigrateCommand) + +if __name__ == '__main__': + manager.run() diff --git a/app/models/__init__.py b/app/models/__init__.py index 8e6408f..35cc101 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ from flask_sqlalchemy import SQLAlchemy from utils.env_loader import ( + TEST_MODE, DB_MODE, DB_MYSQL_HOST, DB_MYSQL_PORT, @@ -23,11 +24,12 @@ def db_url(): :TODO: Use an appropriate "Exception" """ if DB_MODE == "sqlite": - url = f'sqlite:///db.sqlite3' + db_name = "db.test.sqlite3" if TEST_MODE else "db.sqlite3" + url = f'sqlite:///{db_name}' elif DB_MODE == "mysql": host = DB_MYSQL_HOST port = DB_MYSQL_PORT - db_name = DB_MYSQL_DBNAME + db_name = "test_"+DB_MYSQL_DBNAME if TEST_MODE else DB_MYSQL_DBNAME user = DB_MYSQL_USER password = DB_MYSQL_PASSWORD url = f'mysql+pymysql://{user}:{password}@{host}:{port}/{db_name}?charset=utf8' diff --git a/app/models/kubernetes.py b/app/models/kubernetes.py index 2564ad6..643f88e 100644 --- a/app/models/kubernetes.py +++ b/app/models/kubernetes.py @@ -16,6 +16,7 @@ class Kubernetes(db.Model): __table_args__ = ( UniqueConstraint('kubernetes_id'), UniqueConstraint('config_path'), + UniqueConstraint('dns_name'), UniqueConstraint('display_name'), {'mysql_engine': 'InnoDB'} ) diff --git a/app/models/service.py b/app/models/service.py index 5325d2c..122e955 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -15,7 +15,7 @@ class Service(db.Model): __tablename__ = 'services' __table_args__ = ( UniqueConstraint('service_id'), - UniqueConstraint('service_name', 'service_level'), + UniqueConstraint('service_name'), UniqueConstraint('application_id','display_name'), {'mysql_engine': 'InnoDB'} ) diff --git a/app/requirements.txt b/app/requirements.txt index b1bc8f0..388de35 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -13,3 +13,5 @@ flask-restplus==0.11.0 Flask-Testing==0.7.1 Flask-JWT-Simple==0.0.3 python-ldap==3.1.0 +Flask-Migrate==2.2.1 +Flask-Script==2.0.6 \ No newline at end of file diff --git a/app/settings.yml b/app/settings.yml index f1c7e16..ca55f7b 100644 --- a/app/settings.yml +++ b/app/settings.yml @@ -13,6 +13,11 @@ db.mysql.port: 3306 db.mysql.dbname: management db.mysql.user: user db.mysql.password: pass + +# Kubernetes +kube.datadir: kube-config + +# LDAP # auth: # secret: 'super-secret' # ldap: diff --git a/app/start.sh b/app/start.sh index 0191bc3..b9de890 100755 --- a/app/start.sh +++ b/app/start.sh @@ -1,15 +1,34 @@ #!/usr/bin/env bash -ECHO_PREFIX="[drucker-dashboard example]: " +ECHO_PREFIX="[drucker-dashboard]: " set -e set -u echo "$ECHO_PREFIX Start.." +echo "$ECHO_PREFIX Start installing libraries" pip install -r requirements.txt - pip install -r ./drucker-grpc-proto/requirements.txt -python ./drucker-grpc-proto/run_codegen.py +sh ./drucker-grpc-proto/run_codegen.sh +echo "$ECHO_PREFIX End installing libraries" + +echo "$ECHO_PREFIX Start DB migration" +python manage.py db init || true +python manage.py db migrate || true +if [ $# -ne 0 ]; then + case $1 in + "upgrade") + echo "$ECHO_PREFIX Start DB migration upgrade" + python manage.py db upgrade || true + echo "$ECHO_PREFIX End DB migration upgrade" + ;; + *) + echo "$ECHO_PREFIX Invalid option: $1" + ;; + esac +fi +echo "$ECHO_PREFIX End DB migration" +echo "$ECHO_PREFIX Start Drucker dashboard service" python app.py \ No newline at end of file diff --git a/app/utils/env_loader.py b/app/utils/env_loader.py index 9cdd999..e146b3c 100644 --- a/app/utils/env_loader.py +++ b/app/utils/env_loader.py @@ -5,12 +5,16 @@ import drucker_pb2 +TEST_MODE = False if os.getenv("DRUCKER_TEST_MODE", None) is None else True + BASE_PATH = os.path.abspath(os.path.dirname(__file__)) -config = yaml.load(open(os.path.join(BASE_PATH, "..", "settings.yml"), 'r')) +SETTINGS_YAML = os.getenv("DRUCKER_DASHBOARD_SETTINGS_YAML", + os.path.join(BASE_PATH, "..", "settings.yml")) +config = yaml.load(open(SETTINGS_YAML, 'r')) DRUCKER_GRPC_VERSION = drucker_pb2.DESCRIPTOR.GetOptions().Extensions[drucker_pb2.drucker_grpc_proto_version] -DIR_KUBE_CONFIG = "kube-config" +DIR_KUBE_CONFIG = os.getenv('DRUCKER_KUBE_DATADIR', config.get('kube.datadir', 'kube-config')) DB_MODE = os.getenv('DRUCKER_DB_MODE', config.get('use.db',"sqlite")) DB_MYSQL_HOST = os.getenv('DRUCKER_DB_MYSQL_HOST', config.get('db.mysql.host',"")) diff --git a/frontend/configs/config.json b/frontend/configs/config.json index fdd7309..5b71f3c 100644 --- a/frontend/configs/config.json +++ b/frontend/configs/config.json @@ -1,11 +1,12 @@ { "environments": [ - "development", "beta", "staging", "production" + "development", "beta", "staging", "sandbox", "production" ], "environmentsToName": { "development": "Development", "beta": "Beta", "staging": "Staging", + "sandbox": "Sandbox", "production": "Production" } } diff --git a/frontend/src/actions/index.tsx b/frontend/src/actions/index.tsx index 0744b97..55e7951 100644 --- a/frontend/src/actions/index.tsx +++ b/frontend/src/actions/index.tsx @@ -1,15 +1,18 @@ import { Action } from 'redux' import { APIError, APIErrorType } from '@src/apis/Core' import { - addApplication, saveService, saveKubernetesHost, + addApplication, saveService, saveModel, saveKubernetesHost, fetchAllApplications, fetchAllModels, fetchAllServices, fetchApplicationById, fetchKubernetesHostById, - fetchServiceById, FetchServicesParam, fetchServiceDescriptions, + fetchServiceById, fetchModelById, FetchServicesParam, + FetchModelsParam, fetchServiceDescriptions, fetchAllKubernetesHosts, uploadModel, switchModels, syncKubernetesStatus, deleteKubernetesHost, deleteKubernetesServices, + deleteKubernetesModels, settings, login, userInfo, - SaveServiceParam, SwitchModelParam, ModelResponse, KubernetesHost, LoginParam, AuthToken, UserInfo + SaveServiceParam, SaveModelParam, SwitchModelParam, + ModelResponse, KubernetesHost, LoginParam, AuthToken, UserInfo } from '@src/apis' import { Application, Model, Service } from '@src/apis' @@ -110,6 +113,12 @@ export const saveServiceDispatcher = asyncAPIRequestDispatcherCreator('SAVE_MODEL') +export const saveModelDispatcher = asyncAPIRequestDispatcherCreator( + saveModelActionCreators, + saveModel +) + export const fetchAllKubernetesHostsActionCreators = new APIRequestActionCreators<{}, KubernetesHost[]>('FETCH_ALL_CONNECTIONS') export const fetchAllKubernetesHostsDispatcher = asyncAPIRequestDispatcherCreator<{}, KubernetesHost[]>( fetchAllKubernetesHostsActionCreators, @@ -134,8 +143,8 @@ export const fetchKubernetesHostByIdDispatcher = asyncAPIRequestDispatcherCreato fetchKubernetesHostById ) -export const fetchAllModelsActionCreators = new APIRequestActionCreators<{application_id: string}, Model[]>('FETCH_ALL_MODELS') -export const fetchAllModelsDispatcher = asyncAPIRequestDispatcherCreator<{application_id: string}, Model[]>( +export const fetchAllModelsActionCreators = new APIRequestActionCreators<{applicationId: string}, Model[]>('FETCH_ALL_MODELS') +export const fetchAllModelsDispatcher = asyncAPIRequestDispatcherCreator<{applicationId: string}, Model[]>( fetchAllModelsActionCreators, fetchAllModels ) @@ -152,6 +161,12 @@ export const fetchServiceByIdDispatcher = asyncAPIRequestDispatcherCreator('FETCH_MODEL') +export const fetchModelByIdDispatcher = asyncAPIRequestDispatcherCreator( + fetchModelByIdActionCreators, + fetchModelById +) + export const fetchServiceDescriptionsActionCreators = new APIRequestActionCreators('FETCH_SERVICE_DESCRIPTIONS') export const fetchServiceDescriptionsDispatcher = asyncAPIRequestDispatcherCreator( fetchServiceDescriptionsActionCreators, @@ -176,6 +191,12 @@ export const deleteKubernetesServicesDispatcher = asyncAPIRequestDispatcherCreat deleteKubernetesServices ) +export const deleteKubernetesModelsActionCreators = new APIRequestActionCreators('DELETE_KUBERNETES_MODEL') +export const deleteKubernetesModelsDispatcher = asyncAPIRequestDispatcherCreator( + deleteKubernetesModelsActionCreators, + deleteKubernetesModels +) + export const syncKubernetesStatusActionCreators = new APIRequestActionCreators('SYNC_KUBERNETES_STATUS') export const syncKubernetesStatusDispatcher = asyncAPIRequestDispatcherCreator( syncKubernetesStatusActionCreators, diff --git a/frontend/src/apis/Core/index.tsx b/frontend/src/apis/Core/index.tsx index 7e42733..c876eb9 100644 --- a/frontend/src/apis/Core/index.tsx +++ b/frontend/src/apis/Core/index.tsx @@ -182,7 +182,7 @@ export async function rawRequest( return fetch(entryPoint, fullOptions) .then((response) => { - return _handleAPIResponse(response, convert) + return _handleAPIResponse(entryPoint, response, convert) }) .catch((error) => { if (error instanceof APIError) { @@ -213,7 +213,16 @@ export async function rawMultiRequest( } ) const fullOptionsList = requestList.map(generateRawRequest) - + const token = window.localStorage.getItem(JWT_TOKEN_KEY) + if (token) { + fullOptionsList.map( + (fullOptions) => + fullOptions.headers = { + ...fullOptions.headers, + Authorization: `Bearer ${token}` + } + ) + } return Promise.all( entryPoints.map( (entryPoint, i) => @@ -222,8 +231,8 @@ export async function rawMultiRequest( ) .then((responses) => { return responses.map( - (response) => { - return _handleAPIResponse(response, convert) + (response, i) => { + return _handleAPIResponse(entryPoints[i], response, convert) }) }) .catch((error) => { throw new APIError(error.message) }) @@ -237,6 +246,7 @@ function generateFormData(params) { } function _handleAPIResponse( + entryPoint: string, response: Response, convert: (response) => T = (r) => r ): Promise { @@ -246,14 +256,25 @@ function _handleAPIResponse( } // Throw API error exception - return new Promise((_, reject) => { - response.json() - .then((resultJSON) => { - reject(new APIError( - resultJSON.message, - APIErrorType.serverside, - response.status - )) - }) - }) + if (entryPoint.endsWith('/credential')) { + return new Promise((_, reject) => { + response.json() + .then((resultJSON) => { + reject(new APIError( + resultJSON.message, + APIErrorType.serverside, + response.status + )) + }) + }) + } + response.json().then( + (resultJSON) => { + throw new APIError( + resultJSON.message, + APIErrorType.serverside, + response.status + ) + } + ) } diff --git a/frontend/src/apis/index.tsx b/frontend/src/apis/index.tsx index be9112c..044cc97 100644 --- a/frontend/src/apis/index.tsx +++ b/frontend/src/apis/index.tsx @@ -15,7 +15,9 @@ export class Service { public id: string = '', public name: string = '', public serviceLevel: string = '', - public modelId: string = null + public modelId: string = null, + public host: string = '', + public description: string = '', ) { } } @@ -86,6 +88,7 @@ export class Model { constructor( public name: string = '', public id: string = '', + public registeredDate: Date = null, ) { } } @@ -217,6 +220,26 @@ export async function saveService(params: SaveServiceParam): Promise { throw new RangeError(`You specified wrong parameter type ${params.applicationType}`) } +export interface SaveModelParam { + id?: string + applicationId: string + description: string +} +export async function saveModel(params: SaveModelParam): Promise { + const requestBody = Object.keys(params) + .map((value) => ({ [snakelize(value)]: params[value] })) + .reduce((l, r) => Object.assign(l, r), {}) + const convert = (result) => result.status as boolean + const idUrlString = params.id ? `/${params.id}` : '' + + return APICore.formDataRequest( + `${process.env.API_HOST}:${process.env.API_PORT}/api/applications/${params.applicationId}/models${idUrlString}`, + requestBody, + convert, + 'PATCH' + ) +} + export interface UploadModelParam { applicationId: string name: string @@ -295,8 +318,9 @@ export async function fetchKubernetesHostById(params: any): Promise { return APICore.getRequest(`${process.env.API_HOST}:${process.env.API_PORT}/api/kubernetes/${params.id}`, convert) } -interface FetchModelsParam { - application_id: string +export interface FetchModelsParam { + id?: string + applicationId: string } export async function fetchAllModels(params: FetchModelsParam): Promise { const convert = @@ -304,9 +328,10 @@ export async function fetchAllModels(params: FetchModelsParam): Promise return { name: variable.description, id: variable.model_id, + registeredDate: new Date(variable.register_date * 1000), } }) - return APICore.getRequest(`${process.env.API_HOST}:${process.env.API_PORT}/api/applications/${params.application_id}/models`, convert) + return APICore.getRequest(`${process.env.API_HOST}:${process.env.API_PORT}/api/applications/${params.applicationId}/models`, convert) } export interface FetchServicesParam { @@ -323,6 +348,8 @@ export async function fetchAllServices(params: FetchServicesParam) { name: variable.display_name, serviceLevel: variable.service_level, modelId: variable.model_id, + host: variable.host, + description: variable.description, } }) return APICore.getRequest(`${process.env.API_HOST}:${process.env.API_PORT}/api/applications/${params.applicationId}/services`, convert) @@ -350,6 +377,22 @@ export async function fetchServiceById(params: FetchServicesParam) { } } +export async function fetchModelById(params: FetchModelsParam) { + const convertDetail = + (result) => ( + { + id: result.model_id, + description: result.description, + ...convertKeys(result, camelize) + } + ) + + return APICore.getRequest( + `${process.env.API_HOST}:${process.env.API_PORT}/api/applications/${params.applicationId}/models/${params.id}`, + convertDetail + ) +} + export async function fetchServiceDescriptions(params: FetchServicesParam): Promise { const convert = (result) => ( @@ -462,6 +505,20 @@ export async function deleteKubernetesServices(params: any): Promise(entryPoints, convert, requestList) } +export async function deleteKubernetesModels(params: any): Promise>> { + const convert = (result) => result.status + + const entryPoints = params.map( + (param) => + `${process.env.API_HOST}:${process.env.API_PORT}/api/applications/${param.applicationId}/models/${param.modelId}` + ) + const requestList = params.map( + (param) => ({ options: { method: 'DELETE' } }) + ) + + return APICore.rawMultiRequest(entryPoints, convert, requestList) +} + // Login API export interface LoginParam { username: string @@ -475,7 +532,7 @@ export async function login(param: LoginParam) { } } return APICore.postJsonRequest( - `${process.env.API_HOST}:${process.env.API_PORT}/login`, + `${process.env.API_HOST}:${process.env.API_PORT}/api/login`, param, convert ) @@ -483,7 +540,7 @@ export async function login(param: LoginParam) { export async function settings(): Promise { return APICore.getRequest( - `${process.env.API_HOST}:${process.env.API_PORT}/settings` + `${process.env.API_HOST}:${process.env.API_PORT}/api/settings` ) } @@ -494,7 +551,7 @@ export async function userInfo(): Promise { } } return APICore.getRequest( - `${process.env.API_HOST}:${process.env.API_PORT}/credential`, + `${process.env.API_HOST}:${process.env.API_PORT}/api/credential`, convert ) } diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 408fc21..c4df8e7 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -11,6 +11,7 @@ import { saveKubernetesHostReducer, addApplicationReducer, saveServiceReducer, + saveModelReducer, fetchAllKubernetesHostsReducer, fetchKubernetesHostByIdReducer, fetchAllApplicationsReducer, @@ -19,8 +20,10 @@ import { fetchAllServicesReducer, fetchServiceDescriptionsReducer, fetchServiceByIdReducer, + fetchModelByIdReducer, deleteKubernetesHostReducer, deleteKubernetesServicesReducer, + deleteKubernetesModelsReducer, syncKubernetesStatusReducer, settingsReducer, loginReducer, @@ -38,6 +41,7 @@ const store = compose(applyMiddleware(thunk))(createStore)( saveKubernetesHostReducer, addApplicationReducer, saveServiceReducer, + saveModelReducer, fetchAllKubernetesHostsReducer, fetchKubernetesHostByIdReducer, fetchAllApplicationsReducer, @@ -45,9 +49,11 @@ const store = compose(applyMiddleware(thunk))(createStore)( fetchAllModelsReducer, fetchAllServicesReducer, fetchServiceByIdReducer, + fetchModelByIdReducer, fetchServiceDescriptionsReducer, deleteKubernetesHostReducer, deleteKubernetesServicesReducer, + deleteKubernetesModelsReducer, syncKubernetesStatusReducer, settingsReducer, loginReducer, diff --git a/frontend/src/components/App/Application/SideMenu/index.tsx b/frontend/src/components/App/Application/SideMenu/index.tsx index 053bd83..852c138 100644 --- a/frontend/src/components/App/Application/SideMenu/index.tsx +++ b/frontend/src/components/App/Application/SideMenu/index.tsx @@ -12,6 +12,16 @@ class SideMenu extends React.Component { path: 'dashboard', icon: 'ship' }, + { + text: 'Services', + path: 'services', + icon: 'server' + }, + { + text: 'Models', + path: 'models', + icon: 'database' + }, ] }, ] diff --git a/frontend/src/components/App/Deploy/DeployStatusForm.tsx b/frontend/src/components/App/Deploy/DeployStatusForm.tsx index 6a5fb43..921d4a2 100644 --- a/frontend/src/components/App/Deploy/DeployStatusForm.tsx +++ b/frontend/src/components/App/Deploy/DeployStatusForm.tsx @@ -91,7 +91,7 @@ class DeployStatusForm extends React.Component { } const paramsMap = { - [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Services' }, + [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Services/Models' }, [ControlMode.EDIT_DEPLOY_STATUS]: { color: 'success', icon: 'save', text: 'Save Changes' } } @@ -128,7 +128,7 @@ class DeployStatusForm extends React.Component { } = this.props const paramsMap = { - [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Services' }, + [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Services/Models' }, [ControlMode.EDIT_DEPLOY_STATUS]: { color: 'success', icon: 'save', text: 'Save Changes' } } @@ -156,7 +156,7 @@ class DeployStatusForm extends React.Component { disabled={pristine || submitting} > - Delete Services + Delete Services/Models ) diff --git a/frontend/src/components/App/Deploy/DeployStatusTable.tsx b/frontend/src/components/App/Deploy/DeployStatusTable.tsx index 6328611..a97d491 100644 --- a/frontend/src/components/App/Deploy/DeployStatusTable.tsx +++ b/frontend/src/components/App/Deploy/DeployStatusTable.tsx @@ -107,7 +107,7 @@ class DeployStatusTable extends React.Component { const { - selectModelMode, models + selectModelMode, models, applicationId, mode } = this.props const modelSelectCheckBox = (model: Model) => ( @@ -119,6 +119,32 @@ class DeployStatusTable extends React.Component ) + const deleteCheckButton = (modelName: string, modelId: string) => { + return ( + + + + {modelName} + + + ) + } + + const renderButton = (modelName, modelId) => { + const renderMap = { + [ControlMode.VIEW_DEPLOY_STATUS] : deleteCheckButton(modelName, modelId), + [ControlMode.SELECT_TARGETS] : deleteCheckButton(modelName, modelId), + [ControlMode.EDIT_DEPLOY_STATUS]: {modelName} + } + return renderMap[mode] + } + return ( { @@ -126,7 +152,7 @@ class DeployStatusTable extends React.Component ( - {selectModelMode ? modelSelectCheckBox(model) : model.name} + {selectModelMode ? modelSelectCheckBox(model) : renderButton(model.name, model.id)} {this.renderStatusRow(model)} diff --git a/frontend/src/components/App/Deploy/index.tsx b/frontend/src/components/App/Deploy/index.tsx index ea67731..5772eb5 100644 --- a/frontend/src/components/App/Deploy/index.tsx +++ b/frontend/src/components/App/Deploy/index.tsx @@ -15,7 +15,7 @@ import { syncKubernetesStatusDispatcher } from '@src/actions' import DeployStatusForm from './DeployStatusForm' -import { AddModelFileModal } from './Modals/AddModelFileModal' +import { AddModelFileModal } from '@components/App/Model/Modals/AddModelFileModal' import { APIRequestResultsRenderer } from '@common/APIRequestResultsRenderer' export enum ControlMode { @@ -281,7 +281,7 @@ class Deploy extends React.Component { return ( - Delete Kubernetes Services + Delete Services/Models Are you sure to delete? @@ -360,7 +360,7 @@ class Deploy extends React.Component { isDeleteServicesModalOpen: true, selectedData: { services: params.delete.services, - models: [] + models: params.delete.models } }) } @@ -463,7 +463,7 @@ const mapDispatchToProps = (dispatch): DispatchProps => { return { addNotification: (params) => dispatch(addNotification(params)), fetchApplicationById: (id: string) => fetchApplicationByIdDispatcher(dispatch, { id }), - fetchAllModels: (applicationId: string) => fetchAllModelsDispatcher(dispatch, { application_id: applicationId }), + fetchAllModels: (applicationId: string) => fetchAllModelsDispatcher(dispatch, { applicationId }), fetchAllServices: (applicationId: string) => fetchAllServicesDispatcher(dispatch, { applicationId }), switchModels: (params: SwitchModelParam[]) => switchModelsDispatcher(dispatch, params), deleteKubernetesServices: (params) => deleteKubernetesServicesDispatcher(dispatch, params), diff --git a/frontend/src/components/App/Deploy/Modals/AddModelFileModal.tsx b/frontend/src/components/App/Model/Modals/AddModelFileModal.tsx similarity index 99% rename from frontend/src/components/App/Deploy/Modals/AddModelFileModal.tsx rename to frontend/src/components/App/Model/Modals/AddModelFileModal.tsx index fd8817b..eb14fe8 100644 --- a/frontend/src/components/App/Deploy/Modals/AddModelFileModal.tsx +++ b/frontend/src/components/App/Model/Modals/AddModelFileModal.tsx @@ -2,8 +2,8 @@ import * as React from 'react' import { connect } from 'react-redux' import { reduxForm, Field, InjectedFormProps } from 'redux-form' -import { uploadModelDispatcher, addNotification } from '@src/actions' -import { APIRequest, isAPISucceeded, isAPIFailed } from '@src/apis/Core' +import { uploadModelDispatcher, addNotification } from '@src/actions/index' +import { APIRequest, isAPISucceeded, isAPIFailed } from '@src/apis/Core/index' import { SingleFormField } from '@common/Field/SingleFormField' import { FileUploadInputField } from '@common/Field/FileUploadInputField' import { required } from '@common/Field/Validateors' diff --git a/frontend/src/components/App/Model/ModelDescription/ModelDescriptionForm.tsx b/frontend/src/components/App/Model/ModelDescription/ModelDescriptionForm.tsx new file mode 100644 index 0000000..6f14448 --- /dev/null +++ b/frontend/src/components/App/Model/ModelDescription/ModelDescriptionForm.tsx @@ -0,0 +1,121 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { reduxForm, InjectedFormProps, Field } from 'redux-form' +import { Card, CardBody, Form, Button } from 'reactstrap' + +import { Model } from '@src/apis' +import { SingleFormField } from '@common/Field/SingleFormField' + +class ModelDescriptionFormImpl extends React.Component { + render() { + const { handleSubmit, onSubmit } = this.props + const modelSpeicificForm = this.renderModelSpecificForm() + + return ( + + +
+ {modelSpeicificForm} + {this.renderButtons()} +
+
+
+ ) + } + + /** + * + * + * @param applicationType Type of application to set up + * @returns {JSX.Element} Config fields + */ + renderModelSpecificForm(): JSX.Element { + + return ( + + + + ) + } + + /** + * Render control buttons + * + * Put on footer of this modal + */ + renderButtons(): JSX.Element { + const { submitting, reset } = this.props + + if (submitting) { + return ( + +
+ Submitting... + + ) + } + + return ( + + + {' '} + + + ) + } +} + +interface CustomProps { + onCancel + onSubmit + model?: Model +} + +interface StateProps { + initialValues +} + +type ModelDescriptionFormProps = + CustomProps + & StateProps + & InjectedFormProps<{}, CustomProps> + +const generateInitialValues = (props: CustomProps) => ( + { + edit: { + model: { + ...props.model, + } + } + } +) + +/** + * Description form + * Shown only when description + */ +export const ModelDescriptionForm = + connect( + (state: any, extraProps: CustomProps) => ({ + initialValues: generateInitialValues(extraProps), + ...extraProps, + ...state.form + }) + )(reduxForm<{}, CustomProps>({ + form: 'modelDescriptionForm', + touchOnChange: true, + enableReinitialize: true + })(ModelDescriptionFormImpl)) diff --git a/frontend/src/components/App/Model/ModelDescription/index.tsx b/frontend/src/components/App/Model/ModelDescription/index.tsx new file mode 100644 index 0000000..87f14a1 --- /dev/null +++ b/frontend/src/components/App/Model/ModelDescription/index.tsx @@ -0,0 +1,173 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { withRouter, RouteComponentProps } from 'react-router-dom' + +import { APIRequest, isAPISucceeded, isAPIFailed } from '@src/apis/Core' +import { Model, Application, } from '@src/apis' +import { + saveModelDispatcher, + addNotification, + fetchModelByIdDispatcher +} from '@src/actions' +import { APIRequestResultsRenderer } from '@common/APIRequestResultsRenderer' +import { ModelDescriptionForm } from './ModelDescriptionForm' + +/** + * Page for adding model + * You can create model ONLY when your application is deployed with Kubernetes. + */ +class ModelDescription extends React.Component { + constructor(props, context) { + super(props, context) + + this.renderForm = this.renderForm.bind(this) + this.onSubmit = this.onSubmit.bind(this) + this.onCancel = this.onCancel.bind(this) + this.state = { + submitting: false, + notified: false, + } + } + + componentWillReceiveProps(nextProps: ModelDescriptionProps) { + const { saveModelStatus } = nextProps + const { push } = nextProps.history + const { applicationId } = this.props.match.params + const { submitting, notified } = this.state + + // Close modal when API successfully finished + if (submitting && !notified) { + const succeeded: boolean = isAPISucceeded(saveModelStatus) && saveModelStatus.result + const failed: boolean = (isAPISucceeded(saveModelStatus) && !saveModelStatus.result) || + isAPIFailed(saveModelStatus) + if (succeeded) { + nextProps.addNotification({ color: 'success', message: 'Successfully saved model description' }) + this.setState({ notified: true }) + push(`/applications/${applicationId}`) + } else if (failed) { + nextProps.addNotification({ color: 'error', message: 'Something went wrong. Try again later' }) + this.setState({ notified: true }) + } + } + + } + + componentWillMount() { + const { mode } = this.props + const { modelId, applicationId } = this.props.match.params + + if (mode === 'edit') { + this.props.fetchModelById( + { + id: modelId, + applicationId + } + ) + } + } + + render() { + const { fetchModelByIdStatus, mode } = this.props + const targetStatus = { model: fetchModelByIdStatus } + + if (mode === 'edit') { + return( + + ) + } + return this.renderForm({}) + } + + renderForm(params) { + const { onSubmit, onCancel } = this + + return ( + + ) + } + + /** + * Handle cancel button + * + * Reset form and move to application list page + */ + onCancel() { + const { push } = this.props.history + const { applicationId } = this.props.match.params + push(`applications/${applicationId}`) + } + + onSubmit(parameters): Promise { + const { saveModelDescription, mode } = this.props + const { applicationId, modelId } = this.props.match.params + + const request = { + ...parameters[mode].model, + mode, + id: modelId, + applicationId, + saveDescription: true + } + + this.setState({ submitting: true, notified: false }) + return saveModelDescription(request) + } + +} + +type ModelDescriptionProps = + StateProps & DispatchProps + & RouteComponentProps<{applicationId: string, modelId?: string}> + & CustomProps + +interface ModelDescriptionState { + submitting: boolean + notified: boolean +} + +interface StateProps { + application: APIRequest + saveModelStatus: APIRequest + fetchModelByIdStatus: APIRequest +} + +interface CustomProps { + mode: string +} + +const mapStateToProps = (state: any, extraProps: CustomProps) => ( + { + application: state.fetchApplicationByIdReducer.applicationById, + saveModelStatus: state.saveModelReducer.saveModel, + fetchModelByIdStatus: state.fetchModelByIdReducer.modelById, + ...state.form, + ...extraProps + } +) + +export interface DispatchProps { + saveModelDescription: (params) => Promise + fetchModelById: (params) => Promise + addNotification: (params) => Promise +} + +const mapDispatchToProps = (dispatch): DispatchProps => { + return { + fetchModelById: (params) => fetchModelByIdDispatcher(dispatch, params), + saveModelDescription: (params) => saveModelDispatcher(dispatch, params), + addNotification: (params) => dispatch(addNotification(params)) + } +} + +export default withRouter( + connect & CustomProps>( + mapStateToProps, mapDispatchToProps + )(ModelDescription) +) diff --git a/frontend/src/components/App/Model/index.tsx b/frontend/src/components/App/Model/index.tsx new file mode 100644 index 0000000..a58694f --- /dev/null +++ b/frontend/src/components/App/Model/index.tsx @@ -0,0 +1,138 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { withRouter, RouteComponentProps, Link } from 'react-router-dom' +import { Row, Col } from 'reactstrap' + +import { APIRequest, isAPISucceeded, isAPIFailed } from '@src/apis/Core' +import { Model, Application } from '@src/apis' +import { + fetchApplicationByIdDispatcher, + saveModelDispatcher, + addNotification +} from '@src/actions' +import { APIRequestResultsRenderer } from '@common/APIRequestResultsRenderer' +import ModelDescription from './ModelDescription' + +/** + * Page for adding model + * You can create model ONLY when your application is deployed with Kubernetes. + */ +class SaveModel extends React.Component { + constructor(props, context) { + super(props, context) + + this.renderForm = this.renderForm.bind(this) + this.state = { + submitting: false, + notified: false, + } + } + + componentWillReceiveProps(nextProps: ModelProps) { + const { saveModelStatus } = nextProps + const { push } = nextProps.history + const { applicationId } = this.props.match.params + const { submitting, notified } = this.state + + // Close modal when API successfully finished + if (submitting && !notified) { + const succeeded: boolean = isAPISucceeded(saveModelStatus) && saveModelStatus.result + const failed: boolean = (isAPISucceeded(saveModelStatus) && !saveModelStatus.result) || + isAPIFailed(saveModelStatus) + if (succeeded) { + nextProps.addNotification({ color: 'success', message: 'Successfully saved model' }) + this.setState({ notified: true }) + push(`/applications/${applicationId}`) + } else if (failed) { + nextProps.addNotification({ color: 'error', message: 'Something went wrong. Try again later' }) + this.setState({ notified: true }) + } + } + + } + + componentWillMount() { + const { applicationId } = this.props.match.params + const { fetchApplicationById } = this.props + + fetchApplicationById({id: applicationId}) + } + + render() { + const { application } = this.props + const targetStatus = { application } + + return( + + ) + } + + renderForm(result) { + const { mode } = this.props + + return ( + + +

+ + {mode === 'edit' ? 'Edit' : 'Add'} Model +

+ { mode === 'edit' ? : null } + +
+ ) + } +} + +type ModelProps = + StateProps & DispatchProps + & RouteComponentProps<{applicationId: string, modelId?: string}> + & CustomProps + +interface ModelState { + submitting: boolean + notified: boolean +} + +interface StateProps { + model: APIRequest + application: APIRequest + saveModelStatus: APIRequest +} + +interface CustomProps { + mode: string +} + +const mapStateToProps = (state: any, extraProps: CustomProps) => ( + { + application: state.fetchApplicationByIdReducer.applicationById, + model: state.fetchModelByIdReducer.modelById, + saveModelStatus: state.saveModelReducer.saveModel, + ...state.form, + ...extraProps + } +) + +export interface DispatchProps { + fetchApplicationById: (params) => Promise + saveModel: (params) => Promise + addNotification: (params) => Promise +} + +const mapDispatchToProps = (dispatch): DispatchProps => { + return { + fetchApplicationById: (params) => fetchApplicationByIdDispatcher(dispatch, params), + saveModel: (params) => saveModelDispatcher(dispatch, params), + addNotification: (params) => dispatch(addNotification(params)) + } +} + +export default withRouter( + connect & CustomProps>( + mapStateToProps, mapDispatchToProps + )(SaveModel) +) diff --git a/frontend/src/components/App/Models/ModelsDeleteForm.tsx b/frontend/src/components/App/Models/ModelsDeleteForm.tsx new file mode 100644 index 0000000..80e9ca8 --- /dev/null +++ b/frontend/src/components/App/Models/ModelsDeleteForm.tsx @@ -0,0 +1,190 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { Button } from 'reactstrap' +import { reduxForm, InjectedFormProps } from 'redux-form' + +import { Model } from '@src/apis' +import ModelsStatusTable from './ModelsStatusTable' +import { ControlMode } from './index' + +class ModelsDeleteForm extends React.Component { + constructor(props, context) { + super(props, context) + + this.handleDiscardChanges = this.handleDiscardChanges.bind(this) + } + + componentWillReceiveProps(nextProps: ModelsDeleteFormProps) { + const { mode, pristine, changeMode } = nextProps + + if (mode === ControlMode.VIEW_MODELS_STATUS && !pristine) { + changeMode(ControlMode.SELECT_TARGETS) + } else if (mode === ControlMode.SELECT_TARGETS && pristine) { + changeMode(ControlMode.VIEW_MODELS_STATUS) + } + } + + render() { + const { + onSubmit, + handleSubmit, + } = this.props + + return ( +
+
+ {this.renderDiscardButton()} +
+ +
+ {this.renderSubmitButtons()} + + ) + } + + renderDiscardButton = () => { + const { mode } = this.props + + switch (mode) { + case ControlMode.SELECT_TARGETS: + return ( + + ) + default: + return null + } + } + + /** + * Render submit button(s) + * + * Show delete button if selected targets exist + * Show save button if editing deploy status + */ + renderSubmitButtons(): JSX.Element { + const { + mode, + submitting, + pristine + } = this.props + + const showSubmitButton: boolean = mode !== ControlMode.VIEW_MODELS_STATUS + + if (!showSubmitButton) { + return null + } + + const paramsMap = { + [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Models' }, + } + + // Submit button element(s) + const buttons = (params) => ( +
+ +
+ ) + + const submittingLoader = ( +
+
+ Submitting... +
+ ) + + return submitting ? submittingLoader : buttons(paramsMap[mode]) + } + + renderSubmitButtonElements() { + const { + submitting, + pristine + } = this.props + + const paramsMap = { + [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Models' }, + } + + return ( +
+ +
+ ) + } + + // Handle event methods + + handleDiscardChanges(event): void { + const { changeMode, reset } = this.props + reset() + changeMode(ControlMode.VIEW_MODELS_STATUS) + } +} + +interface ModelsDeleteFormCustomProps { + applicationType: string + applicationId + mode: ControlMode + models: Model[] + onSubmit: (e) => Promise + changeMode: (mode: ControlMode) => void +} + +interface StateProps { + initialValues: { + status + delete + } +} + +const mapStateToProps = (state: any, extraProps: ModelsDeleteFormCustomProps) => { + // Map of model ID to delete flag + const initialDeleteStatus: { [x: string]: boolean } = + extraProps.models + .map((model) => ({[model.id]: false})) + .reduce((l, r) => Object.assign(l, r), {}) + + return { + ...state.form, + initialValues: { + delete: { + models: initialDeleteStatus + } + } + } +} + +const mapDispatchToProps = (dispatch): {} => { + return { } +} + +type ModelsDeleteFormProps + = StateProps & ModelsDeleteFormCustomProps & InjectedFormProps<{}, ModelsDeleteFormCustomProps> + +export default connect(mapStateToProps, mapDispatchToProps)( + reduxForm<{}, ModelsDeleteFormCustomProps>( + { + form: 'deployStatusForm' + } + )(ModelsDeleteForm) +) diff --git a/frontend/src/components/App/Models/ModelsStatusTable.tsx b/frontend/src/components/App/Models/ModelsStatusTable.tsx new file mode 100644 index 0000000..d59fad0 --- /dev/null +++ b/frontend/src/components/App/Models/ModelsStatusTable.tsx @@ -0,0 +1,147 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { CustomInput, Table, Row } from 'reactstrap' +import { Field, InjectedFormProps } from 'redux-form' +import { Link } from 'react-router-dom' + +import { Model } from '@src/apis' +import { ControlMode } from './index' + +/** + * Table for showing models status + */ +class ModelsStatusTable extends React.Component { + constructor(props, context) { + super(props, context) + + this.state = { + tooltipOpen: {} + } + } + + render() { + const { models } = this.props + + return ( + + {this.renderTableHead()} + {this.renderTableBody(models)} +
+ ) + } + + toggleTooltip(tag) { + return () => { + const nextTooltipOpen = { + ...this.state.tooltipOpen, + [tag]: !this.state.tooltipOpen[tag] + } + + this.setState({ + tooltipOpen: nextTooltipOpen + }) + } + } + + /** + * Render head row of the table + */ + renderTableHead = () => { + return ( + + + DescriptionRegister Date + + + ) + } + + /** + * Render body of the table + * + * Render Model names + * Each Model is rendered with a deploy check box on viewing/deleting mode + * @param models Models to be shown (Currently show all, but should be filtered) + */ + renderTableBody = (models) => { + const { applicationType, applicationId } = this.props + + // Button to delete Model (for deleting k8s models) + const deleteCheckButton = (modelName: string, modelId: string) => { + return ( + + { applicationType === 'kubernetes' ? + + : null } + + {modelName} + + + ) + } + + return ( + + {models.map( + (model, index: number) => ( + + + {deleteCheckButton(model.name, model.id)} + + + {model.registeredDate.toUTCString()} + + + ) + )} + + ) + } + +} + +const CustomCheckBox = (props) => { + const { input, id, label } = props + + return ( + + ) +} + +interface ModelsStatusFormCustomProps { + applicationType: string + applicationId + models: Model[], + mode: ControlMode, +} + +export interface DispatchProps { + dispatchChange +} + +const mapDispatchToProps = (dispatch): DispatchProps => { + return { + dispatchChange: (field, value, changeMethod) => dispatch(changeMethod(field, value)) + } +} + +const mapStateToProps = (state: any, extraProps: ModelsStatusFormCustomProps) => { + return {} +} + +type ModelsStatusProps = DispatchProps & ModelsStatusFormCustomProps & InjectedFormProps<{}, ModelsStatusFormCustomProps> + +export default connect(mapStateToProps, mapDispatchToProps)(ModelsStatusTable) diff --git a/frontend/src/components/App/Models/index.tsx b/frontend/src/components/App/Models/index.tsx new file mode 100644 index 0000000..5df871d --- /dev/null +++ b/frontend/src/components/App/Models/index.tsx @@ -0,0 +1,331 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { withRouter, RouteComponentProps } from 'react-router' +import { Button, Modal, ModalBody, ModalHeader, Row, Col } from 'reactstrap' + +import { APIRequest, isAPISucceeded, isAPIFailed } from '@src/apis/Core' +import { Model, Application } from '@src/apis' +import { + addNotification, + fetchApplicationByIdDispatcher, + fetchAllModelsDispatcher, + deleteKubernetesModelsDispatcher, +} from '@src/actions' +import { AddModelFileModal } from '@components/App/Model/Modals/AddModelFileModal' +import ModelsDeleteForm from './ModelsDeleteForm' +import { APIRequestResultsRenderer } from '@common/APIRequestResultsRenderer' + +export enum ControlMode { + VIEW_MODELS_STATUS, + SELECT_TARGETS, + UPLOAD_MODEL +} + +type ModelsStatusProps = DispatchProps & StateProps & RouteComponentProps<{applicationId: string}> + +class Models extends React.Component { + constructor(props, context) { + super(props, context) + + this.state = { + controlMode: ControlMode.VIEW_MODELS_STATUS, + isAddModelFileModalOpen: false, + isDeleteModelsModalOpen: false, + selectedData: { models: [] }, + submitted: false, + syncSubmitted: false, + syncNotified: false + } + + this.onSubmitDelete = this.onSubmitDelete.bind(this) + this.deleteKubernetesModels = this.deleteKubernetesModels.bind(this) + this.toggleDeleteModelsModal = this.toggleDeleteModelsModal.bind(this) + this.toggleAddModelFileModalOpen = this.toggleAddModelFileModalOpen.bind(this) + this.renderModels = this.renderModels.bind(this) + this.changeMode = this.changeMode.bind(this) + this.complete = this.complete.bind(this) + } + + componentWillMount() { + const { applicationId } = this.props.match.params + + this.props.fetchApplicationById(applicationId) + this.props.fetchAllModels(applicationId) + } + + componentWillReceiveProps(nextProps: ModelsStatusProps) { + const { deleteKubernetesModelsStatus } = nextProps + const { controlMode, submitted } = this.state + + const checkAllApiResultStatus = + (result: APIRequest) => + isAPISucceeded(result) && + result.result.reduce((p, c) => (p && c)) + + if (submitted && controlMode === ControlMode.SELECT_TARGETS) { + if (checkAllApiResultStatus(deleteKubernetesModelsStatus)) { + this.complete({ color: 'success', message: 'Successfully changed deletion' }) + } else { + this.complete({ color: 'error', message: 'Something went wrong, try again later' }) + } + } + } + + // Render methods + + render(): JSX.Element { + const { application, models } = this.props + if ( this.props.match.params.applicationId === 'add' ) { + return null + } + return ( + + ) + } + + /** + * Render models status / related form fields + * with fetched API results + * + * @param fetchedResults Fetched data from APIs + */ + renderModels(fetchedResults) { + const { controlMode } = this.state + const { + onSubmitNothing, + onSubmitDelete, + changeMode + } = this + + const { kubernetesId, name } = fetchedResults.application + const { applicationId } = this.props.match.params + + const models: Model[] = fetchedResults.models + const onSubmitMap = { + [ControlMode.VIEW_MODELS_STATUS]: onSubmitNothing, + [ControlMode.SELECT_TARGETS]: onSubmitDelete, + } + + return ( + this.renderContent( + , + name, + kubernetesId + ) + ) + } + + renderContent = (content: JSX.Element, applicationName, kubernetesId): JSX.Element => { + return ( +
+ {this.renderTitle(applicationName, kubernetesId)} + +

+ + Models +

+
+ {content} + { + this.state.controlMode === ControlMode.SELECT_TARGETS + ? this.renderConfirmDeleteHostModal() + : null + } +
+ ) + } + + renderTitle = (applicationName, kubernetesId): JSX.Element => { + return ( + + +

+ + {applicationName} +

+ + + + +
+ ) + } + + renderConfirmDeleteHostModal(): JSX.Element { + const { isDeleteModelsModalOpen } = this.state + + const cancel = () => { + this.toggleDeleteModelsModal() + } + + const executeDeletion = (event) => { + this.deleteKubernetesModels(this.state.selectedData.models) + this.toggleDeleteModelsModal() + } + + return ( + + Delete Models + + Are you sure to delete? + +
+ + +
+
+ ) + } + + // Event handing methods + toggleDeleteModelsModal(): void { + this.setState({ + isDeleteModelsModalOpen: !this.state.isDeleteModelsModalOpen + }) + } + + toggleAddModelFileModalOpen(): void { + this.setState({ + isAddModelFileModalOpen: !this.state.isAddModelFileModalOpen + }) + } + + onSubmitNothing(params): void { + this.setState({}) + } + + /** + * Handle submit and call API to delete models + * Currently only supports to delete k8s models + * + * @param params + */ + onSubmitDelete(params): void { + this.setState({ + isDeleteModelsModalOpen: true, + selectedData: { + models: params.delete.models, + } + }) + } + + deleteKubernetesModels(params): Promise { + const { deleteKubernetesModels } = this.props + const { applicationId } = this.props.match.params + + const apiParams = + Object.entries(params) + .filter(([key, value]) => (value)) + .map( + ([key, value]) => ( + { + applicationId, + modelId: key + })) + + this.setState({ submitted: true }) + + return deleteKubernetesModels(apiParams) + } + + // Utils + changeMode(mode: ControlMode) { + this.setState({ controlMode: mode }) + } + + /** + * Reload models status + * + * Fetch models through API again + */ + complete(param) { + const { + fetchAllModels + } = this.props + const { + applicationId + } = this.props.match.params + + this.props.addNotification(param) + fetchAllModels(applicationId) + this.setState({ + controlMode: ControlMode.VIEW_MODELS_STATUS, + submitted: false, + selectedData: { models: [] } + }) + } +} + +export interface StateProps { + application: APIRequest + models: APIRequest + deleteKubernetesModelsStatus: APIRequest +} + +const mapStateToProps = (state): StateProps => { + const props = { + application: state.fetchApplicationByIdReducer.applicationById, + models: state.fetchAllModelsReducer.models, + deleteKubernetesModelsStatus: state.deleteKubernetesModelsReducer.deleteKubernetesModels, + } + return props +} + +export interface DispatchProps { + addNotification + fetchApplicationById: (id: string) => Promise + fetchAllModels: (applicationId: string) => Promise + deleteKubernetesModels: (params) => Promise +} + +const mapDispatchToProps = (dispatch): DispatchProps => { + return { + addNotification: (params) => dispatch(addNotification(params)), + fetchApplicationById: (id: string) => fetchApplicationByIdDispatcher(dispatch, { id }), + fetchAllModels: (applicationId: string) => fetchAllModelsDispatcher(dispatch, { applicationId }), + deleteKubernetesModels: (params) => deleteKubernetesModelsDispatcher(dispatch, params), + } +} + +export default withRouter( + connect>( + mapStateToProps, mapDispatchToProps + )(Models)) diff --git a/frontend/src/components/App/SaveApplication/ApplicationDeploymentForm.tsx b/frontend/src/components/App/SaveApplication/ApplicationDeploymentForm.tsx index 0ec2161..b464166 100644 --- a/frontend/src/components/App/SaveApplication/ApplicationDeploymentForm.tsx +++ b/frontend/src/components/App/SaveApplication/ApplicationDeploymentForm.tsx @@ -49,6 +49,7 @@ class ApplicationDeploymentFormImpl extends React.Component diff --git a/frontend/src/components/App/Service/ServiceDeployment/ServiceDeploymentForm.tsx b/frontend/src/components/App/Service/ServiceDeployment/ServiceDeploymentForm.tsx index 62a092d..66dd2b4 100644 --- a/frontend/src/components/App/Service/ServiceDeployment/ServiceDeploymentForm.tsx +++ b/frontend/src/components/App/Service/ServiceDeployment/ServiceDeploymentForm.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux' import { reduxForm, InjectedFormProps } from 'redux-form' import { Card, CardBody, Form, Button } from 'reactstrap' -import { KubernetesHost, Service } from '@src/apis' +import { KubernetesHost, Model, Service } from '@src/apis' import DeploymentSettingFormFields, { kubernetesDeploymentDefultSettings } from '@components/misc/Forms/DeploymentSettingFormFields' class ServiceDeploymentFormImpl extends React.Component { @@ -33,7 +33,7 @@ class ServiceDeploymentFormImpl extends React.Component @@ -43,6 +43,7 @@ class ServiceDeploymentFormImpl extends React.Component ) @@ -83,6 +84,7 @@ class ServiceDeploymentFormImpl extends React.Component fetchKubernetesHostStatus: APIRequest fetchServiceByIdStatus: APIRequest + fetchAllModelsStatus: APIRequest } interface CustomProps { @@ -190,6 +195,7 @@ const mapStateToProps = (state: any, extraProps: CustomProps) => ( saveServiceStatus: state.saveServiceReducer.saveService, fetchKubernetesHostStatus: state.fetchKubernetesHostByIdReducer.kubernetesHostById, fetchServiceByIdStatus: state.fetchServiceByIdReducer.serviceById, + fetchAllModelsStatus: state.fetchAllModelsReducer.models, ...state.form, ...extraProps } @@ -199,6 +205,7 @@ export interface DispatchProps { saveServiceDeployment: (params) => Promise fetchKubernetesHostById: (params) => Promise fetchServiceById: (params) => Promise + fetchAllModels: (params) => Promise addNotification: (params) => Promise } @@ -206,6 +213,7 @@ const mapDispatchToProps = (dispatch): DispatchProps => { return { fetchKubernetesHostById: (params) => fetchKubernetesHostByIdDispatcher(dispatch, params), fetchServiceById: (params) => fetchServiceByIdDispatcher(dispatch, params), + fetchAllModels: (params) => fetchAllModelsDispatcher(dispatch, params), saveServiceDeployment: (params) => saveServiceDispatcher(dispatch, params), addNotification: (params) => dispatch(addNotification(params)) } diff --git a/frontend/src/components/App/Services/ServicesDeleteForm.tsx b/frontend/src/components/App/Services/ServicesDeleteForm.tsx new file mode 100644 index 0000000..47fbba6 --- /dev/null +++ b/frontend/src/components/App/Services/ServicesDeleteForm.tsx @@ -0,0 +1,190 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { Button } from 'reactstrap' +import { reduxForm, InjectedFormProps } from 'redux-form' + +import { Service } from '@src/apis' +import ServicesStatusTable from './ServicesStatusTable' +import { ControlMode } from './index' + +class ServicesDeleteForm extends React.Component { + constructor(props, context) { + super(props, context) + + this.handleDiscardChanges = this.handleDiscardChanges.bind(this) + } + + componentWillReceiveProps(nextProps: ServicesDeleteFormProps) { + const { mode, pristine, changeMode } = nextProps + + if (mode === ControlMode.VIEW_SERVICES_STATUS && !pristine) { + changeMode(ControlMode.SELECT_TARGETS) + } else if (mode === ControlMode.SELECT_TARGETS && pristine) { + changeMode(ControlMode.VIEW_SERVICES_STATUS) + } + } + + render() { + const { + onSubmit, + handleSubmit, + } = this.props + + return ( +
+
+ {this.renderDiscardButton()} +
+ +
+ {this.renderSubmitButtons()} + + ) + } + + renderDiscardButton = () => { + const { mode } = this.props + + switch (mode) { + case ControlMode.SELECT_TARGETS: + return ( + + ) + default: + return null + } + } + + /** + * Render submit button(s) + * + * Show delete button if selected targets exist + * Show save button if editing deploy status + */ + renderSubmitButtons(): JSX.Element { + const { + mode, + submitting, + pristine + } = this.props + + const showSubmitButton: boolean = mode !== ControlMode.VIEW_SERVICES_STATUS + + if (!showSubmitButton) { + return null + } + + const paramsMap = { + [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Services' }, + } + + // Submit button element(s) + const buttons = (params) => ( +
+ +
+ ) + + const submittingLoader = ( +
+
+ Submitting... +
+ ) + + return submitting ? submittingLoader : buttons(paramsMap[mode]) + } + + renderSubmitButtonElements() { + const { + submitting, + pristine + } = this.props + + const paramsMap = { + [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Services' }, + } + + return ( +
+ +
+ ) + } + + // Handle event methods + + handleDiscardChanges(event): void { + const { changeMode, reset } = this.props + reset() + changeMode(ControlMode.VIEW_SERVICES_STATUS) + } +} + +interface ServicesDeleteFormCustomProps { + applicationType: string + applicationId + mode: ControlMode + services: Service[] + onSubmit: (e) => Promise + changeMode: (mode: ControlMode) => void +} + +interface StateProps { + initialValues: { + status + delete + } +} + +const mapStateToProps = (state: any, extraProps: ServicesDeleteFormCustomProps) => { + // Map of service ID to delete flag + const initialDeleteStatus: { [x: string]: boolean } = + extraProps.services + .map((service) => ({[service.id]: false})) + .reduce((l, r) => Object.assign(l, r), {}) + + return { + ...state.form, + initialValues: { + delete: { + services: initialDeleteStatus + } + } + } +} + +const mapDispatchToProps = (dispatch): {} => { + return { } +} + +type ServicesDeleteFormProps + = StateProps & ServicesDeleteFormCustomProps & InjectedFormProps<{}, ServicesDeleteFormCustomProps> + +export default connect(mapStateToProps, mapDispatchToProps)( + reduxForm<{}, ServicesDeleteFormCustomProps>( + { + form: 'deployStatusForm' + } + )(ServicesDeleteForm) +) diff --git a/frontend/src/components/App/Services/ServicesStatusTable.tsx b/frontend/src/components/App/Services/ServicesStatusTable.tsx new file mode 100644 index 0000000..145cbe6 --- /dev/null +++ b/frontend/src/components/App/Services/ServicesStatusTable.tsx @@ -0,0 +1,153 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { CustomInput, Table, Row } from 'reactstrap' +import { Field, InjectedFormProps } from 'redux-form' +import { Link } from 'react-router-dom' + +import { Service } from '@src/apis' +import { ControlMode } from './index' + +/** + * Table for showing services status + */ +class ServicesStatusTable extends React.Component { + constructor(props, context) { + super(props, context) + + this.state = { + tooltipOpen: {} + } + } + + render() { + const { services } = this.props + + return ( + + {this.renderTableHead()} + {this.renderTableBody(services)} +
+ ) + } + + toggleTooltip(tag) { + return () => { + const nextTooltipOpen = { + ...this.state.tooltipOpen, + [tag]: !this.state.tooltipOpen[tag] + } + + this.setState({ + tooltipOpen: nextTooltipOpen + }) + } + } + + /** + * Render head row of the table + */ + renderTableHead = () => { + return ( + + + NameService LevelDescriptionHost + + + ) + } + + /** + * Render body of the table + * + * Render Service names + * Each Service is rendered with a deploy check box on viewing/deleting mode + * @param services Services to be shown (Currently show all, but should be filtered) + */ + renderTableBody = (services) => { + const { mode, applicationType, applicationId } = this.props + + // Button to delete Service (for deleting k8s services) + const deleteCheckButton = (serviceName: string, serviceId: string) => { + return ( + + { applicationType === 'kubernetes' ? + + : null } + + {serviceName} + + + ) + } + + return ( + + {services.map( + (service, index: number) => ( + + + {deleteCheckButton(service.name, service.id)} + + + {service.serviceLevel} + + + {service.description} + + + {service.host} + + + ) + )} + + ) + } + +} + +const CustomCheckBox = (props) => { + const { input, id, label } = props + + return ( + + ) +} + +interface ServicesStatusFormCustomProps { + applicationType: string + applicationId + services: Service[], + mode: ControlMode, +} + +export interface DispatchProps { + dispatchChange +} + +const mapDispatchToProps = (dispatch): DispatchProps => { + return { + dispatchChange: (field, value, changeMethod) => dispatch(changeMethod(field, value)) + } +} + +const mapStateToProps = (state: any, extraProps: ServicesStatusFormCustomProps) => { + return {} +} + +type ServicesStatusProps = DispatchProps & ServicesStatusFormCustomProps & InjectedFormProps<{}, ServicesStatusFormCustomProps> + +export default connect(mapStateToProps, mapDispatchToProps)(ServicesStatusTable) diff --git a/frontend/src/components/App/Services/index.tsx b/frontend/src/components/App/Services/index.tsx new file mode 100644 index 0000000..0335c55 --- /dev/null +++ b/frontend/src/components/App/Services/index.tsx @@ -0,0 +1,376 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { withRouter, RouteComponentProps } from 'react-router' +import { Button, Modal, ModalBody, ModalHeader, Row, Col } from 'reactstrap' + +import { APIRequest, isAPISucceeded, isAPIFailed } from '@src/apis/Core' +import { Service, SynKubernetesStatusParam, Application } from '@src/apis' +import { + addNotification, + fetchApplicationByIdDispatcher, + fetchAllServicesDispatcher, + deleteKubernetesServicesDispatcher, + syncKubernetesStatusDispatcher +} from '@src/actions' +import ServicesDeleteForm from './ServicesDeleteForm' +import { APIRequestResultsRenderer } from '@common/APIRequestResultsRenderer' + +export enum ControlMode { + VIEW_SERVICES_STATUS, + SELECT_TARGETS, +} + +type ServicesStatusProps = DispatchProps & StateProps & RouteComponentProps<{applicationId: string}> + +class Services extends React.Component { + constructor(props, context) { + super(props, context) + + this.state = { + controlMode: ControlMode.VIEW_SERVICES_STATUS, + isDeleteServicesModalOpen: false, + selectedData: { services: [] }, + submitted: false, + syncSubmitted: false, + syncNotified: false + } + + this.onSubmitDelete = this.onSubmitDelete.bind(this) + this.deleteKubernetesServices = this.deleteKubernetesServices.bind(this) + this.toggleDeleteServicesModal = this.toggleDeleteServicesModal.bind(this) + this.syncServices = this.syncServices.bind(this) + this.renderServices = this.renderServices.bind(this) + this.changeMode = this.changeMode.bind(this) + this.complete = this.complete.bind(this) + } + + componentWillMount() { + const { applicationId } = this.props.match.params + + this.props.fetchApplicationById(applicationId) + this.props.fetchAllServices(applicationId) + } + + componentWillReceiveProps(nextProps: ServicesStatusProps) { + const { + deleteKubernetesServicesStatus, + syncKubernetesServicesStatusStatus + } = nextProps + const { controlMode, submitted } = this.state + + const checkAllApiResultStatus = + (result: APIRequest) => + isAPISucceeded(result) && + result.result.reduce((p, c) => (p && c)) + + if (submitted && controlMode === ControlMode.SELECT_TARGETS) { + if (checkAllApiResultStatus(deleteKubernetesServicesStatus)) { + this.complete({ color: 'success', message: 'Successfully changed deletion' }) + } else { + this.complete({ color: 'error', message: 'Something went wrong, try again later' }) + } + } + + this.checkAndNotifyAPIResult( + syncKubernetesServicesStatusStatus, + 'syncSubmitted', 'syncNotified', + 'Successfully synced application' + ) + } + + checkAndNotifyAPIResult(status, submitted: string, notified: string, notificationText) { + const submittedFlag: boolean = this.state[submitted] + const notifiedFlag: boolean = this.state[notified] + + if (submittedFlag && !notifiedFlag) { + const succeeded: boolean = isAPISucceeded(status) && status.result + const failed: boolean = (isAPISucceeded(status) && !status.result) || + isAPIFailed(status) + + if (succeeded) { + this.setState({[submitted]: false, [notified]: true}) + this.complete({ color: 'success', message: notificationText }) + } else if (failed) { + this.setState({[submitted]: false, [notified]: true}) + this.complete({ color: 'error', message: 'Something went wrong. Try again later' }) + } + } + } + + // Render methods + + render(): JSX.Element { + const { application, services } = this.props + if ( this.props.match.params.applicationId === 'add' ) { + return null + } + return ( + + ) + } + + /** + * Render services status / related form fields + * with fetched API results + * + * @param fetchedResults Fetched data from APIs + */ + renderServices(fetchedResults) { + const { controlMode } = this.state + const { + onSubmitNothing, + onSubmitDelete, + changeMode + } = this + + const { kubernetesId, name } = fetchedResults.application + const { applicationId } = this.props.match.params + + const services: Service[] = fetchedResults.services + const onSubmitMap = { + [ControlMode.VIEW_SERVICES_STATUS]: onSubmitNothing, + [ControlMode.SELECT_TARGETS]: onSubmitDelete, + } + + return ( + this.renderContent( + , + name, + kubernetesId + ) + ) + } + + renderContent = (content: JSX.Element, applicationName, kubernetesId): JSX.Element => { + return ( +
+ {this.renderTitle(applicationName, kubernetesId)} +

+ + Services +

+
+ {content} + { + this.state.controlMode === ControlMode.SELECT_TARGETS + ? this.renderConfirmDeleteHostModal() + : null + } +
+ ) + } + + renderTitle = (applicationName, kubernetesId): JSX.Element => { + return ( + + +

+ + {applicationName} +

+ + + {kubernetesId ? this.renderKubernetesControlButtons(kubernetesId) : null} + +
+ ) + } + + renderKubernetesControlButtons(kubernetesId) { + const { push } = this.props.history + const { syncServices } = this + const { applicationId } = this.props.match.params + + return ( + + + {` `} + + + ) + } + + renderConfirmDeleteHostModal(): JSX.Element { + const { isDeleteServicesModalOpen } = this.state + + const cancel = () => { + this.toggleDeleteServicesModal() + } + + const executeDeletion = (event) => { + this.deleteKubernetesServices(this.state.selectedData.services) + this.toggleDeleteServicesModal() + } + + return ( + + Delete Services + + Are you sure to delete? + +
+ + +
+
+ ) + } + + syncServices(kubernetesId): void { + const { applicationId } = this.props.match.params + + this.setState({ syncSubmitted: true, syncNotified: false }) + this.props.syncKubernetesServicesStatus({applicationId, kubernetesId}) + } + + // Event handing methods + toggleDeleteServicesModal(): void { + this.setState({ + isDeleteServicesModalOpen: !this.state.isDeleteServicesModalOpen + }) + } + + onSubmitNothing(params): void { + this.setState({}) + } + + /** + * Handle submit and call API to delete services + * Currently only supports to delete k8s services + * + * @param params + */ + onSubmitDelete(params): void { + this.setState({ + isDeleteServicesModalOpen: true, + selectedData: { + services: params.delete.services, + } + }) + } + + deleteKubernetesServices(params): Promise { + const { deleteKubernetesServices } = this.props + const { applicationId } = this.props.match.params + + const apiParams = + Object.entries(params) + .filter(([key, value]) => (value)) + .map( + ([key, value]) => ( + { + applicationId, + serviceId: key + })) + + this.setState({ submitted: true }) + + return deleteKubernetesServices(apiParams) + } + + // Utils + changeMode(mode: ControlMode) { + this.setState({ controlMode: mode }) + } + + /** + * Reload services status + * + * Fetch services through API again + */ + complete(param) { + const { + fetchAllServices + } = this.props + const { + applicationId + } = this.props.match.params + + this.props.addNotification(param) + fetchAllServices(applicationId) + this.setState({ + controlMode: ControlMode.VIEW_SERVICES_STATUS, + submitted: false, + selectedData: { services: [] } + }) + } +} + +export interface StateProps { + application: APIRequest + services: APIRequest + deleteKubernetesServicesStatus: APIRequest + syncKubernetesServicesStatusStatus: APIRequest +} + +const mapStateToProps = (state): StateProps => { + const props = { + application: state.fetchApplicationByIdReducer.applicationById, + services: state.fetchAllServicesReducer.services, + deleteKubernetesServicesStatus: state.deleteKubernetesServicesReducer.deleteKubernetesServices, + syncKubernetesServicesStatusStatus: state.syncKubernetesStatusReducer.syncKubernetesStatus + } + return props +} + +export interface DispatchProps { + addNotification + syncKubernetesServicesStatus + fetchApplicationById: (id: string) => Promise + fetchAllServices: (applicationId: string) => Promise + deleteKubernetesServices: (params) => Promise +} + +const mapDispatchToProps = (dispatch): DispatchProps => { + return { + addNotification: (params) => dispatch(addNotification(params)), + fetchApplicationById: (id: string) => fetchApplicationByIdDispatcher(dispatch, { id }), + fetchAllServices: (applicationId: string) => fetchAllServicesDispatcher(dispatch, { applicationId }), + deleteKubernetesServices: (params) => deleteKubernetesServicesDispatcher(dispatch, params), + syncKubernetesServicesStatus: (params: SynKubernetesStatusParam) => syncKubernetesStatusDispatcher(dispatch, params), + } +} + +export default withRouter( + connect>( + mapStateToProps, mapDispatchToProps + )(Services)) diff --git a/frontend/src/components/App/index.tsx b/frontend/src/components/App/index.tsx index 7dbeb28..c6c5245 100644 --- a/frontend/src/components/App/index.tsx +++ b/frontend/src/components/App/index.tsx @@ -8,7 +8,10 @@ import Applications from './Applications' import SaveApplication from './SaveApplication' import Application from './Application' import Deploy from './Deploy' +import Services from './Services' +import Models from './Models' import Service from './Service' +import Model from './Model' import Settings from './Settings' import Login from './Login' import * as Kubernetes from './Kubernetes' @@ -78,10 +81,14 @@ const ApplicationRoute = () => ( + + } /> } /> + } /> diff --git a/frontend/src/components/misc/Forms/DeploymentSettingFormFields/index.tsx b/frontend/src/components/misc/Forms/DeploymentSettingFormFields/index.tsx index 916e78c..fb86fa8 100644 --- a/frontend/src/components/misc/Forms/DeploymentSettingFormFields/index.tsx +++ b/frontend/src/components/misc/Forms/DeploymentSettingFormFields/index.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux' import { Field } from 'redux-form' import { CardTitle, UncontrolledTooltip } from 'reactstrap' -import { KubernetesHost } from '@src/apis' +import { KubernetesHost, Model } from '@src/apis' import { SingleFormField } from '@common/Field/SingleFormField' import { required, applicationNameFormat } from '@common/Field/Validateors' @@ -118,12 +118,16 @@ class DeploymentSettingFormFields extends React.Component { renderBasicKubernetesConfigFields(): JSX.Element { const { environmentsToName } = REACT_APP_CONFIG - const { formNamePrefix, resource } = this.props + const { formNamePrefix, resource, mode } = this.props const serviceLevels = Object.keys(environmentsToName).map( (env) => ({ label: environmentsToName[env], value: env })) + const serviceModelAssignments = + this.props.models.map( + (model: Model) => ({ label: model.name, value: model.id.toString() })) + const serviceLevelSelectBox = ( { /> ) + const modelAssignmentSelectBox = ( + + ) + return ( {serviceLevelSelectBox} @@ -210,6 +225,7 @@ class DeploymentSettingFormFields extends React.Component { {serviceGitUrlField} {serviceGitBranchField} {serviceBootScriptField} + {resource === 'service' && mode === 'add' ? modelAssignmentSelectBox : null} {this.renderResourceConfigFields()} ) @@ -456,6 +472,7 @@ interface CustomProps { resource: string onChangeApplicationType? mode + models: Model[] } type FormProps = diff --git a/frontend/src/reducers/index.tsx b/frontend/src/reducers/index.tsx index b8f3efd..32978bc 100644 --- a/frontend/src/reducers/index.tsx +++ b/frontend/src/reducers/index.tsx @@ -6,6 +6,7 @@ import { saveKubernetesHostActionCreators, addApplicationActionCreators, saveServiceActionCreators, + saveModelActionCreators, fetchAllKubernetesHostsActionCreators, fetchAllApplicationsActionCreators, fetchApplicationByIdActionCreators, @@ -15,8 +16,10 @@ import { fetchKubernetesHostByIdActionCreators, deleteKubernetesHostActionCreators, deleteKubernetesServicesActionCreators, + deleteKubernetesModelsActionCreators, syncKubernetesStatusActionCreators, fetchServiceByIdActionCreators, + fetchModelByIdActionCreators, fetchServiceDescriptionsActionCreators, APIRequestUnauthorized, loginActionCreators, @@ -24,7 +27,11 @@ import { settingsActionCreators } from '@src/actions' import { APIRequest, APIRequestStatusList} from '@src/apis/Core' -import { Application, Model, Service, SwitchModelParam, ModelResponse, KubernetesHost, FetchServicesParam, LoginParam, AuthToken, UserInfo } from '@src/apis' +import { + Application, Model, Service, SwitchModelParam, + ModelResponse, KubernetesHost, FetchServicesParam, + FetchModelsParam, LoginParam, AuthToken, UserInfo +} from '@src/apis' export class AppState { constructor( @@ -34,15 +41,18 @@ export class AppState { public saveKubernetesHost: APIRequest = { status: APIRequestStatusList.notStarted }, public addApplication: APIRequest = { status: APIRequestStatusList.notStarted }, public saveService: APIRequest = { status: APIRequestStatusList.notStarted }, + public saveModel: APIRequest = { status: APIRequestStatusList.notStarted }, public applications: APIRequest = { status: APIRequestStatusList.notStarted }, public applicationById: APIRequest = { status: APIRequestStatusList.notStarted }, public services: APIRequest = { status: APIRequestStatusList.notStarted }, public serviceDescriptions: APIRequest = { status: APIRequestStatusList.notStarted }, + public modelById: APIRequest = { status: APIRequestStatusList.notStarted }, public serviceById: APIRequest = { status: APIRequestStatusList.notStarted }, public models: APIRequest = { status: APIRequestStatusList.notStarted }, public kubernetesHosts: APIRequest = { status: APIRequestStatusList.notStarted }, public kubernetesHostById: APIRequest = { status: APIRequestStatusList.notStarted }, public deleteKubernetesServices: APIRequest = { status: APIRequestStatusList.notStarted }, + public deleteKubernetesModels: APIRequest = { status: APIRequestStatusList.notStarted }, public syncKubernetesStatus: APIRequest = { status: APIRequestStatusList.notStarted }, public settings: APIRequest<{}> = { status: APIRequestStatusList.notStarted }, public login: APIRequest = { status: APIRequestStatusList.notStarted }, @@ -93,6 +103,7 @@ export const uploadModelReducer = APIRequestReducerCreator<{}, ModelResponse>(up export const switchModelsReducer = APIRequestReducerCreator< SwitchModelParam[], boolean[] >(switchModelsActionCreators, 'switchModels') export const addApplicationReducer = APIRequestReducerCreator(addApplicationActionCreators, 'addApplication') export const saveServiceReducer = APIRequestReducerCreator(saveServiceActionCreators, 'saveService') +export const saveModelReducer = APIRequestReducerCreator(saveModelActionCreators, 'saveModel') export const saveKubernetesHostReducer = APIRequestReducerCreator(saveKubernetesHostActionCreators, 'saveKubernetesHost') export const fetchAllKubernetesHostsReducer @@ -103,12 +114,15 @@ export const fetchKubernetesHostByIdReducer = APIRequestReducerCreator<{}, any>( export const fetchAllModelsReducer = APIRequestReducerCreator<{}, Model[]>(fetchAllModelsActionCreators, 'models') export const fetchAllServicesReducer = APIRequestReducerCreator<{}, Service[]>(fetchAllServicesActionCreators, 'services') export const fetchServiceByIdReducer = APIRequestReducerCreator(fetchServiceByIdActionCreators, 'serviceById') +export const fetchModelByIdReducer = APIRequestReducerCreator(fetchModelByIdActionCreators, 'modelById') export const fetchServiceDescriptionsReducer = APIRequestReducerCreator(fetchServiceDescriptionsActionCreators, 'serviceDescriptions') export const deleteKubernetesHostReducer = APIRequestReducerCreator<{id: number}, boolean>(deleteKubernetesHostActionCreators, 'deleteKubernetesHost') export const deleteKubernetesServicesReducer = APIRequestReducerCreator(deleteKubernetesServicesActionCreators, 'deleteKubernetesServices') +export const deleteKubernetesModelsReducer + = APIRequestReducerCreator(deleteKubernetesModelsActionCreators, 'deleteKubernetesModels') export const syncKubernetesStatusReducer = APIRequestReducerCreator(syncKubernetesStatusActionCreators, 'syncKubernetesStatus') export const settingsReducer = APIRequestReducerCreator<{}, any>(settingsActionCreators, 'settings')