From 8c93af33daaaf600ce9f1f0b2f1ca11208eee394 Mon Sep 17 00:00:00 2001 From: Li Xiaoguang Date: Wed, 1 Feb 2023 15:23:18 +0800 Subject: [PATCH 01/15] [WebConsole] Sync to github manually --- web_console_v2/.dockerignore | 2 +- web_console_v2/Dockerfile | 25 +- web_console_v2/Makefile | 8 - web_console_v2/api/.gitignore | 3 +- web_console_v2/api/Makefile | 36 +- web_console_v2/api/README.md | 115 +- web_console_v2/api/ci/pylintrc | 434 - web_console_v2/api/command.py | 49 +- web_console_v2/api/config.py | 12 +- web_console_v2/api/docs/best_practices.md | 57 + web_console_v2/api/envs.py | 168 +- web_console_v2/api/es_configuration.py | 94 +- .../api/fedlearner_webconsole/__init__.py | 17 - .../api/fedlearner_webconsole/app.py | 200 +- .../api/fedlearner_webconsole/auth/apis.py | 488 +- .../api/fedlearner_webconsole/auth/models.py | 44 +- .../composer/composer.py | 411 +- .../composer/interface.py | 53 +- .../fedlearner_webconsole/composer/models.py | 202 +- .../composer/op_locker.py | 24 +- .../fedlearner_webconsole/composer/runner.py | 93 +- .../composer/runner_cache.py | 68 - .../composer/thread_reaper.py | 51 +- .../api/fedlearner_webconsole/dataset/apis.py | 2360 ++- .../dataset/data_pipeline.py | 185 - .../dataset/import_handler.py | 143 - .../fedlearner_webconsole/dataset/models.py | 845 +- .../fedlearner_webconsole/dataset/services.py | 775 +- .../dataset/sparkapp/pipeline/analyzer.py | 92 - .../dataset/sparkapp/pipeline/converter.py | 56 - .../dataset/sparkapp/pipeline/transformer.py | 50 - .../dataset/sparkapp/pipeline/util.py | 36 - .../api/fedlearner_webconsole/db.py | 112 +- .../fedlearner_webconsole/debug/__init__.py | 2 +- .../api/fedlearner_webconsole/debug/apis.py | 210 +- .../api/fedlearner_webconsole/exceptions.py | 39 +- .../api/fedlearner_webconsole/initial_db.py | 253 +- .../api/fedlearner_webconsole/job/apis.py | 674 +- .../api/fedlearner_webconsole/job/metrics.py | 225 +- .../api/fedlearner_webconsole/job/models.py | 265 +- .../api/fedlearner_webconsole/job/service.py | 135 +- .../job/yaml_formatter.py | 156 +- .../api/fedlearner_webconsole/k8s/models.py | 482 +- .../api/fedlearner_webconsole/mmgr/apis.py | 116 - .../api/fedlearner_webconsole/mmgr/models.py | 666 +- .../api/fedlearner_webconsole/mmgr/service.py | 570 +- .../fedlearner_webconsole/project/add_on.py | 342 - .../api/fedlearner_webconsole/project/apis.py | 712 +- .../fedlearner_webconsole/project/models.py | 230 +- .../fedlearner_webconsole/proto/__init__.py | 0 .../api/fedlearner_webconsole/rpc/client.py | 366 +- .../api/fedlearner_webconsole/rpc/server.py | 774 +- .../scheduler/scheduler.py | 149 +- .../scheduler/transaction.py | 107 +- .../fedlearner_webconsole/setting/__init__.py | 0 .../api/fedlearner_webconsole/setting/apis.py | 366 +- .../fedlearner_webconsole/setting/models.py | 26 +- .../fedlearner_webconsole/sparkapp/apis.py | 165 +- .../fedlearner_webconsole/sparkapp/schema.py | 345 +- .../fedlearner_webconsole/sparkapp/service.py | 116 +- .../api/fedlearner_webconsole/utils/base64.py | 24 - .../utils/certificate.py | 56 - .../fedlearner_webconsole/utils/decorators.py | 76 - .../api/fedlearner_webconsole/utils/es.py | 122 +- .../fedlearner_webconsole/utils/es_misc.py | 88 +- .../utils/fake_k8s_client.py | 263 - .../utils/file_manager.py | 229 +- .../api/fedlearner_webconsole/utils/hooks.py | 31 +- .../fedlearner_webconsole/utils/k8s_cache.py | 111 - .../fedlearner_webconsole/utils/k8s_client.py | 389 - .../utils/k8s_watcher.py | 182 - .../api/fedlearner_webconsole/utils/kibana.py | 320 +- .../fedlearner_webconsole/utils/metrics.py | 157 +- .../utils/middlewares.py | 36 - .../api/fedlearner_webconsole/utils/mixins.py | 74 +- .../utils/system_envs.py | 197 +- .../api/fedlearner_webconsole/utils/tars.py | 25 - .../fedlearner_webconsole/workflow/apis.py | 1059 +- .../fedlearner_webconsole/workflow/cronjob.py | 104 +- .../fedlearner_webconsole/workflow/models.py | 608 +- .../workflow_template/apis.py | 751 +- .../workflow_template/models.py | 138 +- .../workflow_template/slots_formatter.py | 101 +- .../workflow_template/template_validaor.py | 144 +- web_console_v2/api/gunicorn_config.py | 25 +- web_console_v2/api/logging_config.py | 81 +- web_console_v2/api/migrations/README | 2 +- web_console_v2/api/migrations/env.py | 38 +- .../5a580877595d_user_add_role_and_state.py | 16 +- ...90c1bf67a_add_completed_failed_jobstate.py | 8 +- .../versions/b3512a6ce912_initial_comment.py | 16 +- .../fedlearner_webconsole/proto/common.proto | 64 +- .../fedlearner_webconsole/proto/dataset.proto | 368 +- .../fedlearner_webconsole/proto/project.proto | 112 +- .../fedlearner_webconsole/proto/service.proto | 190 +- .../proto/workflow_definition.proto | 27 +- web_console_v2/api/requirements.txt | 38 +- web_console_v2/api/run_coverage.sh | 2 +- web_console_v2/api/run_dev.sh | 25 - web_console_v2/api/run_prod.sh | 42 - web_console_v2/api/server.py | 16 +- web_console_v2/api/test/__init__.py | 15 - web_console_v2/api/test/auth_test.py | 166 - .../test/fedlearner_webconsole/app_test.py | 30 - .../fedlearner_webconsole/composer/common.py | 129 - .../composer/composer_test.py | 225 - .../composer/op_locker_test.py | 52 - .../composer/runner_cache_test.py | 54 - .../composer/thread_reaper_test.py | 73 - .../dataset/apis_test.py | 349 - .../api/test/fedlearner_webconsole/db_test.py | 66 - .../fedlearner_webconsole/exceptions_test.py | 57 - .../fedlearner_webconsole/job/metrics_test.py | 105 - .../fedlearner_webconsole/job/service_test.py | 112 - .../job/yaml_formatter_test.py | 152 - .../fedlearner_webconsole/k8s/models_test.py | 203 - .../fedlearner_webconsole/mmgr/model_test.py | 163 - .../project/add_on_test.py | 39 - .../project/apis_test.py | 183 - .../project/models_test.py | 52 - .../fedlearner_webconsole/project/test.tar.gz | Bin 384 -> 0 bytes .../fedlearner_webconsole/rpc/client_test.py | 146 - .../scheduler/scheduler_func_test.py | 39 - .../scheduler/scheduler_test.py | 240 - .../scheduler/workflow_commit_test.py | 139 - .../scheduler/workflow_template_test.py | 738 - .../setting/apis_test.py | 135 - .../sparkapp/__init__.py | 15 - .../sparkapp/apis_test.py | 97 - .../sparkapp/schema_test.py | 153 - .../sparkapp/service_test.py | 296 - .../test_data/code.tar.gz | Bin 176 -> 0 bytes .../test_data/dataset_metainfo/_FEATURES | 1 - .../test_data/dataset_metainfo/_HIST | 1 - .../test_data/dataset_metainfo/_META | 1 - .../test_data/sparkapp.tar | Bin 20480 -> 0 bytes .../test_data/workflow_config.json | 58 - .../test_data/workflow_config_right.json | 58 - .../utils/base64_test.py | 37 - .../utils/decorators_test.py | 77 - .../utils/file_manager_test.py | 242 - .../utils/k8s_client_test.py | 89 - .../utils/kibana_test.py | 37 - .../utils/metrics_test.py | 80 - .../utils/mixins_test.py | 66 - .../utils/system_envs_test.py | 70 - .../workflow/apis_test.py | 474 - .../workflow/cronjob_test.py | 70 - .../workflow_template/apis_test.py | 241 - .../workflow_template/slots_formater_test.py | 38 - .../template_validator_test.py | 55 - .../workflow_template/test_template_left.py | 1332 -- .../workflow_template/test_template_right.py | 1352 -- web_console_v2/api/testing/__init__.py | 2 +- web_console_v2/api/testing/common.py | 169 +- .../api/testing/fake_file_manager.py | 8 +- .../api/testing/workflow_template/__init__.py | 2 +- .../psi_join_tree_model_no_label.py | 728 - .../psi_join_tree_model_with_label.py | 748 - .../api/tools/local_runner/app_a.py | 48 - .../api/tools/local_runner/app_b.py | 48 - .../api/tools/local_runner/initial_db.py | 64 - .../api/tools/local_runner/run_a.sh | 11 - .../api/tools/local_runner/run_b.sh | 11 - web_console_v2/client/.eslintignore | 6 + web_console_v2/client/.eslintrc.js | 7 + web_console_v2/client/.npmrc | 2 +- web_console_v2/client/config/env.js | 3 + .../client/config/webpack.config.js | 68 +- .../client/config/webpackDevServer.config.js | 9 + web_console_v2/client/docs/KNOWN_ISSUES.md | 7 - web_console_v2/client/jest.config.js | 24 +- web_console_v2/client/package.json | 55 +- web_console_v2/client/pnpm-lock.yaml | 13832 ++++++++-------- web_console_v2/client/public/fed-favicon.ico | Bin 2974 -> 0 bytes web_console_v2/client/public/index.html | 7 +- web_console_v2/client/scripts/build.js | 13 + web_console_v2/client/scripts/lessVarsToJs.js | 8 +- .../client/scripts/lessVarsTransform.js | 2 +- web_console_v2/client/scripts/start.js | 5 +- web_console_v2/client/src/App.tsx | 81 +- .../client/src/assets/icons/python.svg | 21 +- .../src/assets/icons/workflow-completed.svg | 6 +- .../src/assets/icons/workflow-error.svg | 5 +- .../src/assets/icons/workflow-pending.svg | 6 +- .../src/assets/icons/workflow-warning.svg | 5 +- .../client/src/assets/images/avatar.jpg | Bin 20674 -> 8940 bytes .../client/src/assets/images/close-icon.svg | 4 +- .../client/src/assets/images/empty.svg | 2 +- .../client/src/assets/images/file.svg | 10 +- .../client/src/assets/images/get-metrics.svg | 2 +- .../client/src/assets/images/hacker-codes.jpg | Bin 241601 -> 103923 bytes .../src/assets/images/login-illustration.png | Bin 186771 -> 44233 bytes .../src/assets/images/logo-colorful.svg | 21 - .../client/src/assets/images/logo-white.svg | 12 - .../client/src/assets/images/logo.svg | 21 - .../client/src/assets/images/no-result.svg | 15 +- .../src/assets/images/project-action.svg | 4 +- .../client/src/assets/images/settings.svg | 2 +- .../src/components/BackButton/index.tsx | 40 +- .../src/components/BreadcrumbLink/Slash.tsx | 4 +- .../src/components/BreadcrumbLink/index.tsx | 6 +- .../src/components/ClickToCopy/index.tsx | 47 +- .../src/components/CodeEditor/index.tsx | 104 +- .../client/src/components/CountTime/index.tsx | 78 +- .../src/components/DatasetSelect/index.tsx | 365 +- .../client/src/components/Footer/index.tsx | 14 - .../client/src/components/FormLabel/index.tsx | 11 +- .../client/src/components/Header/Account.tsx | 86 +- .../src/components/Header/LanguageSwitch.tsx | 15 +- .../src/components/Header/ProjectSelect.tsx | 98 - .../client/src/components/Header/index.tsx | 52 +- .../src/components/IconButton/index.tsx | 6 +- .../src/components/IconPark/icons/Log.tsx | 4 +- .../src/components/IconPark/icons/Struct.tsx | 4 +- .../client/src/components/IconPark/index.ts | 33 + .../ModelCodesEditorButton/FileExplorer.tsx | 49 +- .../ModelCodesEditorButton/index.tsx | 107 +- .../client/src/components/NoResult/index.tsx | 97 +- .../src/components/PrettyMenu/index.tsx | 8 +- .../client/src/components/PrintLogs/index.tsx | 39 +- .../src/components/PropertyList/index.tsx | 220 +- .../client/src/components/ReadFile/index.tsx | 152 +- .../src/components/SharedPageLayout/index.tsx | 283 +- .../client/src/components/Sidebar/index.tsx | 496 +- .../src/components/StateIndicator/index.tsx | 170 +- .../src/components/UserRoleBadge/index.tsx | 14 +- .../client/src/components/Username/index.tsx | 5 +- .../src/components/VariableLabel/index.tsx | 18 +- .../components/VariableSchemaForm/index.tsx | 67 - .../components/VariblePermission/index.tsx | 54 +- .../src/components/WhichProject/index.tsx | 19 +- .../JobNodes/ConfigNode.tsx | 4 +- .../JobNodes/DisabledSwitch.tsx | 4 +- .../JobNodes/EditConfigNode.tsx | 19 +- .../JobNodes/ExecutionNode.tsx | 13 +- .../WorkflowJobsCanvas/JobNodes/ForkNode.tsx | 21 +- .../JobNodes/GlobalConfigNode.tsx | 4 + .../WorkflowJobsCanvas/JobNodes/elements.ts | 4 +- .../WorkflowJobsCanvas/JobNodes/shared.ts | 6 +- .../components/WorkflowJobsCanvas/helpers.ts | 4 +- .../components/WorkflowJobsCanvas/hooks.ts | 2 +- .../components/WorkflowJobsCanvas/index.tsx | 7 +- .../components/WorkflowJobsCanvas/types.ts | 1 + .../YAMLTemplateEditorButton/index.tsx | 57 +- .../src/components/_base/BlockRadio/index.tsx | 263 +- .../src/components/_base/GridRow/index.tsx | 27 +- .../_base/MockDevtools/MockControlPanel.tsx | 151 - .../components/_base/MockDevtools/index.js | 7 - .../components/_base/MockDevtools/utils.ts | 45 - web_console_v2/client/src/hooks/dataset.ts | 12 +- web_console_v2/client/src/hooks/index.ts | 368 +- web_console_v2/client/src/hooks/project.ts | 21 +- web_console_v2/client/src/i18n/index.ts | 1 - .../client/src/i18n/resources/en.ts | 146 + .../client/src/i18n/resources/modules/app.ts | 10 +- .../src/i18n/resources/modules/dataset.ts | 374 +- .../src/i18n/resources/modules/error.ts | 24 + .../src/i18n/resources/modules/login.ts | 24 + .../client/src/i18n/resources/modules/menu.ts | 37 +- .../src/i18n/resources/modules/project.ts | 35 +- .../src/i18n/resources/modules/settings.ts | 7 +- .../client/src/i18n/resources/modules/term.ts | 2 +- .../src/i18n/resources/modules/upload.ts | 4 + .../src/i18n/resources/modules/users.ts | 9 +- .../src/i18n/resources/modules/workflow.ts | 89 +- .../client/src/i18n/resources/zh_CN.ts | 155 + web_console_v2/client/src/index.tsx | 23 +- web_console_v2/client/src/libs/mockAdapter.ts | 11 +- web_console_v2/client/src/libs/request.ts | 144 +- web_console_v2/client/src/services/dataset.ts | 312 +- .../src/services/mocks/v2/auth/signin.ts | 14 - .../src/services/mocks/v2/auth/signout.ts | 4 - .../src/services/mocks/v2/auth/users/:id.ts | 11 - .../services/mocks/v2/datasets/:id/batches.ts | 6 - .../services/mocks/v2/datasets/:id/index.ts | 17 - .../services/mocks/v2/datasets/examples.ts | 516 +- .../src/services/mocks/v2/files/index.ts | 15 + .../src/services/mocks/v2/jobs/:id/events.ts | 15 - .../src/services/mocks/v2/jobs/:id/log.ts | 15 - .../src/services/mocks/v2/jobs/:id/metrics.ts | 982 -- .../v2/projects/:id/connection_checks.ts | 6 - .../services/mocks/v2/projects/:id/index.ts | 36 - .../src/services/mocks/v2/projects/index.ts | 41 - .../src/services/mocks/v2/settings/index.ts | 13 +- .../services/mocks/v2/variables/examples.ts | 179 +- .../mocks/v2/workflow_templates/:id/index.ts | 17 - .../mocks/v2/workflow_templates/examples.ts | 128 +- .../mocks/v2/workflow_templates/index.ts | 77 +- .../services/mocks/v2/workflows/:id/index.ts | 27 - .../mocks/v2/workflows/:id/peer_workflows.ts | 19 - .../services/mocks/v2/workflows/examples.ts | 33 +- web_console_v2/client/src/services/project.ts | 41 +- .../client/src/services/settings.ts | 27 +- web_console_v2/client/src/services/system.ts | 6 + web_console_v2/client/src/services/user.ts | 23 +- .../client/src/services/workflow.ts | 172 +- .../client/src/shared/base64.test.ts | 13 + .../client/src/shared/dataset.test.ts | 197 +- web_console_v2/client/src/shared/dataset.ts | 195 +- web_console_v2/client/src/shared/date.test.ts | 28 +- web_console_v2/client/src/shared/date.ts | 2 +- web_console_v2/client/src/shared/file.ts | 98 +- .../client/src/shared/formSchema.test.ts | 320 +- .../client/src/shared/formSchema.tsx | 442 +- web_console_v2/client/src/shared/helpers.ts | 132 - .../client/src/shared/localStorageKeys.ts | 7 + .../client/src/shared/object.test.ts | 47 + web_console_v2/client/src/shared/object.ts | 9 +- .../client/src/shared/queryClient.ts | 10 +- .../client/src/shared/validator.test.ts | 261 +- web_console_v2/client/src/shared/validator.ts | 145 +- .../client/src/shared/variablePresets.ts | 2 + .../client/src/shared/workflow.test.ts | 137 +- web_console_v2/client/src/shared/workflow.ts | 299 +- web_console_v2/client/src/stores/dataset.ts | 61 +- web_console_v2/client/src/stores/project.ts | 85 +- web_console_v2/client/src/stores/template.ts | 72 +- web_console_v2/client/src/stores/user.ts | 2 +- web_console_v2/client/src/stores/workflow.ts | 10 +- web_console_v2/client/src/styles/_theme.ts | 316 - .../client/src/styles/_variables.css | 313 - .../client/src/styles/animations.ts | 19 +- .../client/src/styles/antd-overrides.less | 358 - web_console_v2/client/src/styles/elements.ts | 59 +- .../client/src/styles/mixins.test.ts | 2 +- web_console_v2/client/src/styles/mixins.ts | 42 +- .../client/src/styles/variables.less | 322 - web_console_v2/client/src/typings/app.ts | 53 +- web_console_v2/client/src/typings/auth.ts | 37 +- .../client/src/typings/component.ts | 11 + web_console_v2/client/src/typings/dataset.ts | 579 +- web_console_v2/client/src/typings/formily.ts | 1 - web_console_v2/client/src/typings/global.d.ts | 12 + web_console_v2/client/src/typings/job.ts | 19 +- web_console_v2/client/src/typings/kibana.ts | 15 +- web_console_v2/client/src/typings/project.ts | 146 +- web_console_v2/client/src/typings/settings.ts | 37 +- web_console_v2/client/src/typings/variable.ts | 29 +- web_console_v2/client/src/typings/workflow.ts | 216 +- .../client/src/views/Dashboard/index.tsx | 15 +- .../AddBatchForm/FileToImportList.tsx | 164 - .../src/views/Datasets/AddBatchForm/index.tsx | 155 - .../CreateDataset/StepOneBasic/index.tsx | 106 - .../CreateDataset/StepTwoAddBatch/index.tsx | 107 - .../views/Datasets/CreateDataset/index.tsx | 783 +- .../Datasets/DatasetList/AddBatchModal.tsx | 106 - .../DatasetList/BatchImportRecordsModal.tsx | 129 - .../Datasets/DatasetList/DatasetActions.tsx | 71 - .../Datasets/DatasetList/ImportProgress.tsx | 69 - .../src/views/Datasets/DatasetList/index.tsx | 257 +- .../client/src/views/Datasets/index.tsx | 40 +- .../client/src/views/Login/index.tsx | 371 +- .../src/views/LogsViewer/JobEvents/index.tsx | 19 +- .../src/views/LogsViewer/JobLogs/index.tsx | 8 +- .../src/views/LogsViewer/PodLogs/index.tsx | 9 +- .../src/views/LogsViewer/SystemLogs/index.tsx | 8 +- .../client/src/views/LogsViewer/index.tsx | 20 +- .../src/views/Projects/ConnectionStatus.tsx | 38 +- .../views/Projects/CreateProject/index.tsx | 105 +- .../client/src/views/Projects/CreateTime.tsx | 19 +- .../src/views/Projects/EditProject/index.tsx | 91 +- .../ProjectDetailDrawer/DetailBody.tsx | 69 - .../ProjectDetailDrawer/DetailHeader.tsx | 57 - .../Projects/ProjectDetailDrawer/index.tsx | 38 - .../Projects/ProjectForm/Certificate.tsx | 89 - .../Projects/ProjectForm/EnvVariablesForm.tsx | 203 - .../Projects/ProjectForm/SecondaryForm.tsx | 30 - .../src/views/Projects/ProjectForm/index.tsx | 308 - .../ProjectList/CardView/ProjectCard.tsx | 152 - .../ProjectList/CardView/ProjectCardProp.tsx | 29 +- .../Projects/ProjectList/CardView/index.tsx | 51 +- .../ProjectList/ProjectListFilters.tsx | 96 +- .../Projects/ProjectList/TableView/index.tsx | 236 +- .../src/views/Projects/ProjectList/index.tsx | 327 +- .../src/views/Projects/ProjectMoreActions.tsx | 95 +- .../client/src/views/Projects/ProjectName.tsx | 26 +- .../client/src/views/Projects/index.tsx | 15 +- .../client/src/views/ProtectedRoute.tsx | 85 +- .../client/src/views/Settings/index.tsx | 101 +- .../src/views/Users/UserCreate/index.tsx | 24 +- .../client/src/views/Users/UserEdit/index.tsx | 15 +- .../client/src/views/Users/UserForm/index.tsx | 107 +- .../client/src/views/Users/UserList/index.tsx | 181 +- .../client/src/views/Users/index.tsx | 4 +- .../CreateTemplate/index.tsx | 5 +- .../WorkflowTemplates/EditTemplate/index.tsx | 1 - .../TemplateForm/StepOneBasic/index.tsx | 399 +- .../VariableForm/WidgetSchema.tsx | 210 - .../JobComposeDrawer/VariableForm/index.tsx | 137 - .../JobComposeDrawer/VariableList.tsx | 49 - .../StepTwoJobs/JobComposeDrawer/index.tsx | 224 - .../StepTwoJobs/TemplateCanvas.tsx | 251 - .../StepTwoJobs/TemplateConfigNode.tsx | 144 - .../TemplateForm/StepTwoJobs/index.tsx | 352 - .../WorkflowTemplates/TemplateForm/index.tsx | 63 +- .../WorkflowTemplates/TemplateForm/store.ts | 82 - .../TemplateList/TemplateUploadDialog.tsx | 89 +- .../WorkflowTemplates/TemplateList/index.tsx | 511 +- .../src/views/WorkflowTemplates/index.tsx | 16 +- .../CreateWorkflow/StepOneBasic/index.tsx | 342 +- .../CreateWorkflow/SteptTwoConfig/index.tsx | 107 +- .../views/Workflows/CreateWorkflow/index.tsx | 68 +- .../EditWorkflow/StepOneBasic/index.tsx | 318 +- .../EditWorkflow/SteptTwoConfig/index.tsx | 371 +- .../views/Workflows/EditWorkflow/index.tsx | 56 +- .../ForkWorkflow/StepOneBasic/index.tsx | 102 +- .../ForkWorkflow/StepTwoConfig/index.tsx | 241 +- .../views/Workflows/ForkWorkflow/index.tsx | 57 +- .../src/views/Workflows/InspectPeerConfig.tsx | 111 +- .../src/views/Workflows/JobFormDrawer.tsx | 148 +- .../ScheduledWorkflowRunning/index.tsx | 49 +- .../WorkflowAccessControl/AccessSwitch.tsx | 8 +- .../Workflows/WorkflowAccessControl/index.tsx | 78 +- .../src/views/Workflows/WorkflowActions.tsx | 313 +- .../WorkflowDetail/GlobalConfigDrawer.tsx | 65 +- .../JobExecutionDetailsDrawer.tsx | 241 +- .../WorkflowDetail/JobExecutionLogs.tsx | 101 +- .../WorkflowDetail/JobExecutionMetrics.tsx | 179 +- .../WorkflowDetail/JobExecutionPods.tsx | 101 +- .../FieldComponents/AggregatorSelect.tsx | 2 +- .../FieldComponents/IntervalInput.tsx | 13 +- .../FieldComponents/JsonStringInput.tsx | 2 +- .../FieldComponents/TimerNameInput.tsx | 12 +- .../FieldComponents/UnixTimePicker.tsx | 2 +- .../FieldComponents/XAxisInput.tsx | 2 +- .../KibanaChart/EmbeddedChart.tsx | 27 +- .../KibanaChart/LineChart.tsx | 23 +- .../JobKibanaMetrics/KibanaItem.tsx | 43 +- .../JobKibanaMetrics/KibanaParamsForm.tsx | 22 +- .../JobKibanaMetrics/elements.ts | 43 - .../JobKibanaMetrics/index.tsx.tsx | 66 - .../views/Workflows/WorkflowDetail/index.tsx | 537 +- .../views/Workflows/WorkflowList/index.tsx | 209 +- .../client/src/views/Workflows/index.tsx | 31 +- web_console_v2/client/src/views/index.tsx | 109 +- web_console_v2/client/src/views/routes.tsx | 189 +- web_console_v2/client/tests/setup.ts | 14 + web_console_v2/client/tsconfig.json | 8 +- web_console_v2/docker/spark/Dockerfile | 27 - web_console_v2/docker/spark/requirements.txt | 2 - web_console_v2/nginx.conf | 11 +- web_console_v2/run_prod.sh | 19 +- web_console_v2/tools/start_db.sh | 32 - 444 files changed, 34292 insertions(+), 36442 deletions(-) delete mode 100644 web_console_v2/Makefile delete mode 100644 web_console_v2/api/ci/pylintrc delete mode 100644 web_console_v2/api/fedlearner_webconsole/__init__.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/composer/runner_cache.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/dataset/data_pipeline.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/dataset/import_handler.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/analyzer.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/converter.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/transformer.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/util.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/mmgr/apis.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/project/add_on.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/proto/__init__.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/setting/__init__.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/base64.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/certificate.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/decorators.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/fake_k8s_client.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/k8s_cache.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/k8s_client.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/k8s_watcher.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/middlewares.py delete mode 100644 web_console_v2/api/fedlearner_webconsole/utils/tars.py delete mode 100755 web_console_v2/api/run_dev.sh delete mode 100755 web_console_v2/api/run_prod.sh delete mode 100644 web_console_v2/api/test/__init__.py delete mode 100644 web_console_v2/api/test/auth_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/app_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/composer/common.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/composer/composer_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/composer/op_locker_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/composer/runner_cache_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/composer/thread_reaper_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/dataset/apis_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/db_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/exceptions_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/job/metrics_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/job/service_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/job/yaml_formatter_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/k8s/models_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/mmgr/model_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/project/add_on_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/project/apis_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/project/models_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/project/test.tar.gz delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/rpc/client_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/scheduler/scheduler_func_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/scheduler/scheduler_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/scheduler/workflow_commit_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/scheduler/workflow_template_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/setting/apis_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/sparkapp/__init__.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/sparkapp/apis_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/sparkapp/schema_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/sparkapp/service_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/test_data/code.tar.gz delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/test_data/dataset_metainfo/_FEATURES delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/test_data/dataset_metainfo/_HIST delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/test_data/dataset_metainfo/_META delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/test_data/sparkapp.tar delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/test_data/workflow_config.json delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/test_data/workflow_config_right.json delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/utils/base64_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/utils/decorators_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/utils/file_manager_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/utils/k8s_client_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/utils/kibana_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/utils/metrics_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/utils/mixins_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/utils/system_envs_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/workflow/apis_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/workflow/cronjob_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/workflow_template/apis_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/workflow_template/slots_formater_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/workflow_template/template_validator_test.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/workflow_template/test_template_left.py delete mode 100644 web_console_v2/api/test/fedlearner_webconsole/workflow_template/test_template_right.py delete mode 100644 web_console_v2/api/testing/workflow_template/psi_join_tree_model_no_label.py delete mode 100644 web_console_v2/api/testing/workflow_template/psi_join_tree_model_with_label.py delete mode 100644 web_console_v2/api/tools/local_runner/app_a.py delete mode 100644 web_console_v2/api/tools/local_runner/app_b.py delete mode 100644 web_console_v2/api/tools/local_runner/initial_db.py delete mode 100755 web_console_v2/api/tools/local_runner/run_a.sh delete mode 100755 web_console_v2/api/tools/local_runner/run_b.sh delete mode 100644 web_console_v2/client/docs/KNOWN_ISSUES.md delete mode 100644 web_console_v2/client/public/fed-favicon.ico delete mode 100644 web_console_v2/client/src/assets/images/logo-colorful.svg delete mode 100644 web_console_v2/client/src/assets/images/logo-white.svg delete mode 100644 web_console_v2/client/src/assets/images/logo.svg delete mode 100644 web_console_v2/client/src/components/Footer/index.tsx delete mode 100644 web_console_v2/client/src/components/Header/ProjectSelect.tsx delete mode 100644 web_console_v2/client/src/components/VariableSchemaForm/index.tsx delete mode 100644 web_console_v2/client/src/components/_base/MockDevtools/MockControlPanel.tsx delete mode 100644 web_console_v2/client/src/components/_base/MockDevtools/index.js delete mode 100644 web_console_v2/client/src/components/_base/MockDevtools/utils.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/auth/signin.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/auth/signout.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/auth/users/:id.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/datasets/:id/batches.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/datasets/:id/index.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/jobs/:id/events.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/jobs/:id/log.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/jobs/:id/metrics.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/projects/:id/connection_checks.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/projects/:id/index.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/projects/index.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/workflow_templates/:id/index.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/workflows/:id/index.ts delete mode 100644 web_console_v2/client/src/services/mocks/v2/workflows/:id/peer_workflows.ts delete mode 100644 web_console_v2/client/src/shared/helpers.ts delete mode 100644 web_console_v2/client/src/styles/_theme.ts delete mode 100644 web_console_v2/client/src/styles/_variables.css delete mode 100644 web_console_v2/client/src/styles/antd-overrides.less delete mode 100644 web_console_v2/client/src/styles/variables.less delete mode 100644 web_console_v2/client/src/views/Datasets/AddBatchForm/FileToImportList.tsx delete mode 100644 web_console_v2/client/src/views/Datasets/AddBatchForm/index.tsx delete mode 100644 web_console_v2/client/src/views/Datasets/CreateDataset/StepOneBasic/index.tsx delete mode 100644 web_console_v2/client/src/views/Datasets/CreateDataset/StepTwoAddBatch/index.tsx delete mode 100644 web_console_v2/client/src/views/Datasets/DatasetList/AddBatchModal.tsx delete mode 100644 web_console_v2/client/src/views/Datasets/DatasetList/BatchImportRecordsModal.tsx delete mode 100644 web_console_v2/client/src/views/Datasets/DatasetList/DatasetActions.tsx delete mode 100644 web_console_v2/client/src/views/Datasets/DatasetList/ImportProgress.tsx delete mode 100644 web_console_v2/client/src/views/Projects/ProjectDetailDrawer/DetailBody.tsx delete mode 100644 web_console_v2/client/src/views/Projects/ProjectDetailDrawer/DetailHeader.tsx delete mode 100644 web_console_v2/client/src/views/Projects/ProjectDetailDrawer/index.tsx delete mode 100644 web_console_v2/client/src/views/Projects/ProjectForm/Certificate.tsx delete mode 100644 web_console_v2/client/src/views/Projects/ProjectForm/EnvVariablesForm.tsx delete mode 100644 web_console_v2/client/src/views/Projects/ProjectForm/SecondaryForm.tsx delete mode 100644 web_console_v2/client/src/views/Projects/ProjectForm/index.tsx delete mode 100644 web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCard.tsx delete mode 100644 web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepTwoJobs/JobComposeDrawer/VariableForm/WidgetSchema.tsx delete mode 100644 web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepTwoJobs/JobComposeDrawer/VariableForm/index.tsx delete mode 100644 web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepTwoJobs/JobComposeDrawer/VariableList.tsx delete mode 100644 web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepTwoJobs/JobComposeDrawer/index.tsx delete mode 100644 web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepTwoJobs/TemplateCanvas.tsx delete mode 100644 web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepTwoJobs/TemplateConfigNode.tsx delete mode 100644 web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepTwoJobs/index.tsx delete mode 100644 web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/store.ts delete mode 100644 web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/elements.ts delete mode 100644 web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/index.tsx.tsx delete mode 100644 web_console_v2/docker/spark/Dockerfile delete mode 100644 web_console_v2/docker/spark/requirements.txt delete mode 100644 web_console_v2/tools/start_db.sh diff --git a/web_console_v2/.dockerignore b/web_console_v2/.dockerignore index f6edae1e9..e74466cc6 100644 --- a/web_console_v2/.dockerignore +++ b/web_console_v2/.dockerignore @@ -4,5 +4,5 @@ Dockerfile # Tests client/tests -api/test +api/tests api/testing diff --git a/web_console_v2/Dockerfile b/web_console_v2/Dockerfile index 0254897a4..6d856b5b4 100644 --- a/web_console_v2/Dockerfile +++ b/web_console_v2/Dockerfile @@ -1,11 +1,13 @@ -FROM python:3.7 +FROM python:3.6.8 RUN apt-get update && \ apt install -y curl && \ # For nodejs PA curl -sL https://deb.nodesource.com/setup_14.x | bash && \ + # For krb5-user installation + export DEBIAN_FRONTEND=noninteractive && \ # Install dependencies - apt-get install -y make nodejs nginx && \ + apt-get install -y make nodejs nginx krb5-user cron && \ apt-get clean WORKDIR /app @@ -14,14 +16,15 @@ COPY . . # Builds frontend WORKDIR /app/client -RUN npx pnpm install && npx pnpm build && rm -rf node_modules +RUN npx pnpm@6.4.0 install && npx pnpm@6.4.0 build && rm -rf node_modules # Builds backend WORKDIR /app/api RUN pip3 install --no-cache-dir -r requirements.txt && make protobuf +WORKDIR /app # Nginx configuration -COPY nginx.conf /etc/nginx/conf.d/nginx.conf +RUN cp nginx.conf /etc/nginx/conf.d/nginx.conf # Port for webconsole http server EXPOSE 1989 @@ -29,19 +32,7 @@ EXPOSE 1989 # This should not be exposed in PROD EXPOSE 1990 -# Install vscode -RUN curl -fOL https://github.com/cdr/code-server/releases/download/v3.8.0/code-server_3.8.0_amd64.deb && \ - dpkg -i code-server_3.8.0_amd64.deb && \ - rm code-server_3.8.0_amd64.deb && \ - mkdir -p ~/.config/code-server/ && \ - echo 'bind-addr: 0.0.0.0:1992\n\ -auth: password\n\ -password: fedlearner\n\ -cert: false\n' >> ~/.config/code-server/config.yaml - -# Port for VScode -EXPOSE 1992 ENV TZ="Asia/Shanghai" WORKDIR /app -CMD sh run_prod.sh +CMD bash run_prod.sh diff --git a/web_console_v2/Makefile b/web_console_v2/Makefile deleted file mode 100644 index 9fb779f40..000000000 --- a/web_console_v2/Makefile +++ /dev/null @@ -1,8 +0,0 @@ -api-test: - cd api && \ - make protobuf && \ - make lint && \ - make test - -docker-spark: - cd ./docker/spark && docker build . -t spark-tfrecord:latest \ No newline at end of file diff --git a/web_console_v2/api/.gitignore b/web_console_v2/api/.gitignore index b42ddd478..9f55327c4 100644 --- a/web_console_v2/api/.gitignore +++ b/web_console_v2/api/.gitignore @@ -4,9 +4,10 @@ # Generated proto python code fedlearner_webconsole/proto/*.py fedlearner_webconsole/proto/*.pyi +fedlearner_webconsole/proto/testing/ # Coverage generated .coverage_html_report/ .coverage* -root.log.* \ No newline at end of file +root.log.* diff --git a/web_console_v2/api/Makefile b/web_console_v2/api/Makefile index 997047b5f..a306d70e7 100644 --- a/web_console_v2/api/Makefile +++ b/web_console_v2/api/Makefile @@ -1,36 +1,16 @@ export PYTHONPATH:=${PWD}:$(PYTHONPATH) -.PHONY: test unit-test-all unit-test protobuf - -lint: - pylint --rcfile ./ci/pylintrc --load-plugins pylint_quotes fedlearner_webconsole +clean: + rm -f err.out && \ + find ./ -type f \( -name "*.db" -o -name "*.log" \) -exec rm -f {} \; protobuf: + PATH=${PATH}:${PWD}/bin/$(shell uname) \ python -m grpc_tools.protoc -I protocols \ --python_out=. \ --grpc_python_out=. \ --mypy_out=. \ - protocols/fedlearner_webconsole/proto/*.proto - -UNIT_TEST_SCRIPTS := $(shell find test/ -type f -name "*_test.py") -UNIT_TEST_SCRIPTS_REGEX := $(shell find test/$(FOLDER) -type f -name "$(REG)*.py") -UNIT_TESTS := $(UNIT_TEST_SCRIPTS:%.py=%.phony) -UNIT_TESTS_REGEX := $(UNIT_TEST_SCRIPTS_REGEX:%.py=%.phony) - -test/%.phony: test/%.py - python $^ - -unit-test-all: protobuf $(UNIT_TESTS) - -# run unit test with optional $FOLDER and $REG parameter to limit the number of -# running tests. -# Sample: make unit-test FOLDER="/fedlearner_webconsole/utils" REG="file*" -unit-test: protobuf $(UNIT_TESTS_REGEX) - -cli-test: - FLASK_APP=command:app flask routes - -test: unit-test-all cli-test - -clean: - find ./ -type f \( -name "*.db" -o -name "*.log" \) -exec rm -f {} \; + --jsonschema_out=prefix_schema_files_with_package,disallow_additional_properties:fedlearner_webconsole/proto/jsonschemas \ + protocols/fedlearner_webconsole/proto/*.proto \ + protocols/fedlearner_webconsole/proto/**/*.proto \ + protocols/fedlearner_webconsole/proto/rpc/v2/*.proto diff --git a/web_console_v2/api/README.md b/web_console_v2/api/README.md index c46e853dd..30a737fec 100644 --- a/web_console_v2/api/README.md +++ b/web_console_v2/api/README.md @@ -2,73 +2,126 @@ ## Prerequisites -* GNU Make -* Python3 +* Bazel * MySQL 8.0 +* Docker ## Get started -``` -python3 -m venv -source /bin/activate -pip3 install -r requirements.txt +Starting development by using fake k8s (no actual data). + +start all the processes -# Generates python code for proto -make protobuf +```bash +bazelisk run //web_console_v2/api/cmds:run_dev +``` -# Use MySQL, please create database in advance, then set -# SQLALCHEMY_DATABASE_URI, for example as follows -export SQLALCHEMY_DATABASE_URI=mysql+pymysql://root:root@localhost:33600/fedlearner +optionally if you want to stop or restart one of the processes -# Creates schemas for DB -FLASK_APP=command:app flask db upgrade +```bash +bazelisk run //web_console_v2/api/cmds:supervisorctl_cli_bin -- -s unix:///tmp/supervisor.sock +``` -# Creates initial user -FLASK_APP=command:app flask create-initial-data +## Develop with remote k8s cluster -# Starts the server -export FLASK_ENV=development -flask run +```bash +# Changes configs in tools/local_runner/app_a.py or app_b.py +bash tools/local_runner/run_a.sh +bash tools/local_runner/run_b.sh ``` ## Tests ### Unit tests -``` -cd -make unit-test +```bash +bazelisk test //web_console_v2/api/... --config lint ``` ## Helpers ### Gets all routes -``` -FLASK_APP=command:app flask routes + +```bash +FLASK_APP=web_console_v2/api/command:app \ + APM_SERVER_ENDPOINT=/dev/null \ + bazelisk run //web_console_v2/api/cmds:flask_cli_bin -- routes ``` ### Add migration files -``` -FLASK_APP=command:app flask db migrate -m "Whats' changed" +```bash +FLASK_APP=web_console_v2/api/command:app \ + APM_SERVER_ENDPOINT=/dev/null \ + bazelisk run //web_console_v2/api/cmds:flask_cli_bin -- db migrate -m "Whats' changed" -d web_console_v2/api/migrations + # like dry-run mode, preview auto-generated SQL -FLASK_APP=command:app flask db upgrade --sql +FLASK_APP=web_console_v2/api/command:app \ + APM_SERVER_ENDPOINT=/dev/null \ + bazelisk run //web_console_v2/api/cmds:flask_cli_bin -- db upgrade --sql -d web_console_v2/api/migrations + # update database actually -FLASK_APP=command:app flask db upgrade +FLASK_APP=web_console_v2/api/command:app \ + APM_SERVER_ENDPOINT=/dev/null \ + bazelisk run //web_console_v2/api/cmds:flask_cli_bin -- db upgrade -d web_console_v2/api/migrations ``` ### Reset migration files Delete migrations folder first. + +```bash +FLASK_APP=web_console_v2/api/command:app \ + APM_SERVER_ENDPOINT=/dev/null \ + bazelisk run //web_console_v2/api/cmds:flask_cli_bin -- db init -d web_console_v2/api/migrations + +FLASK_APP=web_console_v2/api/command:app \ + APM_SERVER_ENDPOINT=/dev/null \ + bazelisk run //web_console_v2/api/cmds:flask_cli_bin -- db migrate -m "Initial migration." -d web_console_v2/api/migrations +``` + +### Cleanup project + +```bash +FLASK_APP=web_console_v2/api/command:app \ + APM_SERVER_ENDPOINT=/dev/null \ + bazelisk run //web_console_v2/api/cmds:flask_cli_bin -- cleanup-project ``` -FLASK_APP=command:app flask db init -FLASK_APP=command:app flask db migrate -m "Initial migration." + +## 规范 & 风格 + +### [Style guide](docs/style_guide.md) + +### Code formatter + +We use [yapf](https://github.com/google/yapf) to format our code, style is defined in `.style.yapf`. + +To check the format, please run: + +```bash +bazelisk test --config lint ``` -## [Style guide](docs/style_guide.md) -## [Best practices](docs/best_practices.md) +To fix the errors, please run: + +```bash +bazelisk test --config fix +``` + +### [gRPC](docs/grpc.md) + +## 最佳实践 + +### [数据库相关最佳实践](docs/best_practices/db.md) + +### [API层最佳实践](docs/best_practices.md) + +### [客户端-服务端模型最佳实践](docs/best_practices/client_server.md) + +### [多进程最佳实践](docs/best_practices/multiprocess.md) ## References ### Default date time in sqlalchemy + https://stackoverflow.com/questions/13370317/sqlalchemy-default-datetime/33532154#33532154 diff --git a/web_console_v2/api/ci/pylintrc b/web_console_v2/api/ci/pylintrc deleted file mode 100644 index 13af12998..000000000 --- a/web_console_v2/api/ci/pylintrc +++ /dev/null @@ -1,434 +0,0 @@ -[MASTER] - -# Specify a configuration file. -#rcfile= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns=.*pb2.* - -# Pickle collected data for later comparisons. -persistent=yes - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Use multiple processes to speed up Pylint. -jobs=1 - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time. See also the "--disable" option for examples. -#enable= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -# -# ----------------------------------------------------------------------- -# 2015-01-12 - What follows is the list of all disabled items necessary -# to get a clean run of lint across CourseBuilder. These are separated -# into three tiers: -# -# - Fix-worthy. This includes: -# - Probable bugs -# - Easily-addressed hygiene issues, -# - Real warnings which we may mark as suppressed on a case-by-case basis. -# - Items that are questionable practice, but not necessarily economical to fix. -# - Items that we intend to ignore, as we do not consider them bad practice. -# -# Warning messages are documented at http://docs.pylint.org/features.html -# -# ---------------------------------------------------------------------- -# Fix-worthy: -# -# ---- Possible bugs: -# disable=super-on-old-class -# disable=arguments-differ (# of arguments to overriding/overridden method) -# disable=signature-differs -# disable=method-hidden -# disable=abstract-method (Abstract method not overridden in derived class) -# disable=no-member (self.foo used when foo not declared in class) -# -# ---- Easy-to-fix and improves readability, cleanliness: -# disable=relative-import -# -# ---- Probably legitimate, but needs markup to indicate intentionality -# disable=no-init (Class does not have __init__, nor do ancestor classes) -# disable=import-error -# disable=attribute-defined-outside-init -# -# ---------------------------------------------------------------------- -# Fix when economical: -# -# ---- Minor code cleanliness problems; fix when encountered. -# disable=unused-argument -# disable=unused-variable -# disable=invalid-name (Variable name does not meet coding standard) -# disable=duplicate-code -# -# ---- Laundry list of tunable parameters for when things are too big/small -# disable=abstract-class-little-used -# disable=too-few-public-methods -# disable=too-many-instance-attributes -# disable=too-many-ancestors -# disable=too-many-return-statements -# disable=too-many-lines -# disable=too-many-locals -# disable=too-many-function-args -# disable=too-many-public-methods -# disable=too-many-arguments -# -# ---------------------------------------------------------------------- -# Ignored; OK by our coding standard: -# -# disable=bad-continuation (Bad whitespace on following line) -# disable=no-self-use (Member function never uses 'self' parameter) -# disable=missing-docstring -# disable=fixme -# disable=star-args -# disable=locally-disabled (Notes local suppression of warning) -# disable=locally-enabled (Notes re-enable of suppressed warning) -# disable=bad-option-value (Notes suppression of unknown warning) -# disable=abstract-class-not-used (Warns when not used in same file) -# -# Unfortunately, since the options parsing does not support multi-line entries -# nor line continuation, all of the above items are redundantly specified here -# in a way that pylint is willing to parse. -disable=super-on-old-class,arguments-differ,signature-differs,method-hidden,abstract-method,no-member,relative-import,no-init,import-error,attribute-defined-outside-init,abstract-class-not-used,unused-argument,unused-variable,invalid-name,duplicate-code,abstract-class-little-used,too-few-public-methods,too-many-instance-attributes,too-many-ancestors,too-many-return-statements,too-many-lines,too-many-locals,too-many-function-args,too-many-public-methods,too-many-arguments,bad-continuation,no-self-use,missing-docstring,fixme,star-args,locally-disabled,locally-enabled,bad-option-value,useless-object-inheritance,logging-format-interpolation - -[REPORTS] - -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html. You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - -# Tells whether to display a full report or only the messages -reports=no - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[SIMILARITIES] - -# Minimum lines number of a similarity. -min-similarity-lines=4 - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO - - -[VARIABLES] - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_$|dummy - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - - -[TYPECHECK] - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis -ignored-modules= - -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). -ignored-classes=SQLObject - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E0201 when accessed. Python regular -# expressions are accepted. -generated-members=REQUEST,acl_users,aq_parent - - -[BASIC] - -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,input - -# Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Regular expression matching correct function names -function-rgx=[a-z_][a-z0-9_]{2,50}$ - -# Naming hint for function names -function-name-hint=[a-z_][a-z0-9_]{2,50}$ - -# Regular expression matching correct variable names -variable-rgx=[a-z_][a-z0-9_]{1,30}$ - -# Naming hint for variable names -variable-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression matching correct attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for attribute names -attr-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for argument names -argument-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression matching correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ - -# Naming hint for class names -class-name-hint=[A-Z_][a-zA-Z0-9]+$ - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=__.*__ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - - -[FORMAT] - -# Maximum number of characters on a single line. -max-line-length=80 - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - -# List of optional constructs for which whitespace checking is disabled -no-space-check=trailing-comma,dict-separator - -# Maximum number of lines in a module -max-module-lines=2000 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - - -[IMPORTS] - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=regsub,TERMIOS,Bastion,rexec - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=12 - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* - -# Maximum number of locals for function / method body -max-locals=25 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of branch for function / method body -max-branches=40 - -# Maximum number of statements in function / method body -max-statements=105 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=50 - -# Set the linting for string quotes -string-quote=single -triple-quote=double -docstring-quote=double - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception diff --git a/web_console_v2/api/command.py b/web_console_v2/api/command.py index ca3fdc337..776408412 100644 --- a/web_console_v2/api/command.py +++ b/web_console_v2/api/command.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,19 +13,24 @@ # limitations under the License. # coding: utf-8 +import click + from config import Config +from flask_migrate import Migrate +from es_configuration import es_config from fedlearner_webconsole.app import create_app -from fedlearner_webconsole.db import db_handler as db +from fedlearner_webconsole.db import db from fedlearner_webconsole.initial_db import initial_db -from flask_migrate import Migrate - from fedlearner_webconsole.utils.hooks import pre_start_hook +from tools.project_cleanup import delete_project +from tools.workflow_migration.workflow_completed_failed import migrate_workflow_completed_failed_state +from tools.dataset_migration.dataset_job_name_migration.dataset_job_name_migration import migrate_dataset_job_name +from tools.variable_finder import find class CliConfig(Config): - START_GRPC_SERVER = False START_SCHEDULER = False - START_COMPOSER = False + START_K8S_WATCHER = False pre_start_hook() @@ -42,3 +47,35 @@ def create_initial_data(): @app.cli.command('create-db') def create_db(): db.create_all() + + +@app.cli.command('cleanup-project') +@click.argument('project_id') +def cleanup_project(project_id): + delete_project(int(project_id)) + + +@app.cli.command('migrate-workflow-completed-failed-state') +def remove_intersection_dataset(): + migrate_workflow_completed_failed_state() + + +@app.cli.command('migrate-dataset-job-name') +def add_dataset_job_name(): + migrate_dataset_job_name() + + +@app.cli.command('migrate-connect-to-test') +def migrate_connect_to_test(): + migrate_connect_to_test() + + +@app.cli.command('find-variable') +@click.argument('name') +def find_variable(name: str): + find(name) + + +@app.cli.command('es-configuration') +def es_configuration(): + es_config() diff --git a/web_console_v2/api/config.py b/web_console_v2/api/config.py index e58492b6e..f3d27f82d 100644 --- a/web_console_v2/api/config.py +++ b/web_console_v2/api/config.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,9 +14,6 @@ # coding: utf-8 -import os -import secrets - from fedlearner_webconsole.db import get_database_uri from envs import Envs @@ -28,12 +25,11 @@ class Config(object): # For unicode strings # Ref: https://stackoverflow.com/questions/14853694/python-jsonify-dictionary-in-utf-8 JSON_AS_ASCII = False - JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', secrets.token_urlsafe(64)) + JWT_SECRET_KEY = Envs.JWT_SECRET_KEY PROPAGATE_EXCEPTIONS = True - GRPC_LISTEN_PORT = 1990 + GRPC_LISTEN_PORT = Envs.GRPC_LISTEN_PORT JWT_ACCESS_TOKEN_EXPIRES = 86400 STORAGE_ROOT = Envs.STORAGE_ROOT - START_GRPC_SERVER = True START_SCHEDULER = True - START_COMPOSER = os.getenv('START_COMPOSER', True) + START_K8S_WATCHER = True diff --git a/web_console_v2/api/docs/best_practices.md b/web_console_v2/api/docs/best_practices.md index 9c5964b50..88aaaddf6 100644 --- a/web_console_v2/api/docs/best_practices.md +++ b/web_console_v2/api/docs/best_practices.md @@ -6,6 +6,8 @@ flask-migrate, which needs us to upgrade the migration files once schema gets updated (inefficiently). Integers/strings makes us easy to extend the enums, the disadvantage is we should take care of data migrations if enum is deleted. +Natively sqlalchemy support Enum type in a column. [Ref](https://docs.sqlalchemy.org/en/14/core/type_basics.html#sqlalchemy.types.Enum) + ### Index in DB Index is not necessary if the value of column is very limited, such as enum or boolean. Reference: https://tech.meituan.com/2014/06/30/mysql-index.html @@ -53,3 +55,58 @@ See details [here](https://en.wikipedia.org/wiki/Representational_state_transfer primaryjoin='Project.id == ' 'foreign(Job.project_id)') ``` + +### sqlalchemy session +* Please limit the session/transaction scope as small as possible, otherwise it may not work as expected. +[Ref](https://docs.sqlalchemy.org/en/14/orm/session_basics.html#when-do-i-construct-a-session-when-do-i-commit-it-and-when-do-i-close-it) +```python +#BAD: the transaction will include the runner query, it may stale. +with db.session_scope() as session: + init_runners = session.query(SchedulerRunner).filter_by( + status=RunnerStatus.INIT.value).all() + for runner in init_runners: + # Do something with the runner + session.commit() + +#GOOD: make the transaction scope clear. +with db.session_scope() as session: + running_runner_ids = session.query(SchedulerRunner.id).filter_by( + status=RunnerStatus.RUNNING.value).all() +for runner_id, *_ in running_runner_ids: + with db.session_scope() as session: + runner = session.query(SchedulerRunner).get(runner_id) + # Do something with the runner + session.commit() +``` + +### Pagination +- Use `utils/paginate.py`, and **read the test case** as a quickstart +- All resources are **un-paginated** by default +- Append page metadata in your returned body in the following format: +```json +// your POV +{ + "data": pagination.get_items(), + "page_meta": pagination.get_metadata() +} + +// frontend POV +{ + "data": {...}, + "page_meta": { + "current_page": 1, + "page_size": 5, + "total_pages": 2, + "total_items": 7 + } +} +``` +- **ALWAYS** return `page_meta` + - If your API is called with `page=...`, then paginate for the caller; return the pagination metadata as shown above + - If your API is called without `page=...`, then return the un-paginated data with an **empty** `page_meta` body like so: + ```json + { + "data": {...}, + "page_meta": {} + } + ``` diff --git a/web_console_v2/api/envs.py b/web_console_v2/api/envs.py index 6f6f5f39d..b4d7a4900 100644 --- a/web_console_v2/api/envs.py +++ b/web_console_v2/api/envs.py @@ -1,56 +1,172 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + import os -import json +import re +import secrets +from typing import Optional +from urllib.parse import unquote +from google.protobuf.json_format import Parse, ParseError import pytz +from fedlearner_webconsole.proto import setting_pb2 +from fedlearner_webconsole.utils.const import API_VERSION + +# SQLALCHEMY_DATABASE_URI pattern dialect+driver://username:password@host:port/database +_SQLALCHEMY_DATABASE_URI_PATTERN = re.compile( + r'^(?P[^+:]+)(\+(?P[^:]+))?://' + r'((?P[^:@]+)?:(?P[^@]+)?@((?P[^:/]+)(:(?P[0-9]+))?)?)?' + r'/(?P[^?]+)?') + +# Limit one thread used by OpenBLAS to avoid many threads that hang. +# ref: https://stackoverflow.com/questions/30791550/limit-number-of-threads-in-numpy +os.environ['OMP_NUM_THREADS'] = '1' + class Envs(object): + SERVER_HOST = os.environ.get('SERVER_HOST', 'http://localhost:666/') TZ = pytz.timezone(os.environ.get('TZ', 'UTC')) - ES_HOST = os.environ.get('ES_HOST', - 'fedlearner-stack-elasticsearch-client') - ES_READ_HOST = os.environ.get('ES_READ_HOST', ES_HOST) + ES_HOST = os.environ.get('ES_HOST', 'fedlearner-stack-elasticsearch-client') ES_PORT = os.environ.get('ES_PORT', 9200) ES_USERNAME = os.environ.get('ES_USERNAME', 'elastic') ES_PASSWORD = os.environ.get('ES_PASSWORD', 'Fedlearner123') + # apm-server service address which is used to collect trace and custom metrics + APM_SERVER_ENDPOINT = os.environ.get('APM_SERVER_ENDPOINT', 'http://fedlearner-stack-apm-server:8200') # addr to Kibana in pod/cluster - KIBANA_SERVICE_ADDRESS = os.environ.get( - 'KIBANA_SERVICE_ADDRESS', 'http://fedlearner-stack-kibana:443') + KIBANA_SERVICE_ADDRESS = os.environ.get('KIBANA_SERVICE_ADDRESS', 'http://fedlearner-stack-kibana:443') # addr to Kibana outside cluster, typically comply with port-forward KIBANA_ADDRESS = os.environ.get('KIBANA_ADDRESS', 'localhost:1993') # What fields are allowed in peer query. - KIBANA_ALLOWED_FIELDS = set( - f for f in os.environ.get('KIBANA_ALLOWED_FIELDS', '*').split(',') - if f) - OPERATOR_LOG_MATCH_PHRASE = os.environ.get('OPERATOR_LOG_MATCH_PHRASE', - None) - # Whether to use the real jwt_required decorator or fake one + KIBANA_ALLOWED_FIELDS = set(f for f in os.environ.get('KIBANA_ALLOWED_FIELDS', '*').split(',') if f) + # Kibana dashboard list of dashboard information consist of [`name`, `uuid`] in json format + KIBANA_DASHBOARD_LIST = os.environ.get('KIBANA_DASHBOARD_LIST', '[]') + OPERATOR_LOG_MATCH_PHRASE = os.environ.get('OPERATOR_LOG_MATCH_PHRASE', None) + # Whether to use the real credentials_required decorator or fake one DEBUG = os.environ.get('DEBUG', False) + SWAGGER_URL_PREFIX = os.environ.get('SWAGGER_URL_PREFIX', API_VERSION) + # grpc client can use this GRPC_SERVER_URL when DEBUG is True + GRPC_SERVER_URL = os.environ.get('GRPC_SERVER_URL', None) + GRPC_LISTEN_PORT = int(os.environ.get('GRPC_LISTEN_PORT', 1990)) + RESTFUL_LISTEN_PORT = int(os.environ.get('RESTFUL_LISTEN_PORT', 1991)) + # composer server listen port for health checking service + COMPOSER_LISTEN_PORT = int(os.environ.get('COMPOSER_LISTEN_PORT', 1992)) ES_INDEX = os.environ.get('ES_INDEX', 'filebeat-*') # Indicates which k8s namespace fedlearner pods belong to K8S_NAMESPACE = os.environ.get('K8S_NAMESPACE', 'default') K8S_CONFIG_PATH = os.environ.get('K8S_CONFIG_PATH', None) - # additional info for k8s.metadata.labels - K8S_LABEL_INFO = json.loads(os.environ.get('K8S_LABEL_INFO', '{}')) - FEDLEARNER_WEBCONSOLE_LOG_DIR = os.environ.get( - 'FEDLEARNER_WEBCONSOLE_LOG_DIR', '.') + K8S_HOOK_MODULE_PATH = os.environ.get('K8S_HOOK_MODULE_PATH', None) + FEDLEARNER_WEBCONSOLE_LOG_DIR = os.environ.get('FEDLEARNER_WEBCONSOLE_LOG_DIR', '.') + LOG_LEVEL = os.environ.get('LOGLEVEL', 'INFO').upper() FLASK_ENV = os.environ.get('FLASK_ENV', 'development') + CLUSTER = os.environ.get('CLUSTER', 'default') + JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', secrets.token_urlsafe(64)) # In seconds - GRPC_CLIENT_TIMEOUT = os.environ.get('GRPC_CLIENT_TIMEOUT', 5) + GRPC_CLIENT_TIMEOUT = int(os.environ.get('GRPC_CLIENT_TIMEOUT', 5)) + # In seconds + GRPC_STREAM_CLIENT_TIMEOUT = int(os.environ.get('GRPC_STREAM_CLIENT_TIMEOUT', 10)) # storage filesystem STORAGE_ROOT = os.getenv('STORAGE_ROOT', '/data') # BASE_DIR BASE_DIR = os.path.abspath(os.path.dirname(__file__)) - # spark on k8s image url - SPARKAPP_IMAGE_URL = os.getenv('SPARKAPP_IMAGE_URL', None) - SPARKAPP_FILES_PATH = os.getenv('SPARKAPP_FILES_PATH', None) - SPARKAPP_VOLUMES = os.getenv('SPARKAPP_VOLUMES', None) - SPARKAPP_VOLUME_MOUNTS = os.getenv('SPARKAPP_VOLUME_MOUNTS', None) # Hooks PRE_START_HOOK = os.environ.get('PRE_START_HOOK', None) + # Flags + FLAGS = os.environ.get('FLAGS', '{}') + + # Third party SSO, see the example in test_sso.json + SSO_INFOS = os.environ.get('SSO_INFOS', '[]') + + # Audit module storage setting + AUDIT_STORAGE = os.environ.get('AUDIT_STORAGE', 'db') + + # system info, include name, domain name, ip + SYSTEM_INFO = os.environ.get('SYSTEM_INFO', '{}') + + CUSTOMIZED_FILE_MANAGER = os.environ.get('CUSTOMIZED_FILE_MANAGER') + SCHEDULER_POLLING_INTERVAL = os.environ.get('FEDLEARNER_WEBCONSOLE_POLLING_INTERVAL', 60) + + # DB related + SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') + DB_HOST = os.environ.get('DB_HOST') + DB_PORT = os.environ.get('DB_PORT') + DB_DATABASE = os.environ.get('DB_DATABASE') + DB_USERNAME = os.environ.get('DB_USERNAME') + DB_PASSWORD = os.environ.get('DB_PASSWORD') + + # Fedlearner related + KVSTORE_TYPE = os.environ.get('KVSTORE_TYPE') + ETCD_NAME = os.environ.get('ETCD_NAME') + ETCD_ADDR = os.environ.get('ETCD_ADDR') + ETCD_BASE_DIR = os.environ.get('ETCD_BASE_DIR') + ROBOT_USERNAME = os.environ.get('ROBOT_USERNAME') + ROBOT_PWD = os.environ.get('ROBOT_PWD') + WEB_CONSOLE_V2_ENDPOINT = os.environ.get('WEB_CONSOLE_V2_ENDPOINT') + HADOOP_HOME = os.environ.get('HADOOP_HOME') + JAVA_HOME = os.environ.get('JAVA_HOME') + + @staticmethod + def _decode_url_codec(codec: str) -> str: + if not codec: + return codec + return unquote(codec) + + @classmethod + def _check_db_envs(cls) -> Optional[str]: + # Checks if DB related envs are matched + if cls.SQLALCHEMY_DATABASE_URI: + matches = _SQLALCHEMY_DATABASE_URI_PATTERN.match(cls.SQLALCHEMY_DATABASE_URI) + if not matches: + return 'Invalid SQLALCHEMY_DATABASE_URI' + if cls.DB_HOST: + # Other DB_* envs should be set together + db_host = cls._decode_url_codec(matches.group('host')) + if cls.DB_HOST != db_host: + return 'DB_HOST does not match' + db_port = cls._decode_url_codec(matches.group('port')) + if cls.DB_PORT != db_port: + return 'DB_PORT does not match' + db_database = cls._decode_url_codec(matches.group('database')) + if cls.DB_DATABASE != db_database: + return 'DB_DATABASQLALCHEMY_DATABASE_URISE does not match' + db_username = cls._decode_url_codec(matches.group('username')) + if cls.DB_USERNAME != db_username: + return 'DB_USERNAME does not match' + db_password = cls._decode_url_codec(matches.group('password')) + if cls.DB_PASSWORD != db_password: + return 'DB_PASSWORD does not match' + return None + + @classmethod + def _check_system_info_envs(cls) -> Optional[str]: + try: + system_info = Parse(Envs.SYSTEM_INFO, setting_pb2.SystemInfo()) + except ParseError as err: + return f'failed to parse SYSTEM_INFO {err}' + if system_info.domain_name == '' or system_info.name == '': + return 'domain_name or name is not set into SYSTEM_INFO' + return None -class Features(object): - FEATURE_MODEL_K8S_HOOK = os.getenv('FEATURE_MODEL_K8S_HOOK') - FEATURE_MODEL_WORKFLOW_HOOK = os.getenv('FEATURE_MODEL_WORKFLOW_HOOK') - DATA_MODULE_BETA = os.getenv('DATA_MODULE_BETA', None) + @classmethod + def check(cls) -> Optional[str]: + db_envs_error = cls._check_db_envs() + if db_envs_error: + return db_envs_error + system_info_envs_error = cls._check_system_info_envs() + if system_info_envs_error: + return system_info_envs_error + return None diff --git a/web_console_v2/api/es_configuration.py b/web_console_v2/api/es_configuration.py index a77694eec..96081f7f2 100644 --- a/web_console_v2/api/es_configuration.py +++ b/web_console_v2/api/es_configuration.py @@ -1,3 +1,18 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + import requests from elasticsearch import Elasticsearch, exceptions @@ -14,42 +29,46 @@ def _configure_index_alias(es, alias_name): es.indices.create( # resolves to alias_name-yyyy.mm.dd-000001 in ES f'<{alias_name}-{{now/d}}-000001>', - body={"aliases": {alias_name: {"is_write_index": True}}} - ) + body={'aliases': { + alias_name: { + 'is_write_index': True + } + }}) def _configure_kibana_index_patterns(kibana_addr, index_type): if not kibana_addr: - requests.post( - url='{}/api/saved_objects/index-pattern/{}' - .format(kibana_addr, ALIAS_NAME[index_type]), - json={'attributes': { - 'title': ALIAS_NAME[index_type] + '*', - 'timeFieldName': 'tags.process_time' - if index_type == 'metrics' else 'tags.event_time'}}, - headers={'kbn-xsrf': 'true', - 'Content-Type': 'application/json'}, - params={'overwrite': True} - ) + requests.post(url=f'{kibana_addr}/api/saved_objects/index-pattern/{ALIAS_NAME[index_type]}', + json={ + 'attributes': { + 'title': ALIAS_NAME[index_type] + '*', + 'timeFieldName': 'tags.process_time' if index_type == 'metrics' else 'tags.event_time' + } + }, + headers={ + 'kbn-xsrf': 'true', + 'Content-Type': 'application/json' + }, + params={'overwrite': True}) def put_ilm(es, ilm_name, hot_size='50gb', hot_age='10d', delete_age='30d'): ilm_body = { - "policy": { - "phases": { - "hot": { - "min_age": "0ms", - "actions": { - "rollover": { - "max_size": hot_size, - "max_age": hot_age + 'policy': { + 'phases': { + 'hot': { + 'min_age': '0ms', + 'actions': { + 'rollover': { + 'max_size': hot_size, + 'max_age': hot_age } } }, - "delete": { - "min_age": delete_age, - "actions": { - "delete": {} + 'delete': { + 'min_age': delete_age, + 'actions': { + 'delete': {} } } } @@ -64,28 +83,25 @@ def _put_index_template(es, index_type, shards): es.indices.put_template(template_name, template_body) -if __name__ == '__main__': - es = Elasticsearch([{'host': Envs.ES_HOST, 'port': Envs.ES_PORT}], - http_auth=(Envs.ES_USERNAME, Envs.ES_PASSWORD)) +def es_config(): + es = Elasticsearch([{'host': Envs.ES_HOST, 'port': Envs.ES_PORT}], http_auth=(Envs.ES_USERNAME, Envs.ES_PASSWORD)) if int(es.info()['version']['number'].split('.')[0]) == 7: es.ilm.start() for index_type, alias_name in ALIAS_NAME.items(): - put_ilm(es, 'fedlearner_{}_ilm'.format(index_type)) + put_ilm(es, f'fedlearner_{index_type}_ilm') _put_index_template(es, index_type, shards=1) _configure_index_alias(es, alias_name) # Kibana index-patterns initialization - _configure_kibana_index_patterns( - Envs.KIBANA_SERVICE_ADDRESS, index_type - ) + _configure_kibana_index_patterns(Envs.KIBANA_SERVICE_ADDRESS, index_type) # Filebeat's built-in ilm does not contain delete phase. Below will # add a delete phase to the existing policy. # NOTE: Due to compatibility, should put policy only when policy exists, # but no method to check existence. So use try-except to do the trick. - for filebeat_name in ('filebeat-7.7.1', 'filebeat-7.0.1'): - try: - es.ilm.get_lifecycle(policy=filebeat_name) - except exceptions.NotFoundError: - pass - else: - put_ilm(es, filebeat_name, hot_age='1d') + filebeat_name = 'filebeat' + try: + es.ilm.get_lifecycle(policy=filebeat_name) + except exceptions.NotFoundError: + pass + else: + put_ilm(es, filebeat_name, hot_age='1d') # Filebeat template and indices should be deployed during deployment. diff --git a/web_console_v2/api/fedlearner_webconsole/__init__.py b/web_console_v2/api/fedlearner_webconsole/__init__.py deleted file mode 100644 index cd7504799..000000000 --- a/web_console_v2/api/fedlearner_webconsole/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -from fedlearner_webconsole import auth diff --git a/web_console_v2/api/fedlearner_webconsole/app.py b/web_console_v2/api/fedlearner_webconsole/app.py index 618b13b30..247227d6c 100644 --- a/web_console_v2/api/fedlearner_webconsole/app.py +++ b/web_console_v2/api/fedlearner_webconsole/app.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -16,40 +16,66 @@ # pylint: disable=wrong-import-position, global-statement import logging import logging.config -import os -import traceback from http import HTTPStatus +from json import load +from pathlib import Path + +from apispec.ext.marshmallow import MarshmallowPlugin +from apispec_webframeworks.flask import FlaskPlugin +from flasgger import APISpec, Swagger from flask import Flask, jsonify from flask_restful import Api -from flask_jwt_extended import JWTManager -from envs import Envs -from fedlearner_webconsole.utils import metrics - -jwt = JWTManager() +from marshmallow import ValidationError +from sqlalchemy import inspect +from sqlalchemy.orm import Session +from webargs.flaskparser import parser +from envs import Envs +from fedlearner_webconsole.utils.hooks import pre_start_hook +from fedlearner_webconsole.composer.apis import initialize_composer_apis +from fedlearner_webconsole.cleanup.apis import initialize_cleanup_apis +from fedlearner_webconsole.audit.apis import initialize_audit_apis +from fedlearner_webconsole.auth.services import UserService +from fedlearner_webconsole.e2e.apis import initialize_e2e_apis +from fedlearner_webconsole.flag.apis import initialize_flags_apis +from fedlearner_webconsole.iam.apis import initialize_iams_apis +from fedlearner_webconsole.iam.client import create_iams_for_user +from fedlearner_webconsole.middleware.middlewares import flask_middlewares +from fedlearner_webconsole.setting.service import SettingService +from fedlearner_webconsole.swagger.models import schema_manager +from fedlearner_webconsole.utils import metrics, const from fedlearner_webconsole.auth.apis import initialize_auth_apis from fedlearner_webconsole.project.apis import initialize_project_apis -from fedlearner_webconsole.workflow_template.apis \ - import initialize_workflow_template_apis +from fedlearner_webconsole.participant.apis import initialize_participant_apis +from fedlearner_webconsole.utils.decorators.pp_flask import parser as custom_parser +from fedlearner_webconsole.utils.swagger import normalize_schema +from fedlearner_webconsole.workflow_template.apis import initialize_workflow_template_apis from fedlearner_webconsole.workflow.apis import initialize_workflow_apis from fedlearner_webconsole.dataset.apis import initialize_dataset_apis from fedlearner_webconsole.job.apis import initialize_job_apis from fedlearner_webconsole.setting.apis import initialize_setting_apis -from fedlearner_webconsole.mmgr.apis import initialize_mmgr_apis +from fedlearner_webconsole.mmgr.model_apis import initialize_mmgr_model_apis +from fedlearner_webconsole.mmgr.model_job_apis import initialize_mmgr_model_job_apis +from fedlearner_webconsole.mmgr.model_job_group_apis import initialize_mmgr_model_job_group_apis +from fedlearner_webconsole.algorithm.apis import initialize_algorithm_apis from fedlearner_webconsole.debug.apis import initialize_debug_apis +from fedlearner_webconsole.serving.apis import initialize_serving_services_apis from fedlearner_webconsole.sparkapp.apis import initialize_sparkapps_apis -from fedlearner_webconsole.rpc.server import rpc_server +from fedlearner_webconsole.file.apis import initialize_files_apis +from fedlearner_webconsole.tee.apis import initialize_tee_apis from fedlearner_webconsole.db import db -from fedlearner_webconsole.exceptions import (make_response, - WebConsoleApiException, - InvalidArgumentException, - NotFoundException) +from fedlearner_webconsole.exceptions import make_response, WebConsoleApiException, InvalidArgumentException from fedlearner_webconsole.scheduler.scheduler import scheduler -from fedlearner_webconsole.utils.k8s_watcher import k8s_watcher -from fedlearner_webconsole.auth.models import User, Session -from fedlearner_webconsole.composer.composer import composer -from logging_config import LOGGING_CONFIG +from fedlearner_webconsole.k8s.k8s_watcher import k8s_watcher +from logging_config import get_logging_config +from werkzeug.exceptions import HTTPException + + +@custom_parser.error_handler +@parser.error_handler +def handle_request_parsing_error(validation_error: ValidationError, *args, **kwargs): + raise InvalidArgumentException(details=validation_error.messages) def _handle_bad_request(error): @@ -63,17 +89,19 @@ def _handle_bad_request(error): return error -def _handle_not_found(error): - """Handles the not found exception raised by framework""" - if not isinstance(error, WebConsoleApiException): - return make_response(NotFoundException()) - return error +def _handle_wsgi_exception(error: HTTPException): + logging.exception('Wsgi exception: %s', str(error)) + response = jsonify( + code=error.code, + msg=str(error), + ) + response.status_code = error.code + return response def _handle_uncaught_exception(error): """A fallback catcher for all exceptions.""" - logging.error('Uncaught exception %s, stack trace:\n %s', str(error), - traceback.format_exc()) + logging.exception('Uncaught exception %s', str(error)) response = jsonify( code=500, msg='Unknown error', @@ -82,71 +110,82 @@ def _handle_uncaught_exception(error): return response -@jwt.unauthorized_loader -def _handle_unauthorized_request(reason): - response = jsonify(code=HTTPStatus.UNAUTHORIZED, msg=reason) - return response, HTTPStatus.UNAUTHORIZED - - -@jwt.invalid_token_loader -def _handle_invalid_jwt_request(reason): - response = jsonify(code=HTTPStatus.UNPROCESSABLE_ENTITY, msg=reason) - return response, HTTPStatus.UNPROCESSABLE_ENTITY - - -@jwt.expired_token_loader -def _handle_token_expired_request(expired_token): - response = jsonify(code=HTTPStatus.UNAUTHORIZED, msg='Token has expired') - return response, HTTPStatus.UNAUTHORIZED - - -@jwt.user_lookup_loader -def user_lookup_callback(jwt_header, jwt_data): - del jwt_header # Unused by user load. - - identity = jwt_data['sub'] - return User.query.filter_by(username=identity).one_or_none() - - -@jwt.token_in_blocklist_loader -def check_if_token_invalid(jwt_header, jwt_data): - del jwt_header # unused by check_if_token_invalid - - jti = jwt_data['jti'] - session = Session.query.filter_by(jti=jti).first() - return session is None +def _initial_iams_for_users(session: Session): + inspector = inspect(db.engine) + if inspector.has_table('users_v2'): + try: + users = UserService(session).get_all_users() + for u in users: + create_iams_for_user(u) + except Exception as e: # pylint: disable=broad-except + logging.warning('Initial iams failed, will be OK after db migration.') + + +def _init_swagger(app: Flask): + openapi_version = '3.0.3' + spec = APISpec(title='FedLearner WebConsole API Documentation', + version=SettingService.get_application_version().version.version, + openapi_version=openapi_version, + plugins=[FlaskPlugin(), MarshmallowPlugin()]) + schemas = schema_manager.get_schemas() + template = spec.to_flasgger(app, definitions=schemas, paths=[*app.view_functions.values()]) + app.config['SWAGGER'] = {'title': 'FedLearner WebConsole API Documentation', 'uiversion': 3} + for path in (Path(__file__).parent / 'proto' / 'jsonschemas').glob('**/*.json'): + with open(path, mode='r', encoding='utf-8') as file: + definitions = load(file)['definitions'] + definitions = normalize_schema(definitions, Path(path)) + template['components']['schemas'] = {**template['components']['schemas'], **definitions} + template['definitions'] = template['components']['schemas'] + Swagger(app, + template=template, + config={ + 'url_prefix': Envs.SWAGGER_URL_PREFIX, + 'openapi': openapi_version + }, + merge=True) def create_app(config): + pre_start_hook() # format logging - logging.config.dictConfig(LOGGING_CONFIG) + logging.config.dictConfig(get_logging_config()) - app = Flask('fedlearner_webconsole') + app = Flask('fedlearner_webconsole', root_path=Envs.BASE_DIR) app.config.from_object(config) - jwt.init_app(app) - # Error handlers app.register_error_handler(400, _handle_bad_request) - app.register_error_handler(404, _handle_not_found) app.register_error_handler(WebConsoleApiException, make_response) + app.register_error_handler(HTTPException, _handle_wsgi_exception) app.register_error_handler(Exception, _handle_uncaught_exception) - - # TODO(wangsen.0914): This will be removed sooner! - db.init_app(app) - - api = Api(prefix='/api/v2') + # TODO(xiangyuxuan.prs): Initial iams for all existed users, remove when not using memory-iams + with db.session_scope() as session: + _initial_iams_for_users(session) + api = Api(prefix=const.API_VERSION) + initialize_composer_apis(api) + initialize_cleanup_apis(api) initialize_auth_apis(api) initialize_project_apis(api) + initialize_participant_apis(api) initialize_workflow_template_apis(api) initialize_workflow_apis(api) initialize_job_apis(api) initialize_dataset_apis(api) initialize_setting_apis(api) - initialize_mmgr_apis(api) + initialize_mmgr_model_apis(api) + initialize_mmgr_model_job_apis(api) + initialize_mmgr_model_job_group_apis(api) + initialize_algorithm_apis(api) initialize_sparkapps_apis(api) - if os.environ.get('FLASK_ENV') != 'production' or Envs.DEBUG: + initialize_files_apis(api) + initialize_flags_apis(api) + initialize_serving_services_apis(api) + initialize_iams_apis(api) + initialize_e2e_apis(api) + initialize_tee_apis(api) + if Envs.FLASK_ENV != 'production' or Envs.DEBUG: initialize_debug_apis(api) + initialize_audit_apis(api) # A hack that use our customized error handlers # Ref: https://github.com/flask-restful/flask-restful/issues/280 handle_exception = app.handle_exception @@ -154,21 +193,16 @@ def create_app(config): api.init_app(app) app.handle_exception = handle_exception app.handle_user_exception = handle_user_exception - + if Envs.FLASK_ENV != 'production' or Envs.DEBUG: + _init_swagger(app) # Inits k8s related stuff first since something in composer # may depend on it - if Envs.FLASK_ENV == 'production' or Envs.K8S_CONFIG_PATH is not None: + if app.config.get('START_K8S_WATCHER', True): k8s_watcher.start() - - if app.config.get('START_GRPC_SERVER', True): - rpc_server.stop() - rpc_server.start(app) if app.config.get('START_SCHEDULER', True): scheduler.stop() - scheduler.start(app) - if app.config.get('START_COMPOSER', True): - with app.app_context(): - composer.run(db_engine=db.get_engine()) + scheduler.start() - metrics.emit_counter('create_app', 1) + metrics.emit_store('create_app', 1) + app = flask_middlewares.init_app(app) return app diff --git a/web_console_v2/api/fedlearner_webconsole/auth/apis.py b/web_console_v2/api/fedlearner_webconsole/auth/apis.py index 7206e3497..d288b5fdf 100644 --- a/web_console_v2/api/fedlearner_webconsole/auth/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/auth/apis.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,27 +13,32 @@ # limitations under the License. # coding: utf-8 -# pylint: disable=cyclic-import +import logging import re -import datetime from http import HTTPStatus -from flask import request -from flask_restful import Resource, reqparse -from flask_jwt_extended.utils import get_current_user -from flask_jwt_extended import create_access_token, decode_token, get_jwt - -from fedlearner_webconsole.utils.base64 import base64decode -from fedlearner_webconsole.utils.decorators import jwt_required -from fedlearner_webconsole.utils.decorators import admin_required +from flask import request +from flask_restful import Resource +from marshmallow import Schema, post_load, fields, validate, EXCLUDE +from marshmallow.decorators import validates_schema +from webargs.flaskparser import use_args + +from fedlearner_webconsole.audit.decorators import emits_event +from fedlearner_webconsole.auth.services import UserService, SessionService +from fedlearner_webconsole.iam.client import create_iams_for_user +from fedlearner_webconsole.proto import auth_pb2 +from fedlearner_webconsole.swagger.models import schema_manager + +from fedlearner_webconsole.utils.pp_base64 import base64decode +from fedlearner_webconsole.auth.third_party_sso import credentials_required, SsoHandlerFactory +from fedlearner_webconsole.utils.flask_utils import get_current_user, make_flask_response +from fedlearner_webconsole.utils.decorators.pp_flask import admin_required from fedlearner_webconsole.db import db -from fedlearner_webconsole.auth.models import (State, User, Role, - MUTABLE_ATTRS_MAPPER, Session) -from fedlearner_webconsole.exceptions import (NotFoundException, - InvalidArgumentException, - ResourceConflictException, - UnauthorizedException, - NoAccessException) +from fedlearner_webconsole.auth.models import (Role, MUTABLE_ATTRS_MAPPER) +from fedlearner_webconsole.exceptions import (NotFoundException, InvalidArgumentException, ResourceConflictException, + NoAccessException, UnauthorizedException) +from fedlearner_webconsole.utils.proto import to_dict +from fedlearner_webconsole.auth.third_party_sso import sso_info_manager # rule: password must have a letter, a num and a special character PASSWORD_FORMAT_L = re.compile(r'.*[A-Za-z]') @@ -41,10 +46,9 @@ PASSWORD_FORMAT_S = re.compile(r'.*[`!@#$%^&*()\-_=+|{}\[\];:\'\",<.>/?~]') -def check_password_format(password: str): +def _check_password_format(password: str): if not 8 <= len(password) <= 20: - raise InvalidArgumentException( - 'Password is not legal: 8 <= length <= 20') + raise InvalidArgumentException('Password is not legal: 8 <= length <= 20') required_chars = [] if PASSWORD_FORMAT_L.match(password) is None: required_chars.append('a letter') @@ -54,111 +58,159 @@ def check_password_format(password: str): required_chars.append('a special character') if required_chars: tip = ', '.join(required_chars) - raise InvalidArgumentException( - f'Password is not legal: must have {tip}.') + raise InvalidArgumentException(f'Password is not legal: must have {tip}.') -class SigninApi(Resource): - def post(self): - parser = reqparse.RequestParser() - parser.add_argument('username', - required=True, - help='username is empty') - parser.add_argument('password', - required=True, - help='password is empty') - data = parser.parse_args() - username = data['username'] - password = base64decode(data['password']) - user = User.query.filter_by(username=username).filter_by( - state=State.ACTIVE).first() - if user is None: - raise NotFoundException(f'Failed to find user: {username}') - if not user.verify_password(password): - raise UnauthorizedException('Invalid password') - token = create_access_token(identity=username) - decoded_token = decode_token(token) - - session = Session(jti=decoded_token.get('jti'), - expired_at=datetime.datetime.fromtimestamp( - decoded_token.get('exp'))) - db.session.add(session) - db.session.commit() - - return { - 'data': { - 'user': user.to_dict(), - 'access_token': token - } - }, HTTPStatus.OK - - @jwt_required() - def delete(self): - decoded_token = get_jwt() +class UserParameter(Schema): + username = fields.Str(required=True) + # Base64 encoded password + password = fields.Str(required=True, validate=lambda x: _check_password_format(base64decode(x))) + role = fields.Str(required=True, validate=validate.OneOf([x.name for x in Role])) + name = fields.Str(required=True, validate=validate.Length(min=1)) + email = fields.Str(required=True, validate=validate.Email()) + + @post_load + def make_user(self, data, **kwargs): + return auth_pb2.User(**data) + + +class SigninParameter(Schema): + username = fields.String() + password = fields.String() + code = fields.String() + ticket = fields.String() + + @validates_schema + def validate_schema(self, data, **kwargs): + del kwargs + if data.get('username') is None and data.get('code') is None and data.get('ticket') is None: + raise InvalidArgumentException('no credential detected') + + @post_load + def make_proto(self, data, **kwargs): + del kwargs + return auth_pb2.SigninParameter(**data) - jti = decoded_token.get('jti') - Session.query.filter_by(jti=jti).delete() - db.session.commit() - return {}, HTTPStatus.OK +class SigninApi(Resource): + + @use_args(SigninParameter(unknown=EXCLUDE), location='json_or_form') + def post(self, signin_parameter: auth_pb2.SigninParameter): + """Sign in to the system + --- + tags: + - auth + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/SigninParameter' + responses: + 200: + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + user: + type: object + properties: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.User' + """ + sso_name = request.args.get('sso_name') + return make_flask_response(SsoHandlerFactory.get_handler(sso_name).signin(signin_parameter)) + + @credentials_required + def delete(self): + """Sign out from the system + --- + tags: + - auth + parameters: + - in: header + name: Authorization + schema: + type: string + description: token used for current session + responses: + 200: + description: Signed out successfully + """ + user = get_current_user() + SsoHandlerFactory.get_handler(user.sso_name).signout() + return make_flask_response() class UsersApi(Resource): - @jwt_required() + + @credentials_required @admin_required def get(self): - return { - 'data': [ - row.to_dict() - for row in User.query.filter_by(state=State.ACTIVE).all() - ] - } - - @jwt_required() + """Get a list of all users + --- + tags: + - auth + responses: + 200: + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.User' + """ + with db.session_scope() as session: + return make_flask_response( + [row.to_dict() for row in UserService(session).get_all_users(filter_deleted=True)]) + + @credentials_required @admin_required - def post(self): - parser = reqparse.RequestParser() - parser.add_argument('username', - required=True, - help='username is empty') - parser.add_argument('password', - required=True, - help='password is empty') - parser.add_argument('role', required=True, help='role is empty') - parser.add_argument('name', required=True, help='name is empty') - parser.add_argument('email', required=True, help='email is empty') - - data = parser.parse_args() - username = data['username'] - password = base64decode(data['password']) - role = data['role'] - name = data['name'] - email = data['email'] - - check_password_format(password) - - if User.query.filter_by(username=username).first() is not None: - raise ResourceConflictException( - 'user {} already exists'.format(username)) - user = User(username=username, - role=role, - name=name, - email=email, - state=State.ACTIVE) - user.set_password(password) - db.session.add(user) - db.session.commit() - - return {'data': user.to_dict()}, HTTPStatus.CREATED + # if use_kwargs is used with explicit parameters, one has to write YAML document! + # Param: https://swagger.io/docs/specification/2-0/describing-parameters/ + # Body: https://swagger.io/docs/specification/2-0/describing-request-body/ + # Resp: https://swagger.io/docs/specification/2-0/describing-responses/ + @use_args(UserParameter(unknown=EXCLUDE)) + @emits_event() + def post(self, params: auth_pb2.User): + """Create a user + --- + tags: + - auth + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/UserParameter' + responses: + 201: + description: The user is created + content: + application/json: + schema: + $ref: '#/definitions/UserParameter' + 409: + description: A user with the same username exists + """ + # Swagger will detect APIs automatically, but params/req body/resp have to be defined manually + with db.session_scope() as session: + user = UserService(session).get_user_by_username(params.username) + if user is not None: + raise ResourceConflictException(f'user {user.username} already exists') + user = UserService(session).create_user_if_not_exists(username=params.username, + role=Role(params.role), + name=params.name, + email=params.email, + password=base64decode(params.password)) + session.commit() + return make_flask_response(user.to_dict(), status=HTTPStatus.CREATED) class UserApi(Resource): - def _find_user(self, user_id) -> User: - user = User.query.filter_by(id=user_id).first() - if user is None or user.state == State.DELETED: - raise NotFoundException( - f'Failed to find user_id: {user_id}') - return user def _check_current_user(self, user_id, msg): current_user = get_current_user() @@ -166,50 +218,188 @@ def _check_current_user(self, user_id, msg): and not user_id == current_user.id: raise NoAccessException(msg) - @jwt_required() + @credentials_required def get(self, user_id): - self._check_current_user(user_id, - 'user cannot get other user\'s information') - user = self._find_user(user_id) - return {'data': user.to_dict()}, HTTPStatus.OK - - @jwt_required() + """Get a user by id + --- + tags: + - auth + parameters: + - in: path + name: user_id + schema: + type: integer + responses: + 200: + description: The user is returned + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.User' + 404: + description: The user with specified ID is not found + """ + self._check_current_user(user_id, 'user cannot get other user\'s information') + + with db.session_scope() as session: + user = UserService(session).get_user_by_id(user_id, filter_deleted=True) + if user is None: + raise NotFoundException(f'Failed to find user_id: {user_id}') + return make_flask_response(user.to_dict()) + + @credentials_required + @emits_event() + # Example of manually defining an API def patch(self, user_id): - self._check_current_user(user_id, - 'user cannot modify other user\'s information') - user = self._find_user(user_id) - - mutable_attrs = MUTABLE_ATTRS_MAPPER.get(get_current_user().role) - - data = request.get_json() - for k, v in data.items(): - if k not in mutable_attrs: - raise InvalidArgumentException(f'cannot edit {k} attribute!') - if k == 'password': - password = base64decode(v) - check_password_format(password) - user.set_password(password) - else: - setattr(user, k, v) - - db.session.commit() - return {'data': user.to_dict()}, HTTPStatus.OK - - @jwt_required() + """Patch a user + --- + tags: + - auth + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + description: The ID of the user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.User' + responses: + 200: + description: The user is updated + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.User' + 404: + description: The user is not found + 400: + description: Attributes selected are uneditable + """ + self._check_current_user(user_id, 'user cannot modify other user\'s information') + + with db.session_scope() as session: + user = UserService(session).get_user_by_id(user_id, filter_deleted=True) + if user is None: + raise NotFoundException(f'Failed to find user_id: {user_id}') + + mutable_attrs = MUTABLE_ATTRS_MAPPER.get(get_current_user().role) + + data = request.get_json() + for k, v in data.items(): + if k not in mutable_attrs: + raise InvalidArgumentException(f'cannot edit {k} attribute!') + if k == 'password': + password = base64decode(v) + _check_password_format(password) + user.set_password(password) + SessionService(session).delete_session_by_user_id(user_id) + elif k == 'role': + user.role = Role(v) + else: + setattr(user, k, v) + create_iams_for_user(user) + session.commit() + return make_flask_response(user.to_dict()) + + @credentials_required @admin_required + @emits_event() def delete(self, user_id): - user = self._find_user(user_id) + """Delete the user with specified ID + --- + tags: + - auth + parameters: + - in: path + name: user_id + schema: + type: integer + responses: + 200: + description: The user with specified ID is deleted + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.User' + 400: + description: Cannot delete the user logged in within current session + 404: + description: The user with specified ID is not found + """ + with db.session_scope() as session: + user_service = UserService(session) + user = user_service.get_user_by_id(user_id, filter_deleted=True) + + if user is None: + raise NotFoundException(f'Failed to find user_id: {user_id}') + + current_user = get_current_user() + if current_user.id == user_id: + raise InvalidArgumentException('cannot delete yourself') + + user = UserService(session).delete_user(user) + session.commit() + return make_flask_response(user.to_dict()) + + +class SsoInfosApi(Resource): - current_user = get_current_user() - if current_user.id == user_id: - raise InvalidArgumentException('cannot delete yourself') - - user.state = State.DELETED - db.session.commit() - return {'data': user.to_dict()}, HTTPStatus.OK + def get(self): + """Get all available options of SSOs + --- + tags: + - auth + responses: + 200: + description: All options are returned + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.Sso' + """ + return make_flask_response([to_dict(sso, with_secret=False) for sso in sso_info_manager.sso_infos]) + + +class SelfUserApi(Resource): + + @credentials_required + def get(self): + """Get current user + --- + tags: + - auth + responses: + 200: + description: User logged in within current session is returned + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.User' + 400: + description: No user is logged in within current session + """ + user = get_current_user() + # Defensively program for unexpected exception + if user is None: + logging.error('No current user.') + raise UnauthorizedException('No current user.') + return make_flask_response(user.to_dict()) def initialize_auth_apis(api): api.add_resource(SigninApi, '/auth/signin') api.add_resource(UsersApi, '/auth/users') api.add_resource(UserApi, '/auth/users/') + api.add_resource(SsoInfosApi, '/auth/sso_infos') + api.add_resource(SelfUserApi, '/auth/self') + + # if a schema is used, one has to append it to schema_manager so Swagger knows there is a schema available + schema_manager.append(UserParameter) + schema_manager.append(SigninParameter) diff --git a/web_console_v2/api/fedlearner_webconsole/auth/models.py b/web_console_v2/api/fedlearner_webconsole/auth/models.py index cad09bc5c..4778c2f0b 100644 --- a/web_console_v2/api/fedlearner_webconsole/auth/models.py +++ b/web_console_v2/api/fedlearner_webconsole/auth/models.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ # coding: utf-8 import enum + from passlib.apps import custom_app_context as pwd_context from sqlalchemy.sql.schema import UniqueConstraint, Index from sqlalchemy.sql import func @@ -24,14 +25,16 @@ class Role(enum.Enum): - USER = 'user' - ADMIN = 'admin' + USER = 'USER' + ADMIN = 'ADMIN' +# yapf: disable MUTABLE_ATTRS_MAPPER = { Role.USER: ('password', 'name', 'email'), Role.ADMIN: ('password', 'role', 'name', 'email') } +# yapf: enable class State(enum.Enum): @@ -42,19 +45,27 @@ class State(enum.Enum): @to_dict_mixin(ignores=['password', 'state']) class User(db.Model): __tablename__ = 'users_v2' - __table_args__ = (UniqueConstraint('username', name='uniq_username'), - default_table_args('This is webconsole user table')) - id = db.Column(db.Integer, primary_key=True, comment='user id') + __table_args__ = (UniqueConstraint('username', + name='uniq_username'), default_table_args('This is webconsole user table')) + id = db.Column(db.Integer, primary_key=True, comment='user id', autoincrement=True) username = db.Column(db.String(255), comment='unique name of user') password = db.Column(db.String(255), comment='user password after encode') - role = db.Column(db.Enum(Role, native_enum=False), + role = db.Column(db.Enum(Role, native_enum=False, create_constraint=False, length=21), default=Role.USER, comment='role of user') name = db.Column(db.String(255), comment='name of user') email = db.Column(db.String(255), comment='email of user') - state = db.Column(db.Enum(State, native_enum=False), + state = db.Column(db.Enum(State, native_enum=False, create_constraint=False, length=21), default=State.ACTIVE, comment='state of user') + sso_name = db.Column(db.String(255), comment='sso_name') + last_sign_in_at = db.Column(db.DateTime(timezone=True), + nullable=True, + comment='the last time when user tries to sign in') + failed_sign_in_attempts = db.Column(db.Integer, + nullable=False, + default=0, + comment='failed sign in attempts since last successful sign in') def set_password(self, password): self.password = pwd_context.hash(password) @@ -63,17 +74,12 @@ def verify_password(self, password): return pwd_context.verify(password, self.password) +@to_dict_mixin(ignores=['expired_at', 'created_at']) class Session(db.Model): __tablename__ = 'session_v2' - __table_args__ = (Index('idx_jti', 'jti'), - default_table_args('This is webconsole session table')) - id = db.Column(db.Integer, - primary_key=True, - autoincrement=True, - comment='session id') + __table_args__ = (Index('idx_jti', 'jti'), default_table_args('This is webconsole session table')) + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='session id') jti = db.Column(db.String(64), comment='JWT jti') - expired_at = db.Column(db.DateTime(timezone=True), - comment='expired time, for db automatically clear') - created_at = db.Column(db.DateTime(timezone=True), - server_default=func.now(), - comment='created at') + user_id = db.Column(db.Integer, nullable=False, comment='for whom the session is created') + expired_at = db.Column(db.DateTime(timezone=True), comment='expired time, for db automatically clear') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created at') diff --git a/web_console_v2/api/fedlearner_webconsole/composer/composer.py b/web_console_v2/api/fedlearner_webconsole/composer/composer.py index e0040ba99..e23a0eec1 100644 --- a/web_console_v2/api/fedlearner_webconsole/composer/composer.py +++ b/web_console_v2/api/fedlearner_webconsole/composer/composer.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,69 +13,53 @@ # limitations under the License. # coding: utf-8 - -import json import logging -import time import threading import traceback from datetime import datetime -from typing import List, Optional +from envs import Envs from sqlalchemy import func from sqlalchemy.engine import Engine +from fedlearner_webconsole.composer.strategy import SingletonStrategy +from fedlearner_webconsole.proto import composer_pb2 +from fedlearner_webconsole.proto.composer_pb2 import PipelineContextData +from fedlearner_webconsole.utils import pp_time from fedlearner_webconsole.db import get_session from fedlearner_webconsole.composer.runner import global_runner_fn -from fedlearner_webconsole.composer.runner_cache import RunnerCache -from fedlearner_webconsole.composer.interface import IItem -from fedlearner_webconsole.composer.models import Context, decode_context, \ - ContextEncoder, SchedulerItem, ItemStatus, SchedulerRunner, RunnerStatus +from fedlearner_webconsole.composer.models import SchedulerItem, ItemStatus, SchedulerRunner, RunnerStatus +from fedlearner_webconsole.composer.pipeline import PipelineExecutor from fedlearner_webconsole.composer.op_locker import OpLocker from fedlearner_webconsole.composer.thread_reaper import ThreadReaper +import grpc +from concurrent import futures +from grpc_health.v1 import health +from grpc_health.v1 import health_pb2_grpc class ComposerConfig(object): + def __init__( self, runner_fn: dict, - name='default_name', - worker_num=10, + name: str = 'default_name', + worker_num: int = 20, ): """Config for composer Args: - runner_fn: runner functions - name: composer name - worker_num: number of worker doing heavy job + runner_fn (dict): runner functions + name (str): composer name + worker_num (int): number of worker doing heavy job """ self.runner_fn = runner_fn self.name = name self.worker_num = worker_num -class Pipeline(object): - def __init__(self, name: str, deps: List[str], meta: dict): - """Define the deps of scheduler item - - Fields: - name: pipeline name - deps: items to be processed in order - meta: additional info - """ - self.name = name - self.deps = deps - self.meta = meta - - -class PipelineEncoder(json.JSONEncoder): - def default(self, obj): - return obj.__dict__ - - class Composer(object): - # attributes that you can patch - MUTABLE_ITEM_KEY = ['interval_time', 'retry_cnt'] + LOOP_INTERVAL = 5 def __init__(self, config: ComposerConfig): """Composer @@ -85,18 +69,37 @@ def __init__(self, config: ComposerConfig): """ self.config = config self.name = config.name - self.runner_fn = config.runner_fn self.db_engine = None self.thread_reaper = ThreadReaper(worker_num=config.worker_num) - self.runner_cache = RunnerCache(runner_fn=config.runner_fn) + self.pipeline_executor = PipelineExecutor( + thread_reaper=self.thread_reaper, + db_engine=self.db_engine, + runner_fns=config.runner_fn, + ) self.lock = threading.Lock() self._stop = False + self._loop_thread = None + self._grpc_server_thread = None def run(self, db_engine: Engine): self.db_engine = db_engine + self.pipeline_executor.db_engine = db_engine logging.info(f'[composer] starting {self.name}...') - loop = threading.Thread(target=self._loop, args=[], daemon=True) - loop.start() + self._loop_thread = threading.Thread(target=self._loop, args=[], daemon=True) + self._loop_thread.start() + self._grpc_server_thread = threading.Thread(target=self._run, args=[], daemon=True) + self._grpc_server_thread.start() + + def wait_for_termination(self): + self._loop_thread.join() + self._grpc_server_thread.join() + + def _run(self): + grpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + health_pb2_grpc.add_HealthServicer_to_server(health.HealthServicer(), grpc_server) + grpc_server.add_insecure_port(f'[::]:{Envs.COMPOSER_LISTEN_PORT}') + grpc_server.start() + grpc_server.wait_for_termination() def _loop(self): while True: @@ -111,300 +114,106 @@ def _loop(self): self._check_init_runners() self._check_running_runners() except Exception as e: # pylint: disable=broad-except - logging.error(f'[composer] something wrong, exception: {e}, ' - f'trace: {traceback.format_exc()}') - time.sleep(5) + logging.error(f'[composer] something wrong, exception: {e}, ' f'trace: {traceback.format_exc()}') + pp_time.sleep(self.LOOP_INTERVAL) def stop(self): logging.info(f'[composer] stopping {self.name}...') with self.lock: self._stop = True - - def collect(self, - name: str, - items: List[IItem], - metadata: dict, - interval: int = -1): - """Collect scheduler item - - Args: - name: item name, should be unique - items: specify dependencies - metadata: pass metadata to share with item dependencies each other - interval: if value is -1, it's run-once job, or run - every interval time in seconds - """ - if len(name) == 0: - return - valid_interval = interval == -1 or interval >= 10 - if not valid_interval: # seems non-sense if interval is less than 10 - raise ValueError('interval should not less than 10 if not -1') - with get_session(self.db_engine) as session: - # check name if exists - existed = session.query(SchedulerItem).filter_by(name=name).first() - if existed: - return - item = SchedulerItem( - name=name, - pipeline=PipelineEncoder().encode( - self._build_pipeline(name, items, metadata)), - interval_time=interval, - ) - session.add(item) - try: - session.commit() - except Exception as e: # pylint: disable=broad-except - logging.error(f'[composer] failed to create scheduler_item, ' - f'name: {name}, exception: {e}') - session.rollback() - - def finish(self, name: str): - """Finish item - - Args: - name: item name - """ - with get_session(self.db_engine) as session: - existed = session.query(SchedulerItem).filter_by( - name=name, status=ItemStatus.ON.value).first() - if not existed: - return - existed.status = ItemStatus.OFF.value - try: - session.commit() - except Exception as e: # pylint: disable=broad-except - logging.error(f'[composer] failed to finish scheduler_item, ' - f'name: {name}, exception: {e}') - session.rollback() - - def get_item_status(self, name: str) -> Optional[ItemStatus]: - """Get item status - - Args: - name: item name - """ - with get_session(self.db_engine) as session: - existed = session.query(SchedulerItem).filter( - SchedulerItem.name == name).first() - if not existed: - return None - return ItemStatus(existed.status) - - def patch_item_attr(self, name: str, key: str, value: str): - """ patch item args - - Args: - name (str): name of this item - key (str): key you want to update - value (str): value you wnat to set - - Returns: - Raise if some check violates - """ - if key not in self.__class__.MUTABLE_ITEM_KEY: - raise ValueError(f'fail to change attribute {key}') - - with get_session(self.db_engine) as session: - item: SchedulerItem = session.query(SchedulerItem).filter( - SchedulerItem.name == name).first() - if not item: - raise ValueError(f'cannot find item {name}') - setattr(item, key, value) - session.add(item) - try: - session.commit() - except Exception as e: # pylint: disable=broad-except - logging.error(f'[composer] failed to patch item attr, ' - f'name: {name}, exception: {e}') - session.rollback() - - def get_recent_runners(self, - name: str, - count: int = 10) -> List[SchedulerRunner]: - """Get recent runners order by created_at in desc - - Args: - name: item name - count: the number of runners - """ - with get_session(self.db_engine) as session: - runners = session.query(SchedulerRunner).join( - SchedulerItem, - SchedulerItem.id == SchedulerRunner.item_id).filter( - SchedulerItem.name == name).order_by( - SchedulerRunner.created_at.desc()).limit(count) - if not runners: - return [] - return runners + if self._loop_thread is not None: + self._loop_thread.join(timeout=self.LOOP_INTERVAL * 2) def _check_items(self): with get_session(self.db_engine) as session: - items = session.query(SchedulerItem).filter_by( - status=ItemStatus.ON.value).all() + items = session.query(SchedulerItem).filter_by(status=ItemStatus.ON.value).all() for item in items: - if not item.need_run(): + if not SingletonStrategy(session).should_run(item): + continue + + pipeline: composer_pb2.Pipeline = item.get_pipeline() + if pipeline.version != 2: + logging.error(f'[Composer] Invalid pipeline in item {item.id}') + item.status = ItemStatus.OFF.value + session.commit() continue - # NOTE: use `func.now()` to let sqlalchemy handles + runner = SchedulerRunner(item_id=item.id) + runner.set_pipeline(pipeline) + runner.set_context(PipelineContextData()) + session.add(runner) + + # NOTE: use sqlalchemy's `func.now()` to let it handles # the timezone. item.last_run_at = func.now() - if item.interval_time < 0: + if not item.cron_config: # finish run-once item automatically item.status = ItemStatus.OFF.value - pp = Pipeline(**(json.loads(item.pipeline))) - context = Context(data=pp.meta, - internal={}, - db_engine=self.db_engine) - runner = SchedulerRunner( - item_id=item.id, - pipeline=item.pipeline, - context=ContextEncoder().encode(context), - ) - session.add(runner) - try: - logging.info( - f'[composer] insert runner, item_id: {item.id}') - session.commit() - except Exception as e: # pylint: disable=broad-except - logging.error( - f'[composer] failed to create scheduler_runner, ' - f'item_id: {item.id}, exception: {e}') - session.rollback() + + logging.info(f'[composer] insert runner, item_id: {item.id}') + session.commit() def _check_init_runners(self): with get_session(self.db_engine) as session: - init_runners = session.query(SchedulerRunner).filter_by( - status=RunnerStatus.INIT.value).all() + init_runner_ids = session.query(SchedulerRunner.id).filter_by(status=RunnerStatus.INIT.value).all() # TODO: support priority - for runner in init_runners: - # if thread_reaper is full, skip this round and - # wait next checking - if self.thread_reaper.is_full(): - return - lock_name = f'check_init_runner_{runner.id}_lock' - check_lock = OpLocker(lock_name, self.db_engine).try_lock() - if not check_lock: - logging.error(f'[composer] failed to lock, ' - f'ignore current init_runner_{runner.id}') - continue - pipeline = Pipeline(**(json.loads(runner.pipeline))) - context = decode_context(val=runner.context, - db_engine=self.db_engine) - # find the first job in pipeline - first = pipeline.deps[0] + for runner_id, *_ in init_runner_ids: + # if thread_reaper is full, skip this round and + # wait next checking + if self.thread_reaper.is_full(): + logging.info('[composer] thread_reaper is full now, waiting for other item finish') + return + lock_name = f'check_init_runner_{runner_id}_lock' + check_lock = OpLocker(lock_name, self.db_engine).try_lock() + if not check_lock: + logging.error(f'[composer] failed to lock, ignore current init_runner_{runner_id}') + continue + with get_session(self.db_engine) as session: + runner: SchedulerRunner = session.query(SchedulerRunner).get(runner_id) # update status runner.start_at = func.now() runner.status = RunnerStatus.RUNNING.value - output = json.loads(runner.output) - output[first] = {'status': RunnerStatus.RUNNING.value} - runner.output = json.dumps(output) - # record current running job - context.set_internal('current', first) - runner.context = ContextEncoder().encode(context) - # start runner - runner_fn = self.runner_cache.find_runner(runner.id, first) - self.thread_reaper.enqueue(name=lock_name, - fn=runner_fn, - context=context) + pipeline: composer_pb2.Pipeline = runner.get_pipeline() + if pipeline.version != 2: + logging.error(f'[Composer] Invalid pipeline in runner {runner.id}') + runner.status = RunnerStatus.FAILED.value + session.commit() + continue try: - logging.info( - f'[composer] update runner, status: {runner.status}, ' - f'pipeline: {runner.pipeline}, ' - f'output: {output}, context: {runner.context}') + logging.info(f'[composer] update runner, status: {runner.status}, ' + f'pipeline: {runner.pipeline}, ' + f'context: {runner.context}') if check_lock.is_latest_version() and \ check_lock.update_version(): session.commit() else: - logging.error(f'[composer] {lock_name} is outdated, ' - f'ignore updates to database') + logging.error(f'[composer] {lock_name} is outdated, ignore updates to database') except Exception as e: # pylint: disable=broad-except - logging.error(f'[composer] failed to update init runner' - f'status, exception: {e}') + logging.error(f'[composer] failed to update init runner status, exception: {e}') session.rollback() def _check_running_runners(self): with get_session(self.db_engine) as session: - running_runners = session.query(SchedulerRunner).filter_by( - status=RunnerStatus.RUNNING.value).all() - for runner in running_runners: - if self.thread_reaper.is_full(): - return - lock_name = f'check_running_runner_{runner.id}_lock' - check_lock = OpLocker(lock_name, self.db_engine).try_lock() - if not check_lock: - logging.error(f'[composer] failed to lock, ' - f'ignore current running_runner_{runner.id}') - continue + running_runner_ids = session.query(SchedulerRunner.id).filter_by(status=RunnerStatus.RUNNING.value).all() + for runner_id, *_ in running_runner_ids: + if self.thread_reaper.is_full(): + logging.info('[composer] thread_reaper is full now, waiting for other item finish') + return + lock_name = f'check_running_runner_{runner_id}_lock' + check_lock = OpLocker(lock_name, self.db_engine).try_lock() + if not check_lock: + logging.error(f'[composer] failed to lock, ' f'ignore current running_runner_{runner_id}') + continue + with get_session(self.db_engine) as session: # TODO: restart runner if exit unexpectedly - pipeline = Pipeline(**(json.loads(runner.pipeline))) - output = json.loads(runner.output) - context = decode_context(val=runner.context, - db_engine=self.db_engine) - current = context.internal['current'] - runner_fn = self.runner_cache.find_runner(runner.id, current) - # check status of current one - status, current_output = runner_fn.result(context) - if status == RunnerStatus.RUNNING: - continue # ignore - if status == RunnerStatus.DONE: - output[current] = {'status': RunnerStatus.DONE.value} - context.set_internal(f'output_{current}', current_output) - current_idx = pipeline.deps.index(current) - if current_idx == len(pipeline.deps) - 1: # all done - runner.status = RunnerStatus.DONE.value - runner.end_at = func.now() - else: # run next one - next_one = pipeline.deps[current_idx + 1] - output[next_one] = { - 'status': RunnerStatus.RUNNING.value - } - context.set_internal('current', next_one) - next_runner_fn = self.runner_cache.find_runner( - runner.id, next_one) - self.thread_reaper.enqueue(name=lock_name, - fn=next_runner_fn, - context=context) - elif status == RunnerStatus.FAILED: - # TODO: abort now, need retry - output[current] = {'status': RunnerStatus.FAILED.value} - context.set_internal(f'output_{current}', current_output) + runner = session.query(SchedulerRunner).get(runner_id) + pipeline = runner.get_pipeline() + if pipeline.version != 2: + logging.error(f'[Composer] Invalid pipeline in runner {runner.id}') runner.status = RunnerStatus.FAILED.value - runner.end_at = func.now() - - runner.pipeline = PipelineEncoder().encode(pipeline) - runner.output = json.dumps(output) - runner.context = ContextEncoder().encode(context) - - updated_db = False - try: - logging.info( - f'[composer] update runner, status: {runner.status}, ' - f'pipeline: {runner.pipeline}, ' - f'output: {output}, context: {runner.context}') - if check_lock.is_latest_version(): - if check_lock.update_version(): - session.commit() - updated_db = True - else: - logging.error(f'[composer] {lock_name} is outdated, ' - f'ignore updates to database') - except Exception as e: # pylint: disable=broad-except - logging.error(f'[composer] failed to update running ' - f'runner status, exception: {e}') - session.rollback() - - # delete useless runner obj in runner cache - if status in (RunnerStatus.DONE, - RunnerStatus.FAILED) and updated_db: - self.runner_cache.del_runner(runner.id, current) - - @staticmethod - def _build_pipeline(name: str, items: List[IItem], - metadata: dict) -> Pipeline: - deps = [] - for item in items: - deps.append(f'{item.type().value}_{item.get_id()}') - return Pipeline(name=name, deps=deps, meta=metadata) + session.commit() + continue + # If the runner is running, we always try to run it. + self.pipeline_executor.run(runner_id) -composer = Composer(config=ComposerConfig( - runner_fn=global_runner_fn(), name='scheduler for fedlearner webconsole')) +composer = Composer(config=ComposerConfig(runner_fn=global_runner_fn(), name='scheduler for fedlearner webconsole')) diff --git a/web_console_v2/api/fedlearner_webconsole/composer/interface.py b/web_console_v2/api/fedlearner_webconsole/composer/interface.py index f9acdeb88..001eafe79 100644 --- a/web_console_v2/api/fedlearner_webconsole/composer/interface.py +++ b/web_console_v2/api/fedlearner_webconsole/composer/interface.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,20 +19,38 @@ import enum from typing import Tuple -from fedlearner_webconsole.composer.models import Context, RunnerStatus +from fedlearner_webconsole.composer.context import RunnerContext +from fedlearner_webconsole.composer.models import RunnerStatus + +from fedlearner_webconsole.proto.composer_pb2 import RunnerOutput # NOTE: remember to register new item in `global_runner_fn` \ # which defined in `runner.py` class ItemType(enum.Enum): TASK = 'task' # test only - MEMORY = 'memory' - WORKFLOW_CRON_JOB = 'workflow_cron_job' - DATA_PIPELINE = 'data_pipeline' + WORKFLOW_CRON_JOB = 'workflow_cron_job' # v2 + BATCH_STATS = 'batch_stats' # v2 + SERVING_SERVICE_PARSE_SIGNATURE = 'serving_service_parse_signature' # v2 + SERVING_SERVICE_QUERY_PARTICIPANT_STATUS = 'serving_service_query_participant_status' # v2 + SERVING_SERVICE_UPDATE_MODEL = 'serving_service_update_model' # v2 + SCHEDULE_WORKFLOW = 'schedule_workflow' # v2 + SCHEDULE_JOB = 'schedule_job' # v2 + CLEANUP_CRON_JOB = 'cleanup_cron_job' # v2 + MODEL_TRAINING_CRON_JOB = 'model_training_cron_job' # v2 + TEE_CREATE_RUNNER = 'tee_create_runner' # v2 + TEE_RESOURCE_CHECK_RUNNER = 'tee_resource_check_runner' # v2 + SCHEDULE_PROJECT = 'schedule_project' # v2 + DATASET_LONG_PERIOD_SCHEDULER = 'dataset_long_period_scheduler' # v2 + DATASET_SHORT_PERIOD_SCHEDULER = 'dataset_short_period_scheduler' # v2 + SCHEDULE_MODEL_JOB = 'schedule_model_job' # v2 + SCHEDULE_MODEL_JOB_GROUP = 'schedule_model_job_group' # v2 + SCHEDULE_LONG_PERIOD_MODEL_JOB_GROUP = 'schedule_long_period_model_job_group' # v2 # item interface class IItem(metaclass=ABCMeta): + @abstractmethod def type(self) -> ItemType: pass @@ -42,27 +60,16 @@ def get_id(self) -> int: pass -# runner interface -class IRunner(metaclass=ABCMeta): - @abstractmethod - def start(self, context: Context): - """Start runner - - Args: - context: shared in runner. Don't write data to context in this - method. Only can read data via `context.data`. - """ +class IRunnerV2(metaclass=ABCMeta): @abstractmethod - def result(self, context: Context) -> Tuple[RunnerStatus, dict]: - """Check runner result + def run(self, context: RunnerContext) -> Tuple[RunnerStatus, RunnerOutput]: + """Runs the runner. - NOTE: You could check runner if is timeout in this method. If it's - timeout, return `RunnerStatus.FAILED`. Since runners executed by - `ThreadPoolExecutor` may have some common resources, it's better to - stop the runner by user instead of `composer`. + The implementation should be light, as runners will be executed by `ThreadPoolExecutor`. Args: - context: shared in runner. In this method, data can be - read or written to context via `context.data`. + context: immutable context in the runner. + Returns: + status and the output. """ diff --git a/web_console_v2/api/fedlearner_webconsole/composer/models.py b/web_console_v2/api/fedlearner_webconsole/composer/models.py index 4cf88ac7e..17e8c5c11 100644 --- a/web_console_v2/api/fedlearner_webconsole/composer/models.py +++ b/web_console_v2/api/fedlearner_webconsole/composer/models.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -16,17 +16,24 @@ import enum import json -import datetime import logging - -from sqlalchemy import UniqueConstraint +from datetime import timezone, datetime +from sqlalchemy import UniqueConstraint, Index from sqlalchemy.engine import Engine from sqlalchemy.sql import func +from croniter import croniter from fedlearner_webconsole.db import db, default_table_args +from fedlearner_webconsole.proto import composer_pb2 +from fedlearner_webconsole.utils.pp_datetime import now +from fedlearner_webconsole.utils.mixins import to_dict_mixin +from fedlearner_webconsole.utils.proto import to_json, parse_from_json +from fedlearner_webconsole.proto.composer_pb2 import SchedulerItemPb, SchedulerRunnerPb +from fedlearner_webconsole.utils.pp_datetime import to_timestamp class Context(object): + def __init__(self, data: dict, internal: dict, db_engine: Engine): self._data = data # user data self._internal = internal # internal system data @@ -52,24 +59,21 @@ def db_engine(self) -> Engine: class ContextEncoder(json.JSONEncoder): - def default(self, obj) -> dict: - d = obj.__dict__ - return { - '_data': d.get('_data', {}), - '_internal': d.get('_internal', {}) - } + + def default(self, o) -> dict: + d = o.__dict__ + return {'_data': d.get('_data', {}), '_internal': d.get('_internal', {})} class ContextDecoder(json.JSONDecoder): + def __init__(self, db_engine: Engine): self.db_engine = db_engine super().__init__(object_hook=self.dict2object) def dict2object(self, val): if '_data' in val and '_internal' in val: - return Context(data=val.get('_data', {}), - internal=val.get('_internal', {}), - db_engine=self.db_engine) + return Context(data=val.get('_data', {}), internal=val.get('_internal', {}), db_engine=self.db_engine) return val @@ -84,37 +88,24 @@ class ItemStatus(enum.Enum): ON = 1 # need to run +@to_dict_mixin(extras={'need_run': (lambda si: si.need_run())}) class SchedulerItem(db.Model): __tablename__ = 'scheduler_item_v2' - __table_args__ = (UniqueConstraint('name', name='uniq_name'), - default_table_args('scheduler items')) - id = db.Column(db.Integer, - comment='id', - primary_key=True, - autoincrement=True) + __table_args__ = ( + UniqueConstraint('name', name='uniq_name'), + # idx_status is a common name will may cause conflict in sqlite + Index('idx_item_status', 'status'), + default_table_args('scheduler items'), + ) + id = db.Column(db.Integer, comment='id', primary_key=True, autoincrement=True) name = db.Column(db.String(255), comment='item name', nullable=False) - pipeline = db.Column(db.Text, - comment='pipeline', - nullable=False, - default='{}') - status = db.Column(db.Integer, - comment='item status', - nullable=False, - default=ItemStatus.ON.value) - interval_time = db.Column(db.Integer, - comment='item run interval in second', - nullable=False, - default=-1) - last_run_at = db.Column(db.DateTime(timezone=True), - comment='last runner time') - retry_cnt = db.Column(db.Integer, - comment='retry count when item is failed', - nullable=False, - default=0) + pipeline = db.Column(db.Text(16777215), comment='pipeline', nullable=False, default='{}') + status = db.Column(db.Integer, comment='item status', nullable=False, default=ItemStatus.ON.value) + cron_config = db.Column(db.String(255), comment='cron expression in UTC timezone') + last_run_at = db.Column(db.DateTime(timezone=True), comment='last runner time') + retry_cnt = db.Column(db.Integer, comment='retry count when item is failed', nullable=False, default=0) extra = db.Column(db.Text(), comment='extra info') - created_at = db.Column(db.DateTime(timezone=True), - comment='created at', - server_default=func.now()) + created_at = db.Column(db.DateTime(timezone=True), comment='created at', server_default=func.now()) updated_at = db.Column(db.DateTime(timezone=True), comment='updated at', server_default=func.now(), @@ -122,23 +113,40 @@ class SchedulerItem(db.Model): deleted_at = db.Column(db.DateTime(timezone=True), comment='deleted at') def need_run(self) -> bool: - # job runs one time - if self.interval_time == -1 and self.last_run_at is None: - return True - if self.interval_time > 0: # cronjob - if self.last_run_at is None: # never run - return True - # compare datetime in utc - next_run_at = self.last_run_at.replace( - tzinfo=datetime.timezone.utc) + datetime.timedelta( - seconds=self.interval_time) - utc_now = datetime.datetime.now(datetime.timezone.utc) - logging.debug(f'[composer] item id: {self.id}, ' - f'next_run_at: {next_run_at.timestamp()}, ' - f'utc_now: {utc_now.timestamp()}') - if next_run_at.timestamp() < utc_now.timestamp(): - return True - return False + if not self.cron_config: + # job runs once + return self.last_run_at is None + # cronjob + if self.last_run_at is None: # never run + # if there is no start time, croniter will return next run + # datetime (UTC) based on create time + base = self.created_at.replace(tzinfo=timezone.utc) + else: + base = self.last_run_at.replace(tzinfo=timezone.utc) + next_run_at = croniter(self.cron_config, base).get_next(datetime) + utc_now = now(timezone.utc) + logging.debug(f'[composer] item id: {self.id}, ' + f'next_run_at: {next_run_at.timestamp()}, ' + f'utc_now: {utc_now.timestamp()}') + return next_run_at.timestamp() < utc_now.timestamp() + + def set_pipeline(self, proto: composer_pb2.Pipeline): + self.pipeline = to_json(proto) + + def get_pipeline(self) -> composer_pb2.Pipeline: + return parse_from_json(self.pipeline, composer_pb2.Pipeline()) + + def to_proto(self) -> SchedulerItemPb: + return SchedulerItemPb(id=self.id, + name=self.name, + pipeline=self.get_pipeline(), + status=ItemStatus(self.status).name, + cron_config=self.cron_config, + last_run_at=to_timestamp(self.last_run_at) if self.last_run_at else None, + retry_cnt=self.retry_cnt, + created_at=to_timestamp(self.created_at) if self.created_at else None, + updated_at=to_timestamp(self.updated_at) if self.updated_at else None, + deleted_at=to_timestamp(self.deleted_at) if self.deleted_at else None) class RunnerStatus(enum.Enum): @@ -148,43 +156,62 @@ class RunnerStatus(enum.Enum): FAILED = 3 +@to_dict_mixin() class SchedulerRunner(db.Model): __tablename__ = 'scheduler_runner_v2' - __table_args__ = (default_table_args('scheduler runners')) - id = db.Column(db.Integer, - comment='id', - primary_key=True, - autoincrement=True) + __table_args__ = ( + # idx_status is a common name will may cause conflict in sqlite + Index('idx_runner_status', 'status'), + Index('idx_runner_item_id', 'item_id'), + default_table_args('scheduler runners'), + ) + id = db.Column(db.Integer, comment='id', primary_key=True, autoincrement=True) item_id = db.Column(db.Integer, comment='item id', nullable=False) - status = db.Column(db.Integer, - comment='runner status', - nullable=False, - default=RunnerStatus.INIT.value) - start_at = db.Column(db.DateTime(timezone=True), - comment='runner start time') + status = db.Column(db.Integer, comment='runner status', nullable=False, default=RunnerStatus.INIT.value) + start_at = db.Column(db.DateTime(timezone=True), comment='runner start time') end_at = db.Column(db.DateTime(timezone=True), comment='runner end time') - pipeline = db.Column(db.Text(), - comment='pipeline from scheduler item', - nullable=False, - default='{}') - output = db.Column(db.Text(), - comment='output', - nullable=False, - default='{}') - context = db.Column(db.Text(), - comment='context', - nullable=False, - default='{}') + pipeline = db.Column(db.Text(16777215), comment='pipeline from scheduler item', nullable=False, default='{}') + output = db.Column(db.Text(), comment='output', nullable=False, default='{}') + context = db.Column(db.Text(16777215), comment='context', nullable=False, default='{}') extra = db.Column(db.Text(), comment='extra info') - created_at = db.Column(db.DateTime(timezone=True), - comment='created at', - server_default=func.now()) + created_at = db.Column(db.DateTime(timezone=True), comment='created at', server_default=func.now()) updated_at = db.Column(db.DateTime(timezone=True), comment='updated at', server_default=func.now(), onupdate=func.now()) deleted_at = db.Column(db.DateTime(timezone=True), comment='deleted at') + def set_pipeline(self, proto: composer_pb2.Pipeline): + self.pipeline = to_json(proto) + + def get_pipeline(self) -> composer_pb2.Pipeline: + return parse_from_json(self.pipeline, composer_pb2.Pipeline()) + + def set_context(self, proto: composer_pb2.PipelineContextData): + self.context = to_json(proto) + + def get_context(self) -> composer_pb2.PipelineContextData: + return parse_from_json(self.context, composer_pb2.PipelineContextData()) + + def set_output(self, proto: composer_pb2.RunnerOutput): + self.output = to_json(proto) + + def get_output(self) -> composer_pb2.RunnerOutput: + return parse_from_json(self.output, composer_pb2.RunnerOutput()) + + def to_proto(self) -> SchedulerRunnerPb: + return SchedulerRunnerPb(id=self.id, + item_id=self.item_id, + status=RunnerStatus(self.status).name, + start_at=to_timestamp(self.start_at) if self.start_at else None, + end_at=to_timestamp(self.end_at) if self.end_at else None, + pipeline=self.get_pipeline(), + output=self.get_output(), + context=self.get_context(), + created_at=to_timestamp(self.created_at) if self.created_at else None, + updated_at=to_timestamp(self.updated_at) if self.updated_at else None, + deleted_at=to_timestamp(self.deleted_at) if self.deleted_at else None) + class OptimisticLock(db.Model): __tablename__ = 'optimistic_lock_v2' @@ -192,15 +219,10 @@ class OptimisticLock(db.Model): UniqueConstraint('name', name='uniq_name'), default_table_args('optimistic lock'), ) - id = db.Column(db.Integer, - comment='id', - primary_key=True, - autoincrement=True) + id = db.Column(db.Integer, comment='id', primary_key=True, autoincrement=True) name = db.Column(db.String(255), comment='lock name', nullable=False) version = db.Column(db.BIGINT, comment='lock version', nullable=False) - created_at = db.Column(db.DateTime(timezone=True), - comment='created at', - server_default=func.now()) + created_at = db.Column(db.DateTime(timezone=True), comment='created at', server_default=func.now()) updated_at = db.Column(db.DateTime(timezone=True), comment='updated at', server_default=func.now(), diff --git a/web_console_v2/api/fedlearner_webconsole/composer/op_locker.py b/web_console_v2/api/fedlearner_webconsole/composer/op_locker.py index 8b0bdd404..1c4c09e77 100644 --- a/web_console_v2/api/fedlearner_webconsole/composer/op_locker.py +++ b/web_console_v2/api/fedlearner_webconsole/composer/op_locker.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,11 +23,13 @@ class OpLocker(object): + def __init__(self, name: str, db_engine: Engine): """Optimistic Lock Args: - name: lock name should be unique in same thread + name (str): lock name should be unique in same thread + db_engine (Engine): db engine """ self._name = name self._version = 0 @@ -45,14 +47,12 @@ def version(self) -> int: def try_lock(self) -> 'OpLocker': with get_session(self.db_engine) as session: try: - lock = session.query(OptimisticLock).filter_by( - name=self._name).first() + lock = session.query(OptimisticLock).filter_by(name=self._name).first() if lock: self._has_lock = True self._version = lock.version return self - new_lock = OptimisticLock(name=self._name, - version=self._version) + new_lock = OptimisticLock(name=self._name, version=self._version) session.add(new_lock) session.commit() self._has_lock = True @@ -67,16 +67,13 @@ def is_latest_version(self) -> bool: with get_session(self.db_engine) as session: try: - new_lock = session.query(OptimisticLock).filter_by( - name=self._name).first() + new_lock = session.query(OptimisticLock).filter_by(name=self._name).first() if not new_lock: return False - logging.info(f'[op_locker] version, current: {self._version}, ' - f'new: {new_lock.version}') + logging.info(f'[op_locker] version, current: {self._version}, ' f'new: {new_lock.version}') return self._version == new_lock.version except Exception as e: # pylint: disable=broad-except - logging.error( - f'failed to check lock is conflict, exception: {e}') + logging.error(f'failed to check lock is conflict, exception: {e}') return False def update_version(self) -> bool: @@ -86,8 +83,7 @@ def update_version(self) -> bool: with get_session(self.db_engine) as session: try: - lock = session.query(OptimisticLock).filter_by( - name=self._name).first() + lock = session.query(OptimisticLock).filter_by(name=self._name).first() lock.version = self._version + 1 session.commit() return True diff --git a/web_console_v2/api/fedlearner_webconsole/composer/runner.py b/web_console_v2/api/fedlearner_webconsole/composer/runner.py index 46d87ec20..2807e7ab3 100644 --- a/web_console_v2/api/fedlearner_webconsole/composer/runner.py +++ b/web_console_v2/api/fedlearner_webconsole/composer/runner.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,78 +13,45 @@ # limitations under the License. # coding: utf-8 -import datetime import logging -import random import sys -import time -from typing import Tuple -from fedlearner_webconsole.composer.interface import IItem, IRunner, ItemType -from fedlearner_webconsole.composer.models import Context, RunnerStatus, \ - SchedulerRunner -from fedlearner_webconsole.dataset.data_pipeline import DataPipelineRunner -from fedlearner_webconsole.db import get_session +from fedlearner_webconsole.composer.interface import ItemType +from fedlearner_webconsole.dataset.batch_stats import BatchStatsRunner +from fedlearner_webconsole.dataset.scheduler.dataset_long_period_scheduler import DatasetLongPeriodScheduler +from fedlearner_webconsole.dataset.scheduler.dataset_short_period_scheduler import DatasetShortPeriodScheduler +from fedlearner_webconsole.job.scheduler import JobScheduler +from fedlearner_webconsole.project.project_scheduler import ScheduleProjectRunner +from fedlearner_webconsole.serving.runners import ModelSignatureParser, QueryParticipantStatusRunner, UpdateModelRunner from fedlearner_webconsole.workflow.cronjob import WorkflowCronJob - - -class MemoryItem(IItem): - def __init__(self, task_id: int): - self.id = task_id - - def type(self) -> ItemType: - return ItemType.MEMORY - - def get_id(self) -> int: - return self.id - - -class MemoryRunner(IRunner): - def __init__(self, task_id: int): - """Runner Example - - Args: - task_id: required - """ - self.task_id = task_id - self._start_at = None - - def start(self, context: Context): - # NOTE: in this method, context.data can only be getter, - # don't modify context - data = context.data.get(str(self.task_id), 'EMPTY') - logging.info(f'[memory_runner] {self.task_id} started, data: {data}') - self._start_at = datetime.datetime.utcnow() - - def result(self, context: Context) -> Tuple[RunnerStatus, dict]: - time.sleep(2) - now = datetime.datetime.utcnow() - timeout = random.randint(0, 10) - # mock timeout - if self._start_at is not None and self._start_at + datetime.timedelta( - seconds=timeout) < now: - # kill runner - logging.info(f'[memory_runner] {self.task_id} is timeout, ' - f'start at: {self._start_at}') - return RunnerStatus.FAILED, {} - - # use `get_session` to query database - with get_session(context.db_engine) as session: - count = session.query(SchedulerRunner).count() - # write data to context - context.set_data(f'is_done_{self.task_id}', { - 'status': 'OK', - 'count': count - }) - return RunnerStatus.DONE, {} +from fedlearner_webconsole.workflow.workflow_scheduler import ScheduleWorkflowRunner +from fedlearner_webconsole.cleanup.cleaner_cronjob import CleanupCronJob +from fedlearner_webconsole.mmgr.cronjob import ModelTrainingCronJob +from fedlearner_webconsole.mmgr.scheduler import ModelJobSchedulerRunner, ModelJobGroupSchedulerRunner, \ + ModelJobGroupLongPeriodScheduler +from fedlearner_webconsole.tee.runners import TeeCreateRunner, TeeResourceCheckRunner def global_runner_fn(): # register runner_fn runner_fn = { - ItemType.MEMORY.value: MemoryRunner, ItemType.WORKFLOW_CRON_JOB.value: WorkflowCronJob, - ItemType.DATA_PIPELINE.value: DataPipelineRunner, + ItemType.BATCH_STATS.value: BatchStatsRunner, + ItemType.SERVING_SERVICE_PARSE_SIGNATURE.value: ModelSignatureParser, + ItemType.SERVING_SERVICE_QUERY_PARTICIPANT_STATUS.value: QueryParticipantStatusRunner, + ItemType.SERVING_SERVICE_UPDATE_MODEL.value: UpdateModelRunner, + ItemType.SCHEDULE_WORKFLOW.value: ScheduleWorkflowRunner, + ItemType.SCHEDULE_JOB.value: JobScheduler, + ItemType.CLEANUP_CRON_JOB.value: CleanupCronJob, + ItemType.MODEL_TRAINING_CRON_JOB.value: ModelTrainingCronJob, + ItemType.TEE_CREATE_RUNNER.value: TeeCreateRunner, + ItemType.TEE_RESOURCE_CHECK_RUNNER.value: TeeResourceCheckRunner, + ItemType.SCHEDULE_PROJECT.value: ScheduleProjectRunner, + ItemType.DATASET_LONG_PERIOD_SCHEDULER.value: DatasetLongPeriodScheduler, + ItemType.DATASET_SHORT_PERIOD_SCHEDULER.value: DatasetShortPeriodScheduler, + ItemType.SCHEDULE_MODEL_JOB.value: ModelJobSchedulerRunner, + ItemType.SCHEDULE_MODEL_JOB_GROUP.value: ModelJobGroupSchedulerRunner, + ItemType.SCHEDULE_LONG_PERIOD_MODEL_JOB_GROUP.value: ModelJobGroupLongPeriodScheduler, } for item in ItemType: if item.value in runner_fn or item == ItemType.TASK: diff --git a/web_console_v2/api/fedlearner_webconsole/composer/runner_cache.py b/web_console_v2/api/fedlearner_webconsole/composer/runner_cache.py deleted file mode 100644 index bd93e8bac..000000000 --- a/web_console_v2/api/fedlearner_webconsole/composer/runner_cache.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import logging -import threading - -from fedlearner_webconsole.composer.interface import IRunner - - -class RunnerCache(object): - def __init__(self, runner_fn: dict): - self._lock = threading.Lock() - self._cache = {} # id:name => obj - self.runner_fn = runner_fn - - def find_runner(self, runner_id: int, runner_name: str) -> IRunner: - """Find runner - - Args: - runner_id: id in runner table - runner_name: {item_type}_{item_id} - """ - with self._lock: - key = self.cache_key(runner_id, runner_name) - obj = self._cache.get(key, None) - if obj: - return obj - item_type, item_id = runner_name.rsplit('_', 1) - if item_type not in self.runner_fn: - logging.error( - f'failed to find item_type {item_type} in runner_fn, ' - f'please register it in global_runner_fn') - raise ValueError(f'unknown item_type {item_type} in runner') - obj = self.runner_fn[item_type](int(item_id)) - self._cache[key] = obj - return obj - - def del_runner(self, runner_id: int, runner_name: str): - """Delete runner - - Args: - runner_id: id in runner table - runner_name: {item_type}_{item_id} - """ - with self._lock: - key = self.cache_key(runner_id, runner_name) - del self._cache[key] - - @staticmethod - def cache_key(runner_id: int, runner_name: str) -> str: - return f'{runner_id}:{runner_name}' - - @property - def data(self) -> dict: - with self._lock: - return self._cache diff --git a/web_console_v2/api/fedlearner_webconsole/composer/thread_reaper.py b/web_console_v2/api/fedlearner_webconsole/composer/thread_reaper.py index e63a1ae69..b97510228 100644 --- a/web_console_v2/api/fedlearner_webconsole/composer/thread_reaper.py +++ b/web_console_v2/api/fedlearner_webconsole/composer/thread_reaper.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,12 +18,14 @@ import threading from concurrent.futures import Future from concurrent.futures.thread import ThreadPoolExecutor +from typing import Callable, Optional -from fedlearner_webconsole.composer.models import Context -from fedlearner_webconsole.composer.interface import IRunner +from fedlearner_webconsole.composer.context import RunnerContext +from fedlearner_webconsole.composer.interface import IRunnerV2 class ThreadReaper(object): + def __init__(self, worker_num: int): """ThreadPool with battery @@ -33,26 +35,51 @@ def __init__(self, worker_num: int): self.lock = threading.RLock() self.worker_num = worker_num self.running_worker_num = 0 + self._running_workers = {} self._thread_pool = ThreadPoolExecutor(max_workers=worker_num) - def enqueue(self, name: str, fn: IRunner, context: Context) -> bool: + def is_running(self, runner_id: int) -> bool: + with self.lock: + return runner_id in self._running_workers + + def submit(self, + runner_id: int, + fn: IRunnerV2, + context: RunnerContext, + done_callback: Optional[Callable[[int, Future], None]] = None) -> bool: if self.is_full(): return False - logging.info(f'[thread_reaper] enqueue {name}') + + def full_done_callback(fu: Future): + # The order matters, as we need to update the status at the last. + if done_callback: + done_callback(runner_id, fu) + self._track_status(runner_id, fu) + + logging.info(f'[thread_reaper] enqueue {runner_id}') with self.lock: + if runner_id in self._running_workers: + logging.warning(f'f[thread_reaper] {runner_id} already enqueued') + return False self.running_worker_num += 1 - fu = self._thread_pool.submit(fn.start, context=context) - fu.add_done_callback(self._track_status) + self._running_workers[runner_id] = fn + fu = self._thread_pool.submit(fn.run, context=context) + fu.add_done_callback(full_done_callback) return True - def _track_status(self, fu: Future): + def _track_status(self, runner_id: int, fu: Future): with self.lock: self.running_worker_num -= 1 - logging.info(f'this job is done, result: {fu.result()}') + # Safely removing + self._running_workers.pop(runner_id, None) + try: + logging.info(f'f------Job {runner_id} is done------') + logging.info(f'result: {fu.result()}') + except Exception as e: # pylint: disable=broad-except + logging.info(f'error: {str(e)}') if self.running_worker_num < 0: - logging.error( - f'[thread_reaper] something wrong, should be non-negative, ' - f'val: f{self.running_worker_num}') + logging.error(f'[thread_reaper] something wrong, should be non-negative, ' + f'val: f{self.running_worker_num}') def is_full(self) -> bool: with self.lock: diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/apis.py b/web_console_v2/api/fedlearner_webconsole/dataset/apis.py index 865f41a26..f195b2f50 100644 --- a/web_console_v2/api/fedlearner_webconsole/dataset/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/dataset/apis.py @@ -1,236 +1,2242 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# -# coding: utf-8 # pylint: disable=raise-missing-from -import os +from datetime import timedelta +import logging +from typing import Any, Dict, Optional, List +from urllib.parse import urlparse -from datetime import datetime, timezone from http import HTTPStatus +from flask_restful import Resource, Api +from webargs.flaskparser import use_kwargs, use_args +from marshmallow.exceptions import ValidationError +from marshmallow import post_load, validate, fields +from marshmallow.schema import Schema +from google.protobuf.json_format import ParseDict, ParseError +from envs import Envs -from flask import current_app, request -from flask_restful import Resource, Api, reqparse -from slugify import slugify - -from fedlearner_webconsole.dataset.models import (Dataset, DatasetType, - BatchState, DataBatch) -from fedlearner_webconsole.dataset.services import DatasetService -from fedlearner_webconsole.exceptions import (InvalidArgumentException, - NotFoundException) -from fedlearner_webconsole.db import db_handler as db -from fedlearner_webconsole.proto import dataset_pb2 -from fedlearner_webconsole.scheduler.scheduler import scheduler -from fedlearner_webconsole.utils.decorators import jwt_required +from fedlearner_webconsole.audit.decorators import emits_event +from fedlearner_webconsole.composer.composer_service import ComposerService +from fedlearner_webconsole.composer.models import RunnerStatus +from fedlearner_webconsole.dataset.controllers import DatasetJobController +from fedlearner_webconsole.dataset.job_configer.dataset_job_configer import DatasetJobConfiger +from fedlearner_webconsole.dataset.job_configer.base_configer import set_variable_value_to_job_config +from fedlearner_webconsole.dataset.local_controllers import DatasetJobStageLocalController +from fedlearner_webconsole.dataset.models import (DataBatch, DataSource, DataSourceType, Dataset, + DatasetJobSchedulerState, ResourceState, DatasetJob, DatasetJobKind, + DatasetJobStage, DatasetJobState, ImportType, StoreFormat, + DatasetType, DatasetSchemaChecker, DatasetKindV2, DatasetFormat) +from fedlearner_webconsole.dataset.services import (DatasetJobService, DatasetService, DataSourceService, + DatasetJobStageService) +from fedlearner_webconsole.dataset.util import get_export_dataset_name, add_default_url_scheme, is_streaming_folder, \ + CronInterval +from fedlearner_webconsole.dataset.auth_service import AuthService +from fedlearner_webconsole.dataset.filter_funcs import dataset_auth_status_filter_op_in, dataset_format_filter_op_in, \ + dataset_format_filter_op_equal, dataset_publish_frontend_filter_op_equal +from fedlearner_webconsole.exceptions import InvalidArgumentException, MethodNotAllowedException, NoAccessException, \ + NotFoundException +from fedlearner_webconsole.db import db +from fedlearner_webconsole.proto.dataset_pb2 import DatasetJobGlobalConfigs, TimeRange +from fedlearner_webconsole.proto.filtering_pb2 import FilterExpression, FilterOp +from fedlearner_webconsole.proto.review_pb2 import TicketDetails, TicketType +from fedlearner_webconsole.review.ticket_helper import get_ticket_helper +from fedlearner_webconsole.rpc.v2.job_service_client import JobServiceClient +from fedlearner_webconsole.rpc.v2.resource_service_client import ResourceServiceClient +from fedlearner_webconsole.rpc.v2.system_service_client import SystemServiceClient +from fedlearner_webconsole.setting.service import SettingService +from fedlearner_webconsole.utils.base_model.auth_model import AuthStatus +from fedlearner_webconsole.utils.decorators.pp_flask import admin_required, input_validator +from fedlearner_webconsole.utils.domain_name import get_pure_domain_name +from fedlearner_webconsole.auth.third_party_sso import credentials_required from fedlearner_webconsole.utils.file_manager import FileManager +from fedlearner_webconsole.utils import filtering, sorting +from fedlearner_webconsole.utils.flask_utils import FilterExpField, make_flask_response +from fedlearner_webconsole.proto import dataset_pb2 +from fedlearner_webconsole.swagger.models import schema_manager +from fedlearner_webconsole.participant.services import ParticipantService +from fedlearner_webconsole.project.models import Project +from fedlearner_webconsole.rpc.client import RpcClient +from fedlearner_webconsole.utils.paginate import paginate +from fedlearner_webconsole.utils.file_tree import FileTreeBuilder +from fedlearner_webconsole.workflow.models import WorkflowExternalState +from fedlearner_webconsole.participant.models import Participant +from fedlearner_webconsole.flag.models import Flag + +_DEFAULT_DATA_SOURCE_PREVIEW_FILE_NUM = 3 + + +def _path_authority_validator(path: str): + """Validate data_source path + this func is used to forbiden access to local filesystem + 1. if path is not nfs, pass + 2. if path is nfs and belongs to STORAGE_ROOT, pass + 3. if path is nfs but doesn's belong to STORAGE_ROOT, raise ValidationError + """ + path = path.strip() + authority_path = add_default_url_scheme(Envs.STORAGE_ROOT) + if not authority_path.endswith('/'): + authority_path += '/' + validate_path = add_default_url_scheme(path) + if _parse_data_source_url(validate_path).type != DataSourceType.FILE.value: + return + if not validate_path.startswith(authority_path): + raise ValidationError(f'no access to unauchority path {validate_path}!') + + +def _export_path_validator(path: str): + path = path.strip() + if len(path) == 0: + raise ValidationError('export path is empty!') + fm = FileManager() + if not fm.can_handle(path): + raise ValidationError('cannot handle export path!') + if not fm.isdir(path): + raise ValidationError('export path is not exist!') + _path_authority_validator(path) + + +def _parse_data_source_url(data_source_url: str) -> dataset_pb2.DataSource: + data_source_url = data_source_url.strip() + data_source_url = add_default_url_scheme(data_source_url) + url_parser = urlparse(data_source_url) + data_source_type = url_parser.scheme + # source_type must in DataSourceType + if data_source_type not in [o.value for o in DataSourceType]: + raise ValidationError(f'{data_source_type} is not a supported data_source type') + return dataset_pb2.DataSource( + type=data_source_type, + url=data_source_url, + is_user_upload=False, + is_user_export=False, + ) + + +def _validate_data_source(data_source_url: str, dataset_type: DatasetType): + fm = FileManager() + if not fm.can_handle(path=data_source_url): + raise InvalidArgumentException(f'invalid data_source_url: {data_source_url}') + if not fm.isdir(path=data_source_url): + raise InvalidArgumentException(f'cannot connect to data_source_url: {data_source_url}') + if dataset_type == DatasetType.STREAMING: + res, message = is_streaming_folder(data_source_url) + if not res: + raise InvalidArgumentException(message) + -_FORMAT_ERROR_MESSAGE = '{} is empty' +class DatasetJobConfigParameter(Schema): + dataset_uuid = fields.Str(required=False) + dataset_id = fields.Integer(required=False) + variables = fields.List(fields.Dict()) + @post_load + def make_dataset_job_config(self, item: Dict[str, Any], **kwargs) -> dataset_pb2.DatasetJobConfig: + del kwargs # this variable is not needed for now -def _get_dataset_path(dataset_name): - root_dir = current_app.config.get('STORAGE_ROOT') - prefix = datetime.now().strftime('%Y%m%d_%H%M%S') - # Builds a path for dataset according to the dataset name - # Example: '/data/dataset/20210305_173312_test-dataset - return f'{root_dir}/dataset/{prefix}_{slugify(dataset_name)[:32]}' + try: + dataset_job_config = dataset_pb2.DatasetJobConfig() + return ParseDict(item, dataset_job_config) + except ParseError as err: + raise ValidationError(message='failed to convert dataset_job_config', + field_name='global_configs', + data=err.args) + + +class DatasetJobParameter(Schema): + global_configs = fields.Dict(required=True, keys=fields.Str(), values=fields.Nested(DatasetJobConfigParameter())) + dataset_job_kind = fields.Str(required=False, + validate=validate.OneOf([o.value for o in DatasetJobKind]), + load_default='') + + @post_load + def make_dataset_job(self, item: Dict[str, Any], **kwargs) -> dataset_pb2.DatasetJob: + del kwargs # this variable is not needed for now + + global_configs = item['global_configs'] + global_configs_pb = DatasetJobGlobalConfigs() + for domain_name, job_config in global_configs.items(): + global_configs_pb.global_configs[get_pure_domain_name(domain_name)].MergeFrom(job_config) + + return dataset_pb2.DatasetJob(kind=item['dataset_job_kind'], global_configs=global_configs_pb) + + +class DatasetJobVariablesParameter(Schema): + variables = fields.List(fields.Dict()) + + @post_load + def make_dataset_job_config(self, item: Dict[str, Any], **kwargs) -> dataset_pb2.DatasetJobConfig: + del kwargs # this variable is not needed for now + + try: + dataset_job_config = dataset_pb2.DatasetJobConfig() + return ParseDict(item, dataset_job_config) + except ParseError as err: + raise ValidationError(message='failed to convert dataset_job_config', + field_name='dataset_job_config', + data=err.args) class DatasetApi(Resource): - @jwt_required() - def get(self, dataset_id): + + @credentials_required + def get(self, dataset_id: int): + """Get dataset details + --- + tags: + - dataset + description: get details of dataset + parameters: + - in: path + required: true + name: dataset_id + schema: + type: integer + responses: + 200: + description: get details of dataset + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Dataset' + """ with db.session_scope() as session: - dataset = session.query(Dataset).get(dataset_id) - if dataset is None: - raise NotFoundException( - f'Failed to find dataset: {dataset_id}') - return {'data': dataset.to_dict()} - - @jwt_required() - def patch(self, dataset_id: int): - parser = reqparse.RequestParser() - parser.add_argument('name', - type=str, - required=False, - help='dataset name') - parser.add_argument('comment', - type=str, - required=False, - help='dataset comment') - parser.add_argument('comment') - data = parser.parse_args() + dataset = DatasetService(session).get_dataset(dataset_id) + # TODO(liuhehan): this commit is a lazy update of dataset store_format, remove it after release 2.4 + session.commit() + return make_flask_response(dataset) + + @input_validator + @credentials_required + @emits_event() + @use_kwargs({'comment': fields.Str(required=False, load_default=None)}) + def patch(self, dataset_id: int, comment: Optional[str]): + """Change dataset info + --- + tags: + - dataset + description: change dataset info + parameters: + - in: path + required: true + name: dataset_id + schema: + type: integer + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + comment: + type: string + responses: + 200: + description: change dataset info + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Dataset' + """ with db.session_scope() as session: dataset = session.query(Dataset).filter_by(id=dataset_id).first() if not dataset: - raise NotFoundException( - f'Failed to find dataset: {dataset_id}') - if data['name']: - dataset.name = data['name'] - if data['comment']: - dataset.comment = data['comment'] + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + if comment: + dataset.comment = comment session.commit() - return {'data': dataset.to_dict()}, HTTPStatus.OK + return make_flask_response(dataset.to_proto()) + + @credentials_required + @emits_event() + def delete(self, dataset_id: int): + """Delete dataset + --- + tags: + - dataset + description: delete dataset + parameters: + - in: path + required: true + name: dataset_id + schema: + type: integer + responses: + 204: + description: deleted dataset result + """ + with db.session_scope() as session: + # added an exclusive lock to this row + # ensure the state is modified correctly in a concurrency scenario. + dataset = session.query(Dataset).with_for_update().populate_existing().get(dataset_id) + if not dataset: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + DatasetService(session).cleanup_dataset(dataset) + session.commit() + return make_flask_response(status=HTTPStatus.NO_CONTENT) class DatasetPreviewApi(Resource): - def get(self, dataset_id: int): + + @credentials_required + @use_kwargs({ + 'batch_id': fields.Integer(required=True), + }, location='query') + def get(self, dataset_id: int, batch_id: int): + """Get dataset preview + --- + tags: + - dataset + description: get dataset preview + parameters: + - in: path + required: true + name: dataset_id + schema: + type: integer + - in: query + name: batch_id + schema: + type: integer + responses: + 200: + description: dataset preview info + content: + application/json: + schema: + type: object + properties: + dtypes: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + sample: + type: array + items: + type: array + items: + anyOf: + - type: string + - type: integer + - type: number + num_example: + type: integer + metrics: + type: object + images: + type: array + items: + type: object + properties: + created_at: + type: string + file_name: + type: string + name: + type: string + height: + type: string + width: + type: string + path: + type: string + """ if dataset_id <= 0: raise NotFoundException(f'Failed to find dataset: {dataset_id}') with db.session_scope() as session: - data = DatasetService(session).get_dataset_preview(dataset_id) - return {'data': data} + data = DatasetService(session).get_dataset_preview(dataset_id, batch_id) + return make_flask_response(data) + +class DatasetLedgerApi(Resource): -class DatasetMetricsApi(Resource): def get(self, dataset_id: int): - if dataset_id <= 0: - raise NotFoundException(f'Failed to find dataset: {dataset_id}') - name = request.args.get('name', None) - if not name: - raise InvalidArgumentException(f'required params name') + """Get dataset ledger + --- + tags: + - dataset + description: get + parameters: + - in: path + name: dataset_id + schema: + type: integer + responses: + 200: + description: get dataset ledger page + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetLedger' + """ + return make_flask_response(data={}, status=HTTPStatus.NO_CONTENT) + + +class DatasetExportApi(Resource): + + @credentials_required + @use_kwargs({ + 'export_path': fields.Str(required=True, validate=_export_path_validator), + 'batch_id': fields.Integer(required=False, load_default=None) + }) + def post(self, dataset_id: int, export_path: str, batch_id: Optional[int]): + """Export dataset + --- + tags: + - dataset + description: Export dataset + parameters: + - in: path + required: true + name: dataset_id + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + export_path: + type: string + required: true + batch_id: + type: integer + required: false + responses: + 201: + description: Export dataset + content: + application/json: + schema: + type: object + properties: + export_dataset_id: + type: integer + dataset_job_id: + type: integer + """ + export_path = _parse_data_source_url(export_path).url with db.session_scope() as session: - data = DatasetService(session).feature_metrics(name, dataset_id) - return {'data': data} + input_dataset: Dataset = session.query(Dataset).get(dataset_id) + if not input_dataset: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + export_index = session.query(DatasetJob).filter(DatasetJob.kind == DatasetJobKind.EXPORT).filter( + DatasetJob.input_dataset_id == dataset_id).count() + if batch_id: + data_batch = session.query(DataBatch).filter(DataBatch.dataset_id == dataset_id).filter( + DataBatch.id == batch_id).first() + if data_batch is None: + raise NotFoundException(f'Failed to find data_batch {batch_id} in dataset {dataset_id}') + data_batches = [data_batch] + export_dataset_name = get_export_dataset_name(index=export_index, + input_dataset_name=input_dataset.name, + input_data_batch_name=data_batch.batch_name) + else: + data_batches = input_dataset.data_batches + export_dataset_name = get_export_dataset_name(index=export_index, input_dataset_name=input_dataset.name) + dataset_job_config = dataset_pb2.DatasetJobConfig(dataset_uuid=input_dataset.uuid) + store_format = StoreFormat.UNKNOWN.value if input_dataset.store_format == StoreFormat.UNKNOWN \ + else StoreFormat.CSV.value + dataset_parameter = dataset_pb2.DatasetParameter(name=export_dataset_name, + type=input_dataset.dataset_type.value, + project_id=input_dataset.project.id, + kind=DatasetKindV2.EXPORTED.value, + format=DatasetFormat(input_dataset.dataset_format).name, + is_published=False, + store_format=store_format, + auth_status=AuthStatus.AUTHORIZED.name, + path=export_path) + output_dataset = DatasetService(session=session).create_dataset(dataset_parameter=dataset_parameter) + session.flush() + global_configs = DatasetJobGlobalConfigs() + pure_domain_name = SettingService.get_system_info().pure_domain_name + global_configs.global_configs[pure_domain_name].MergeFrom(dataset_job_config) + export_dataset_job = DatasetJobService(session).create_as_coordinator(project_id=input_dataset.project_id, + kind=DatasetJobKind.EXPORT, + output_dataset_id=output_dataset.id, + global_configs=global_configs) + session.flush() + for data_batch in reversed(data_batches): + # skip non-succeeded data_batch + if not data_batch.is_available(): + continue + DatasetJobStageLocalController(session=session).create_data_batch_and_job_stage_as_coordinator( + dataset_job_id=export_dataset_job.id, + global_configs=export_dataset_job.get_global_configs(), + event_time=data_batch.event_time) + + session.commit() + return make_flask_response(data={ + 'export_dataset_id': output_dataset.id, + 'dataset_job_id': export_dataset_job.id + }, + status=HTTPStatus.OK) + + +class DatasetStateFixtApi(Resource): + + @credentials_required + @admin_required + @use_kwargs({ + 'force': + fields.Str(required=False, load_default=None, validate=validate.OneOf([o.value for o in DatasetJobState])) + }) + def post(self, dataset_id: int, force: str): + """fix dataset state + --- + tags: + - dataset + description: fix dataset state + parameters: + - in: path + required: true + name: dataset_id + schema: + type: integer + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + force: + type: array + items: + type: string + responses: + 200: + description: fix dataset state successfully + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Dataset' + """ + with db.session_scope() as session: + dataset: Dataset = session.query(Dataset).get(dataset_id) + if not dataset: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + if force: + dataset.parent_dataset_job.state = DatasetJobState(force) + else: + workflow_state = dataset.parent_dataset_job.workflow.get_state_for_frontend() + # if workflow is completed, restart the batch stats task + if workflow_state == WorkflowExternalState.COMPLETED: + item_name = dataset.parent_dataset_job.get_context().batch_stats_item_name + runners = ComposerService(session).get_recent_runners(item_name, count=1) + # This is a hack to restart the composer runner, see details in job_scheduler.py + if len(runners) > 0: + runners[0].status = RunnerStatus.INIT.value + dataset.parent_dataset_job.state = DatasetJobState.RUNNING + elif workflow_state in (WorkflowExternalState.FAILED, WorkflowExternalState.STOPPED, + WorkflowExternalState.INVALID): + dataset.parent_dataset_job.state = DatasetJobState.FAILED + session.commit() + return make_flask_response(data=dataset.to_proto(), status=HTTPStatus.OK) + + +class DatasetParameter(Schema): + name = fields.Str(required=True) + dataset_type = fields.Str(required=False, + load_default=DatasetType.PSI.value, + validate=validate.OneOf([o.value for o in DatasetType])) + comment = fields.Str(required=False) + project_id = fields.Int(required=True) + kind = fields.Str(required=False, + load_default=DatasetKindV2.RAW.value, + validate=validate.OneOf([o.value for o in DatasetKindV2])) + dataset_format = fields.Str(required=True, validate=validate.OneOf([o.name for o in DatasetFormat])) + need_publish = fields.Bool(required=False, load_default=False) + value = fields.Int(required=False, load_default=0, validate=[validate.Range(min=100, max=10000)]) + schema_checkers = fields.List(fields.Str(validate=validate.OneOf([o.value for o in DatasetSchemaChecker]))) + is_published = fields.Bool(required=False, load_default=False) + import_type = fields.Str(required=False, + load_default=ImportType.COPY.value, + validate=validate.OneOf([o.value for o in ImportType])) + store_format = fields.Str(required=False, + load_default=StoreFormat.TFRECORDS.value, + validate=validate.OneOf([o.value for o in StoreFormat])) + + @post_load + def make_dataset_parameter(self, item: Dict[str, str], **kwargs) -> dataset_pb2.DatasetParameter: + return dataset_pb2.DatasetParameter(name=item.get('name'), + type=item.get('dataset_type'), + comment=item.get('comment'), + project_id=item.get('project_id'), + kind=item.get('kind'), + format=item.get('dataset_format'), + need_publish=item.get('need_publish'), + value=item.get('value'), + is_published=item.get('is_published'), + schema_checkers=item.get('schema_checkers'), + import_type=item.get('import_type'), + store_format=item.get('store_format')) class DatasetsApi(Resource): - @jwt_required() - def get(self): - parser = reqparse.RequestParser() - parser.add_argument('project', - type=int, - required=False, - help='project') - data = parser.parse_args() - with db.session_scope() as session: - datasets = DatasetService(session).get_datasets( - project_id=int(data['project'] or 0)) - return {'data': [d.to_dict() for d in datasets]} - - @jwt_required() - def post(self): - parser = reqparse.RequestParser() - parser.add_argument('name', - required=True, - type=str, - help=_FORMAT_ERROR_MESSAGE.format('name')) - parser.add_argument('dataset_type', - required=True, - type=DatasetType, - help=_FORMAT_ERROR_MESSAGE.format('dataset_type')) - parser.add_argument('comment', type=str) - parser.add_argument('project_id', - required=True, - type=int, - help=_FORMAT_ERROR_MESSAGE.format('project_id')) - body = parser.parse_args() - name = body.get('name') - dataset_type = body.get('dataset_type') - comment = body.get('comment') - project_id = body.get('project_id') + FILTER_FIELDS = { + 'name': + filtering.SupportedField(type=filtering.FieldType.STRING, ops={FilterOp.CONTAIN: None}), + 'project_id': + filtering.SupportedField(type=filtering.FieldType.NUMBER, ops={FilterOp.EQUAL: None}), + 'uuid': + filtering.SupportedField(type=filtering.FieldType.STRING, ops={FilterOp.EQUAL: None}), + 'dataset_kind': + filtering.SupportedField(type=filtering.FieldType.STRING, ops={ + FilterOp.IN: None, + FilterOp.EQUAL: None + }), + 'dataset_format': + filtering.SupportedField(type=filtering.FieldType.STRING, + ops={ + FilterOp.IN: dataset_format_filter_op_in, + FilterOp.EQUAL: dataset_format_filter_op_equal + }), + 'is_published': + filtering.SupportedField(type=filtering.FieldType.BOOL, ops={FilterOp.EQUAL: None}), + 'dataset_type': + filtering.SupportedField(type=filtering.FieldType.STRING, ops={FilterOp.EQUAL: None}), + 'publish_frontend_state': + filtering.SupportedField(type=filtering.FieldType.STRING, + ops={FilterOp.EQUAL: dataset_publish_frontend_filter_op_equal}), + 'auth_status': + filtering.SupportedField(type=filtering.FieldType.STRING, + ops={FilterOp.IN: dataset_auth_status_filter_op_in}), + } + + SORTER_FIELDS = ['created_at'] + def __init__(self): + self._filter_builder = filtering.FilterBuilder(model_class=Dataset, supported_fields=self.FILTER_FIELDS) + self._sorter_builder = sorting.SorterBuilder(model_class=Dataset, supported_fields=self.SORTER_FIELDS) + + @credentials_required + @use_kwargs( + { + 'page': + fields.Integer(required=False, load_default=1), + 'page_size': + fields.Integer(required=False, load_default=10), + 'dataset_job_kind': + fields.String(required=False, load_default=None), + 'state_frontend': + fields.List( + fields.String( + required=False, load_default=None, validate=validate.OneOf([o.value for o in ResourceState]))), + 'filter_exp': + FilterExpField(required=False, load_default=None, data_key='filter'), + 'sorter_exp': + fields.String(required=False, load_default=None, data_key='order_by'), + 'cron_interval': + fields.String( + required=False, load_default=None, validate=validate.OneOf([o.value for o in CronInterval])), + }, + location='query') + def get(self, + page: int, + page_size: int, + dataset_job_kind: Optional[str] = None, + state_frontend: Optional[List[str]] = None, + filter_exp: Optional[FilterExpression] = None, + sorter_exp: Optional[str] = None, + cron_interval: Optional[str] = None): + """Get datasets list + --- + tags: + - dataset + description: get datasets list + parameters: + - in: query + name: page + schema: + type: integer + - in: query + name: page_size + schema: + type: integer + - in: query + name: dataset_job_kind + schema: + type: string + - in: query + name: state_frontend + schema: + type: array + collectionFormat: multi + items: + type: string + enum: [PENDING, PROCESSING, SUCCEEDED, FAILED] + - in: query + name: filter + schema: + type: string + - in: query + name: order_by + schema: + type: string + - in: query + name: cron_interval + schema: + type: string + responses: + 200: + description: get datasets list result + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetRef' + """ + if dataset_job_kind is not None: + try: + dataset_job_kind = DatasetJobKind(dataset_job_kind) + except TypeError as err: + raise InvalidArgumentException( + details=f'failed to find dataset dataset_job_kind {dataset_job_kind}') from err with db.session_scope() as session: + query = DatasetService(session).query_dataset_with_parent_job() + if dataset_job_kind: + query = query.filter(DatasetJob.kind == dataset_job_kind) + if filter_exp is not None: + try: + query = self._filter_builder.build_query(query, filter_exp) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid filter: {str(e)}') from e try: - # Create dataset - dataset = Dataset( - name=name, - dataset_type=dataset_type, - comment=comment, - path=_get_dataset_path(name), - project_id=project_id, - ) - session.add(dataset) - # TODO: scan cronjob - session.commit() - return {'data': dataset.to_dict()} - except Exception as e: - session.rollback() - raise InvalidArgumentException(details=str(e)) + if sorter_exp is not None: + sorter_exp = sorting.parse_expression(sorter_exp) + else: + sorter_exp = sorting.SortExpression(field='created_at', is_asc=False) + query = self._sorter_builder.build_query(query, sorter_exp) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid sorter: {str(e)}') from e + # TODO(liuhehan): add state_frontend as custom_builder + if state_frontend is not None: + states = [] + for state in state_frontend: + states.append(ResourceState(state)) + query = DatasetService.filter_dataset_state(query, states) + # filter daily or hourly cron + if cron_interval: + if cron_interval == CronInterval.HOURS.value: + time_range = timedelta(hours=1) + else: + time_range = timedelta(days=1) + query = query.filter(DatasetJob.time_range == time_range) + pagination = paginate(query=query, page=page, page_size=page_size) + datasets = [] + for dataset in pagination.get_items(): + dataset_ref = dataset.to_ref() + dataset_ref.total_value = 0 + datasets.append(dataset_ref) + # TODO(liuhehan): this commit is a lazy update of dataset store_format, remove it after release 2.4 + session.commit() + return make_flask_response(data=datasets, page_meta=pagination.get_metadata()) + + @input_validator + @credentials_required + @emits_event() + @use_args(DatasetParameter()) + def post(self, dataset_parameter: dataset_pb2.DatasetParameter): + """Create dataset + --- + tags: + - dataset + description: Create dataset + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/DatasetParameter' + responses: + 201: + description: Create dataset + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Dataset' + """ + with db.session_scope() as session: + # processed dataset must be is_published + if DatasetKindV2(dataset_parameter.kind) == DatasetKindV2.PROCESSED and not dataset_parameter.is_published: + raise InvalidArgumentException('is_published must be true if dataset kind is PROCESSED') + if DatasetKindV2(dataset_parameter.kind) == DatasetKindV2.PROCESSED and ImportType( + dataset_parameter.import_type) != ImportType.COPY: + raise InvalidArgumentException('import type must be copy if dataset kind is PROCESSED') + if StoreFormat(dataset_parameter.store_format) == StoreFormat.CSV and DatasetKindV2( + dataset_parameter.kind) in [DatasetKindV2.RAW, DatasetKindV2.PROCESSED]: + raise InvalidArgumentException('csv store_type is not support if dataset kind is RAW or PROCESSED') + dataset_parameter.auth_status = AuthStatus.AUTHORIZED.name + dataset = DatasetService(session=session).create_dataset(dataset_parameter=dataset_parameter) + session.flush() + # create review ticket for processed_dataset + if DatasetKindV2(dataset_parameter.kind) == DatasetKindV2.PROCESSED: + ticket_helper = get_ticket_helper(session=session) + ticket_helper.create_ticket(TicketType.CREATE_PROCESSED_DATASET, TicketDetails(uuid=dataset.uuid)) + session.commit() + return make_flask_response(data=dataset.to_proto(), status=HTTPStatus.CREATED) + + +class ChildrenDatasetsApi(Resource): + + def get(self, dataset_id: int): + """Get children datasets list + --- + tags: + - dataset + description: Get children datasets list + parameters: + - in: path + name: dataset_id + schema: + type: integer + responses: + 200: + description: get children datasets list result + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetRef' + """ + with db.session_scope() as session: + query = DatasetService(session=session).query_dataset_with_parent_job() + query = query.filter(DatasetJob.input_dataset_id == dataset_id) + # exported dataset should not be shown in children datasets + query = query.filter(Dataset.dataset_kind != DatasetKindV2.EXPORTED) + return make_flask_response(data=[dataset.to_ref() for dataset in query.all()]) + + +class BatchParameter(Schema): + data_source_id = fields.Integer(required=True) + comment = fields.Str(required=False) + + @post_load + def make_batch_parameter(self, item: Dict[str, Any], **kwargs) -> dataset_pb2.BatchParameter: + data_source_id = item.get('data_source_id') + comment = item.get('comment') + + with db.session_scope() as session: + data_source = session.query(DataSource).get(data_source_id) + if data_source is None: + raise ValidationError(message=f'failed to find data_source {data_source_id}', + field_name='data_source_id') + + return dataset_pb2.BatchParameter(comment=comment, data_source_id=data_source_id) class BatchesApi(Resource): - @jwt_required() + + SORTER_FIELDS = ['created_at', 'updated_at'] + + def __init__(self): + self._sorter_builder = sorting.SorterBuilder(model_class=DataBatch, supported_fields=self.SORTER_FIELDS) + + @credentials_required + @use_kwargs( + { + 'page': fields.Integer(required=False, load_default=1), + 'page_size': fields.Integer(required=False, load_default=10), + 'sorter_exp': fields.String(required=False, load_default=None, data_key='order_by') + }, + location='query') + def get(self, dataset_id: int, page: int, page_size: int, sorter_exp: Optional[str]): + """List data batches + --- + tags: + - dataset + description: List data batches + parameters: + - in: path + name: dataset_id + schema: + type: integer + - in: query + name: page + schema: + type: integer + - in: query + name: page_size + schema: + type: integer + - in: query + name: order_by + schema: + type: string + responses: + 200: + description: list of data batches + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.DataBatch' + """ + with db.session_scope() as session: + query = session.query(DataBatch).filter(DataBatch.dataset_id == dataset_id) + try: + if sorter_exp is not None: + sorter_exp = sorting.parse_expression(sorter_exp) + else: + # default sort is created_at desc + sorter_exp = sorting.SortExpression(field='created_at', is_asc=False) + query = self._sorter_builder.build_query(query, sorter_exp) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid sorter: {str(e)}') from e + pagination = paginate(query=query, page=page, page_size=page_size) + return make_flask_response(data=[data_batch.to_proto() for data_batch in pagination.get_items()], + page_meta=pagination.get_metadata()) + + +class BatchApi(Resource): + + @credentials_required + def get(self, dataset_id: int, data_batch_id: int): + """Get data batch by id + --- + tags: + - dataset + description: Get data batch by id + parameters: + - in: path + name: dataset_id + schema: + type: integer + - in: path + name: data_batch_id + schema: + type: integer + responses: + 200: + description: Get data batch by id + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DataBatch' + """ + with db.session_scope() as session: + batch: DataBatch = session.query(DataBatch).filter(DataBatch.dataset_id == dataset_id).filter( + DataBatch.id == data_batch_id).first() + if batch is None: + raise NotFoundException(f'failed to find batch {data_batch_id} in dataset {dataset_id}') + return make_flask_response(data=batch.to_proto()) + + +class BatchAnalyzeApi(Resource): + + @credentials_required + @use_kwargs({'dataset_job_config': fields.Nested(DatasetJobVariablesParameter())}) + def post(self, dataset_id: int, data_batch_id: int, dataset_job_config: dataset_pb2.DatasetJobConfig): + """Analyze data_batch by id + --- + tags: + - dataset + description: Analyze data_batch by id + parameters: + - in: path + name: dataset_id + schema: + type: integer + - in: path + name: data_batch_id + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dataset_job_config: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJobConfig' + responses: + 200: + description: analyzer dataset job details + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJob' + """ + with db.session_scope() as session: + dataset: Dataset = session.query(Dataset).get(dataset_id) + if dataset is None: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + dataset_job_config.dataset_uuid = dataset.uuid + global_configs = DatasetJobGlobalConfigs() + pure_domain_name = SettingService.get_system_info().pure_domain_name + global_configs.global_configs[pure_domain_name].MergeFrom(dataset_job_config) + analyzer_dataset_job: DatasetJob = session.query(DatasetJob).filter( + DatasetJob.output_dataset_id == dataset_id).filter(DatasetJob.kind == DatasetJobKind.ANALYZER).first() + if analyzer_dataset_job is None: + analyzer_dataset_job = DatasetJobService(session).create_as_coordinator(project_id=dataset.project_id, + kind=DatasetJobKind.ANALYZER, + output_dataset_id=dataset_id, + global_configs=global_configs) + else: + previous_global_configs = analyzer_dataset_job.get_global_configs() + for variable in dataset_job_config.variables: + set_variable_value_to_job_config(previous_global_configs.global_configs[pure_domain_name], variable) + analyzer_dataset_job.set_global_configs(previous_global_configs) + session.flush() + DatasetJobStageService(session).create_dataset_job_stage_as_coordinator( + project_id=dataset.project_id, + dataset_job_id=analyzer_dataset_job.id, + output_data_batch_id=data_batch_id, + global_configs=analyzer_dataset_job.get_global_configs()) + dataset_job_details = analyzer_dataset_job.to_proto() + session.commit() + + return make_flask_response(data=dataset_job_details, status=HTTPStatus.OK) + + +class BatchMetricsApi(Resource): + + @credentials_required + @use_kwargs({ + 'name': fields.Str(required=True), + }, location='query') + def get(self, dataset_id: int, data_batch_id: int, name: str): + """Get data batch metrics info + --- + tags: + - dataset + description: get data batch metrics info + parameters: + - in: path + required: true + name: dataset_id + schema: + type: integer + - in: path + required: true + name: data_batch_id + schema: + type: integer + - in: query + required: true + name: name + schema: + type: string + responses: + 200: + description: get data batch metrics info + content: + application/json: + schema: + type: object + properties: + name: + type: string + metrics: + type: object + properties: + count: + type: string + max: + type: string + min: + type: string + mean: + type: string + stddev: + type: string + missing_count: + type: string + hist: + type: object + properties: + x: + type: array + items: + type: number + y: + type: array + items: + type: number + """ + # TODO(liuhehan): return dataset metrics in proto + with db.session_scope() as session: + data = DatasetService(session).feature_metrics(name, dataset_id, data_batch_id) + return make_flask_response(data) + + +class BatchRerunApi(Resource): + + @credentials_required + @use_kwargs({'dataset_job_parameter': fields.Nested(DatasetJobParameter())}) + def post(self, dataset_id: int, data_batch_id: int, dataset_job_parameter: dataset_pb2.DatasetJob): + """rerun data_batch by id + --- + tags: + - dataset + description: Rerun data_batch by id + parameters: + - in: path + name: dataset_id + schema: + type: integer + - in: path + name: data_batch_id + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dataset_job_parameter: + $ref: '#/definitions/DatasetJobParameter' + responses: + 200: + description: dataset job stage details + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJobStage' + """ + global_configs = dataset_job_parameter.global_configs + + with db.session_scope() as session: + dataset: Dataset = session.query(Dataset).get(dataset_id) + if dataset is None: + raise InvalidArgumentException(f'failed to find dataset: {dataset_id}') + data_batch: DataBatch = session.query(DataBatch).filter(DataBatch.dataset_id == dataset_id).filter( + DataBatch.id == data_batch_id).first() + if data_batch is None: + raise InvalidArgumentException(f'failed to find data_batch: {data_batch_id}') + dataset_job: DatasetJob = dataset.parent_dataset_job + if dataset_job is None: + raise InvalidArgumentException(f'dataset_job is missing, output_dataset_id: {dataset_id}') + # get current global_configs + if dataset_job.is_coordinator(): + current_global_configs = dataset_job.get_global_configs() + else: + participant: Participant = session.query(Participant).get(dataset_job.coordinator_id) + system_client = SystemServiceClient.from_participant(domain_name=participant.domain_name) + flag_resp = system_client.list_flags() + if not flag_resp.get(Flag.DATA_BATCH_RERUN_ENABLED.name): + raise MethodNotAllowedException( + f'particiapnt {participant.pure_domain_name()} not support rerun data_batch, ' \ + 'could only rerun data_batch created as coordinator' + ) + client = RpcClient.from_project_and_participant(dataset_job.project.name, dataset_job.project.token, + participant.domain_name) + response = client.get_dataset_job(uuid=dataset_job.uuid) + current_global_configs = response.dataset_job.global_configs + # set global_configs + for pure_domain_name in global_configs.global_configs: + for variable in global_configs.global_configs[pure_domain_name].variables: + set_variable_value_to_job_config(current_global_configs.global_configs[pure_domain_name], variable) + # create dataset_job_stage + dataset_job_stage = DatasetJobStageService(session).create_dataset_job_stage_as_coordinator( + project_id=dataset.project_id, + dataset_job_id=dataset_job.id, + output_data_batch_id=data_batch_id, + global_configs=current_global_configs) + session.flush() + dataset_job_stage_details = dataset_job_stage.to_proto() + session.commit() + + return make_flask_response(data=dataset_job_stage_details, status=HTTPStatus.OK) + + +class DataSourceParameter(Schema): + name = fields.Str(required=True) + comment = fields.Str(required=False) + data_source_url = fields.Str(required=True, validate=_path_authority_validator) + is_user_upload = fields.Bool(required=False) + dataset_format = fields.Str(required=False, + load_default=DatasetFormat.TABULAR.name, + validate=validate.OneOf([o.name for o in DatasetFormat])) + store_format = fields.Str(required=False, + load_default=StoreFormat.UNKNOWN.value, + validate=validate.OneOf([o.value for o in StoreFormat])) + dataset_type = fields.Str(required=False, + load_default=DatasetType.PSI.value, + validate=validate.OneOf([o.value for o in DatasetType])) + + @post_load + def make_data_source(self, item: Dict[str, str], **kwargs) -> dataset_pb2.DataSource: + del kwargs # this variable is not needed for now + name = item.get('name') + comment = item.get('comment') + data_source_url = item.get('data_source_url') + is_user_upload = item.get('is_user_upload', False) + data_source = _parse_data_source_url(data_source_url) + data_source.name = name + data_source.dataset_format = item.get('dataset_format') + data_source.store_format = item.get('store_format') + data_source.dataset_type = item.get('dataset_type') + if is_user_upload: + data_source.is_user_upload = True + if comment: + data_source.comment = comment + return data_source + + +class DataSourcesApi(Resource): + + @credentials_required + @use_kwargs({'data_source': fields.Nested(DataSourceParameter()), 'project_id': fields.Integer(required=True)}) + def post(self, data_source: dataset_pb2.DataSource, project_id: int): + """Create a data source + --- + tags: + - dataset + description: create a data source + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + data_source: + type: object + required: true + properties: + schema: + $ref: '#/definitions/DataSourceParameter' + project_id: + type: integer + required: true + responses: + 201: + description: The data source is created + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DataSource' + 409: + description: A data source with the same name exists + 400: + description: | + A data source that webconsole cannot connect with + Probably, unexist data source or unauthorized to the data source + """ + + _validate_data_source(data_source.url, DatasetType(data_source.dataset_type)) + with db.session_scope() as session: + data_source.project_id = project_id + data_source = DataSourceService(session=session).create_data_source(data_source) + session.commit() + return make_flask_response(data=data_source.to_proto(), status=HTTPStatus.CREATED) + + @credentials_required + @use_kwargs({'project_id': fields.Integer(required=False, load_default=0, validate=validate.Range(min=0))}, + location='query') + def get(self, project_id: int): + """Get a list of data source + --- + tags: + - dataset + description: get a list of data source + parameters: + - in: query + name: project_id + schema: + type: integer + responses: + 200: + description: list of data source + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.DataSource' + """ + + with db.session_scope() as session: + data_sources = DataSourceService(session=session).get_data_sources(project_id) + return make_flask_response(data=data_sources) + + +class DataSourceApi(Resource): + + @credentials_required + def get(self, data_source_id: int): + """Get target data source by id + --- + tags: + - dataset + description: get target data source by id + parameters: + - in: path + name: data_source_id + schema: + type: integer + responses: + 200: + description: data source + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DataSource' + """ + + with db.session_scope() as session: + data_source: DataSource = session.query(DataSource).get(data_source_id) + if not data_source: + raise NotFoundException(message=f'cannot find data_source with id: {data_source_id}') + return make_flask_response(data=data_source.to_proto()) + + @credentials_required + def delete(self, data_source_id: int): + """Delete a data source + --- + tags: + - dataset + description: delete a data source + parameters: + - in: path + name: data_source_id + schema: + type: integer + responses: + 204: + description: deleted data source result + """ + + with db.session_scope() as session: + DataSourceService(session=session).delete_data_source(data_source_id) + session.commit() + return make_flask_response(data={}, status=HTTPStatus.NO_CONTENT) + + +class DataSourceTreeApi(Resource): + + @credentials_required + def get(self, data_source_id: int): + """Get the data source tree + --- + tags: + - dataset + description: get the data source tree + parameters: + - in: path + name: data_source_id + schema: + type: integer + responses: + 200: + description: the file tree of the data source + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.FileTreeNode' + """ + with db.session_scope() as session: + data_source: DataSource = session.query(DataSource).get(data_source_id) + # relative path is used in returned file tree + file_tree = FileTreeBuilder(data_source.path, relpath=True).build_with_root() + return make_flask_response(file_tree) + + +class DataSourceCheckConnectionApi(Resource): + + @credentials_required + @use_kwargs({ + 'data_source_url': + fields.Str(required=True, validate=_path_authority_validator), + 'file_num': + fields.Integer(required=False, load_default=_DEFAULT_DATA_SOURCE_PREVIEW_FILE_NUM), + 'dataset_type': + fields.Str(required=False, + load_default=DatasetType.PSI.value, + validate=validate.OneOf([o.value for o in DatasetType])) + }) + def post(self, data_source_url: str, file_num: int, dataset_type: str): + """Check data source connection status + --- + tags: + - dataset + description: check data source connection status + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + data_source_url: + type: string + required: true + file_num: + type: integer + required: false + dataset_type: + type: string + required: false + responses: + 200: + description: status details and file_names + content: + application/json: + schema: + type: object + properties: + extra_nums: + type: interger + file_names: + type: array + items: + type: string + """ + + data_source_url = _parse_data_source_url(data_source_url).url + _validate_data_source(data_source_url, DatasetType(dataset_type)) + file_names = FileManager().listdir(data_source_url) + return make_flask_response(data={ + 'file_names': file_names[:file_num], + 'extra_nums': max(len(file_names) - file_num, 0), + }) + + +class ParticipantDatasetsApi(Resource): + + @credentials_required + @use_kwargs( + { + 'kind': + fields.Str(required=False, load_default=None), + 'uuid': + fields.Str(required=False, load_default=None), + 'participant_id': + fields.Integer(required=False, load_default=None), + 'cron_interval': + fields.String( + required=False, load_default=None, validate=validate.OneOf([o.value for o in CronInterval])), + }, + location='query') + def get( + self, + project_id: int, + kind: Optional[str], + uuid: Optional[str], + participant_id: Optional[int], + cron_interval: Optional[str], + ): + """Get list of participant datasets + --- + tags: + - dataset + description: get list of participant datasets + parameters: + - in: path + name: project_id + schema: + type: integer + - in: query + name: kind + schema: + type: string + - in: query + name: uuid + schema: + type: string + - in: query + name: participant_id + schema: + type: integer + - in: query + name: cron_interval + schema: + type: string + responses: + 200: + description: list of participant datasets + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.ParticipantDatasetRef' + """ + if kind is not None: + try: + DatasetKindV2(kind) + except ValueError as err: + raise InvalidArgumentException(details=f'failed to find dataset kind {kind}') from err + time_range = None + if cron_interval: + if cron_interval == CronInterval.HOURS.value: + time_range = TimeRange(hours=1) + else: + time_range = TimeRange(days=1) + + with db.session_scope() as session: + if participant_id is None: + participants = ParticipantService(session).get_platform_participants_by_project(project_id) + else: + participant = session.query(Participant).get(participant_id) + if participant is None: + raise NotFoundException(f'particiapnt {participant_id} is not found') + participants = [participant] + project = session.query(Project).get(project_id) + data = [] + for participant in participants: + # check flag + system_client = SystemServiceClient.from_participant(domain_name=participant.domain_name) + flag_resp = system_client.list_flags() + # if participant supports list dataset rpc, use new rpc + if flag_resp.get(Flag.LIST_DATASETS_RPC_ENABLED.name): + client = ResourceServiceClient.from_project_and_participant(participant.domain_name, project.name) + response = client.list_datasets(kind=DatasetKindV2(kind) if kind is not None else None, + uuid=uuid, + state=ResourceState.SUCCEEDED, + time_range=time_range) + else: + client = RpcClient.from_project_and_participant(project.name, project.token, + participant.domain_name) + response = client.list_participant_datasets(kind=kind, uuid=uuid) + datasets = response.participant_datasets + if uuid: + datasets = [d for d in datasets if uuid and d.uuid == uuid] + for dataset in datasets: + dataset.participant_id = participant.id + dataset.project_id = project_id + data.extend(datasets) + + return make_flask_response(data=data) + + +class DatasetPublishApi(Resource): + + @credentials_required + @use_kwargs({'value': fields.Int(required=False, load_default=0, validate=[validate.Range(min=100, max=10000)])}) + def post(self, dataset_id: int, value: int): + """Publish the dataset in workspace + --- + tags: + - dataset + description: Publish the dataset in workspace + parameters: + - in: path + name: dataset_id + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + type: integer + required: true + responses: + 200: + description: published the dataset in workspace + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Dataset' + """ + with db.session_scope() as session: + dataset = DatasetService(session=session).publish_dataset(dataset_id, value) + session.commit() + return make_flask_response(data=dataset.to_proto()) + + @credentials_required + def delete(self, dataset_id: int): + """Revoke publish dataset ops + --- + tags: + - dataset + description: Revoke publish dataset ops + parameters: + - in: path + name: dataset_id + schema: + type: integer + responses: + 204: + description: revoked publish dataset successfully + """ + with db.session_scope() as session: + DatasetService(session=session).withdraw_dataset(dataset_id) + session.commit() + return make_flask_response(data=None, status=HTTPStatus.NO_CONTENT) + + +class DatasetAuthorizehApi(Resource): + + @credentials_required def post(self, dataset_id: int): - parser = reqparse.RequestParser() - parser.add_argument('event_time', type=int) - parser.add_argument('files', - required=True, - type=list, - location='json', - help=_FORMAT_ERROR_MESSAGE.format('files')) - parser.add_argument('move', type=bool) - parser.add_argument('comment', type=str) - body = parser.parse_args() - event_time = body.get('event_time') - files = body.get('files') - move = body.get('move', False) - comment = body.get('comment') + """Authorize target dataset by id + --- + tags: + - dataset + description: authorize target dataset by id + parameters: + - in: path + name: dataset_id + schema: + type: integer + responses: + 200: + description: authorize target dataset by id + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Dataset' + """ with db.session_scope() as session: - dataset = session.query(Dataset).filter_by(id=dataset_id).first() + dataset: Dataset = session.query(Dataset).get(dataset_id) if dataset is None: - raise NotFoundException( - f'Failed to find dataset: {dataset_id}') - if event_time is None and dataset.type == DatasetType.STREAMING: - raise InvalidArgumentException( - details='data_batch.event_time is empty') - # TODO: PSI dataset should not allow multi batches - - # Use current timestamp to fill when type is PSI - event_time = datetime.fromtimestamp( - event_time or datetime.utcnow().timestamp(), tz=timezone.utc) - batch_folder_name = event_time.strftime('%Y%m%d_%H%M%S') - batch_path = f'{dataset.path}/batch/{batch_folder_name}' - # Create batch - batch = DataBatch(dataset_id=dataset.id, - event_time=event_time, - comment=comment, - state=BatchState.NEW, - move=move, - path=batch_path) - batch_details = dataset_pb2.DataBatch() - for file_path in files: - file = batch_details.files.add() - file.source_path = file_path - file_name = file_path.split('/')[-1] - file.destination_path = f'{batch_path}/{file_name}' - batch.set_details(batch_details) - session.add(batch) - session.commit() - session.refresh(batch) - scheduler.wakeup(data_batch_ids=[batch.id]) - return {'data': batch.to_dict()} - - -class FilesApi(Resource): + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + # update local auth_status + dataset.auth_status = AuthStatus.AUTHORIZED + if dataset.participants_info is not None: + # update local auth_status cache + AuthService(session=session, dataset_job=dataset.parent_dataset_job).update_auth_status( + domain_name=SettingService.get_system_info().pure_domain_name, auth_status=AuthStatus.AUTHORIZED) + # update participants auth_status cache + DatasetJobController(session=session).inform_auth_status(dataset_job=dataset.parent_dataset_job, + auth_status=AuthStatus.AUTHORIZED) + session.commit() + return make_flask_response(data=dataset.to_proto()) + + @credentials_required + def delete(self, dataset_id: int): + """Revoke dataset authorization by id + --- + tags: + - dataset + description: revoke dataset authorization by id + parameters: + - in: path + name: dataset_id + schema: + type: integer + responses: + 200: + description: revoke dataset authorization by id successfully + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Dataset' + """ + with db.session_scope() as session: + dataset: Dataset = session.query(Dataset).get(dataset_id) + if dataset is None: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + # update local auth_status + dataset.auth_status = AuthStatus.WITHDRAW + if dataset.participants_info is not None: + # update local auth_status cache + AuthService(session=session, dataset_job=dataset.parent_dataset_job).update_auth_status( + domain_name=SettingService.get_system_info().pure_domain_name, auth_status=AuthStatus.WITHDRAW) + # update participants auth_status cache + DatasetJobController(session=session).inform_auth_status(dataset_job=dataset.parent_dataset_job, + auth_status=AuthStatus.WITHDRAW) + session.commit() + return make_flask_response(data=dataset.to_proto()) + + +class DatasetFlushAuthStatusApi(Resource): + + @credentials_required + def post(self, dataset_id: int): + """flush dataset auth status cache by id + --- + tags: + - dataset + description: flush dataset auth status cache by id + parameters: + - in: path + name: dataset_id + schema: + type: integer + responses: + 200: + description: flush dataset auth status cache by id successfully + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Dataset' + """ + with db.session_scope() as session: + dataset: Dataset = session.query(Dataset).get(dataset_id) + if dataset is None: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + if dataset.participants_info is not None: + DatasetJobController(session=session).update_auth_status_cache(dataset_job=dataset.parent_dataset_job) + session.commit() + return make_flask_response(data=dataset.to_proto()) + + +class TimeRangeParameter(Schema): + days = fields.Integer(required=False, load_default=0, validate=[validate.Range(min=0, max=1)]) + hours = fields.Integer(required=False, load_default=0, validate=[validate.Range(min=0, max=1)]) + + @post_load + def make_time_range(self, item: Dict[str, Any], **kwargs) -> dataset_pb2.TimeRange: + days = item['days'] + hours = item['hours'] + + return dataset_pb2.TimeRange(days=days, hours=hours) + + +class DatasetJobDefinitionApi(Resource): + + @credentials_required + def get(self, dataset_job_kind: str): + """Get variables of this dataset_job + --- + tags: + - dataset + description: Get variables of this dataset_job + parameters: + - in: path + name: dataset_job_kind + schema: + type: string + responses: + 200: + description: variables of this dataset_job + content: + application/json: + schema: + type: object + properties: + variables: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.Variable' + is_federated: + type: boolean + """ + # webargs doesn't support location=path for now + # reference: webargs/core.py:L285 + try: + dataset_job_kind = DatasetJobKind(dataset_job_kind) + except ValueError as err: + raise InvalidArgumentException(details=f'unkown dataset_job_kind {dataset_job_kind}') from err + with db.session_scope() as session: + configer = DatasetJobConfiger.from_kind(dataset_job_kind, session) + user_variables = configer.user_variables + is_federated = not DatasetJobService(session).is_local(dataset_job_kind) + return make_flask_response(data={'variables': user_variables, 'is_federated': is_federated}) + + +class DatasetJobsApi(Resource): + FILTER_FIELDS = { + 'name': filtering.SupportedField(type=filtering.FieldType.STRING, ops={FilterOp.CONTAIN: None}), + 'kind': filtering.SupportedField(type=filtering.FieldType.STRING, ops={FilterOp.IN: None}), + 'input_dataset_id': filtering.SupportedField(type=filtering.FieldType.NUMBER, ops={FilterOp.EQUAL: None}), + 'coordinator_id': filtering.SupportedField(type=filtering.FieldType.NUMBER, ops={FilterOp.IN: None}), + 'state': filtering.SupportedField(type=filtering.FieldType.STRING, ops={FilterOp.IN: None}), + } + + SORTER_FIELDS = ['created_at'] + + def __init__(self): + self._filter_builder = filtering.FilterBuilder(model_class=DatasetJob, supported_fields=self.FILTER_FIELDS) + self._sorter_builder = sorting.SorterBuilder(model_class=DatasetJob, supported_fields=self.SORTER_FIELDS) + + @credentials_required + @use_kwargs( + { + 'page': fields.Integer(required=False, load_default=1), + 'page_size': fields.Integer(required=False, load_default=10), + 'filter_exp': FilterExpField(required=False, load_default=None, data_key='filter'), + 'sorter_exp': fields.String(required=False, load_default=None, data_key='order_by'), + }, + location='query') + def get(self, + project_id: int, + page: int, + page_size: int, + filter_exp: Optional[FilterExpression] = None, + sorter_exp: Optional[str] = None): + """Get list of this dataset_jobs + --- + tags: + - dataset + description: Get list of this dataset_jobs + parameters: + - in: path + name: project_id + schema: + type: integer + - in: query + name: filter + schema: + type: string + - in: query + name: order_by + schema: + type: string + responses: + 200: + description: list of this dataset_jobs + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJobRef' + """ + with db.session_scope() as session: + query = session.query(DatasetJob).filter(DatasetJob.project_id == project_id) + if filter_exp is not None: + try: + query = self._filter_builder.build_query(query, filter_exp) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid filter: {str(e)}') from e + try: + if sorter_exp is not None: + sorter_exp = sorting.parse_expression(sorter_exp) + else: + sorter_exp = sorting.SortExpression(field='created_at', is_asc=False) + query = self._sorter_builder.build_query(query, sorter_exp) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid sorter: {str(e)}') from e + pagination = paginate(query=query, page=page, page_size=page_size) + + return make_flask_response(data=[dataset_job.to_ref() for dataset_job in pagination.get_items()], + page_meta=pagination.get_metadata()) + + @credentials_required + @use_kwargs({ + 'dataset_job_parameter': fields.Nested(DatasetJobParameter()), + 'output_dataset_id': fields.Integer(required=False, load_default=None), + 'time_range': fields.Nested(TimeRangeParameter(), required=False, load_default=dataset_pb2.TimeRange()) + }) + def post(self, project_id: int, dataset_job_parameter: dataset_pb2.DatasetJob, output_dataset_id: Optional[int], + time_range: dataset_pb2.TimeRange): + """Create new dataset job of the kind + --- + tags: + - dataset + description: Create new dataset job of the kind + parameters: + - in: path + name: project_id + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dataset_job_parameter: + $ref: '#/definitions/DatasetJobParameter' + time_range: + $ref: '#/definitions/TimeRangeParameter' + responses: + 201: + description: Create new dataset job of the kind + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJob' + """ + dataset_job_kind = DatasetJobKind(dataset_job_parameter.kind) + if not Flag.OT_PSI_ENABLED.value and dataset_job_kind == DatasetJobKind.OT_PSI_DATA_JOIN: + raise NoAccessException(f'dataset job {dataset_job_parameter.kind} is not enabled') + if not Flag.HASH_DATA_JOIN_ENABLED.value and dataset_job_kind == DatasetJobKind.HASH_DATA_JOIN: + raise NoAccessException(f'dataset job {dataset_job_parameter.kind} is not enabled') + + global_configs = dataset_job_parameter.global_configs + + with db.session_scope() as session: + output_dataset = session.query(Dataset).get(output_dataset_id) + if not output_dataset: + raise InvalidArgumentException(f'failed to find dataset: {output_dataset_id}') + time_delta = None + if output_dataset.dataset_type == DatasetType.STREAMING: + if not (time_range.days > 0) ^ (time_range.hours > 0): + raise InvalidArgumentException('must specify cron by days or hours') + time_delta = timedelta(days=time_range.days, hours=time_range.hours) + dataset_job = DatasetJobService(session).create_as_coordinator(project_id=project_id, + kind=dataset_job_kind, + output_dataset_id=output_dataset.id, + global_configs=global_configs, + time_range=time_delta) + session.flush() + dataset_job_details = dataset_job.to_proto() + + # we set particiapnts_info in dataset_job api as we need get participants from dataset_kind + particiapnts = DatasetJobService(session=session).get_participants_need_distribute(dataset_job=dataset_job) + AuthService(session=session, + dataset_job=dataset_job).initialize_participants_info_as_coordinator(participants=particiapnts) + # set need_create_stage to True for non-cron dataset_job, + # we donot create stage here as we should promise no stage created before all particiapnts authorized + if not dataset_job.is_cron(): + context = dataset_job.get_context() + context.need_create_stage = True + dataset_job.set_context(context) + session.commit() + + return make_flask_response(dataset_job_details, status=HTTPStatus.CREATED) + + +class DatasetJobApi(Resource): + + @credentials_required + def get(self, project_id: int, dataset_job_id: int): + """Get detail of this dataset_job + --- + tags: + - dataset + description: Get detail of this dataset_job + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: dataset_job_id + schema: + type: integer + responses: + 200: + description: detail of this dataset_job + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJob' + """ + with db.session_scope() as session: + # TODO(wangsen.0914): move these logic into service + dataset_job: DatasetJob = session.query(DatasetJob).filter_by(project_id=project_id).filter_by( + id=dataset_job_id).first() + if dataset_job is None: + raise NotFoundException(f'failed to find datasetjob {dataset_job_id}') + dataset_job_pb = dataset_job.to_proto() + if not dataset_job.is_coordinator(): + participant = session.query(Participant).get(dataset_job.coordinator_id) + client = RpcClient.from_project_and_participant(dataset_job.project.name, dataset_job.project.token, + participant.domain_name) + response = client.get_dataset_job(uuid=dataset_job.uuid) + dataset_job_pb.global_configs.MergeFrom(response.dataset_job.global_configs) + dataset_job_pb.scheduler_state = response.dataset_job.scheduler_state + return make_flask_response(dataset_job_pb) + + @credentials_required + def delete(self, project_id: int, dataset_job_id: int): + """Delete dataset_job by id + --- + tags: + - dataset + description: Delete dataset_job by id + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: dataset_job_id + schema: + type: integer + responses: + 204: + description: delete dataset_job successfully + """ + with db.session_scope() as session: + dataset_job = session.query(DatasetJob).filter_by(project_id=project_id).filter_by( + id=dataset_job_id).first() + if dataset_job is None: + message = f'Failed to delete dataset_job: {dataset_job_id}; reason: failed to find dataset_job' + logging.error(message) + raise NotFoundException(message) + DatasetJobService(session).delete_dataset_job(dataset_job=dataset_job) + session.commit() + return make_flask_response(status=HTTPStatus.NO_CONTENT) + + +class DatasetJobStopApi(Resource): + + @credentials_required + def post(self, project_id: int, dataset_job_id: int): + """Stop dataset_job by id + --- + tags: + - dataset + description: Stop dataset_job by id + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: dataset_job_id + schema: + type: integer + responses: + 200: + description: stop dataset_job successfully + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJob' + """ + with db.session_scope() as session: + dataset_job = session.query(DatasetJob).filter_by(project_id=project_id).filter_by( + id=dataset_job_id).first() + if dataset_job is None: + raise NotFoundException(f'failed to find datasetjob {dataset_job_id}') + DatasetJobController(session).stop(uuid=dataset_job.uuid) + session.commit() + return make_flask_response(data=dataset_job.to_proto()) + + +class DatasetJobStopSchedulerApi(Resource): + + @credentials_required + def post(self, project_id: int, dataset_job_id: int): + """Stop scheduler dataset_job by id + --- + tags: + - dataset + description: Stop scheduler dataset_job by id + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: dataset_job_id + schema: + type: integer + responses: + 200: + description: stop scheduler dataset_job successfully + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJob' + """ + with db.session_scope() as session: + dataset_job: DatasetJob = session.query(DatasetJob).filter_by(project_id=project_id).filter_by( + id=dataset_job_id).first() + if dataset_job is None: + raise NotFoundException(f'failed to find datasetjob {dataset_job_id}') + if dataset_job.is_coordinator(): + DatasetJobService(session=session).stop_cron_scheduler(dataset_job=dataset_job) + dataset_job_pb = dataset_job.to_proto() + else: + participant = session.query(Participant).get(dataset_job.coordinator_id) + client = JobServiceClient.from_project_and_participant(participant.domain_name, + dataset_job.project.name) + client.update_dataset_job_scheduler_state(uuid=dataset_job.uuid, + scheduler_state=DatasetJobSchedulerState.STOPPED) + client = RpcClient.from_project_and_participant(dataset_job.project.name, dataset_job.project.token, + participant.domain_name) + response = client.get_dataset_job(uuid=dataset_job.uuid) + dataset_job_pb = dataset_job.to_proto() + dataset_job_pb.global_configs.MergeFrom(response.dataset_job.global_configs) + dataset_job_pb.scheduler_state = response.dataset_job.scheduler_state + session.commit() + return make_flask_response(data=dataset_job_pb) + + +class DatasetJobStagesApi(Resource): + + FILTER_FIELDS = { + 'state': filtering.SupportedField(type=filtering.FieldType.STRING, ops={FilterOp.IN: None}), + } + + SORTER_FIELDS = ['created_at'] + def __init__(self): - self._file_manager = FileManager() + self._filter_builder = filtering.FilterBuilder(model_class=DatasetJobStage, supported_fields=self.FILTER_FIELDS) + self._sorter_builder = sorting.SorterBuilder(model_class=DatasetJobStage, supported_fields=self.SORTER_FIELDS) + + @credentials_required + @use_kwargs( + { + 'page': fields.Integer(required=False, load_default=1), + 'page_size': fields.Integer(required=False, load_default=10), + 'filter_exp': FilterExpField(required=False, load_default=None, data_key='filter'), + 'sorter_exp': fields.String(required=False, load_default=None, data_key='order_by') + }, + location='query') + def get(self, project_id: int, dataset_job_id: int, page: int, page_size: int, + filter_exp: Optional[FilterExpression], sorter_exp: Optional[str]): + """List dataset job stages + --- + tags: + - dataset + description: List dataset job stages + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: dataset_job_id + schema: + type: integer + - in: query + name: page + schema: + type: integer + - in: query + name: page_size + schema: + type: integer + - in: query + name: filter + schema: + type: string + - in: query + name: order_by + schema: + type: string + responses: + 200: + description: list of dataset job stages + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJobStageRef' + """ + with db.session_scope() as session: + query = session.query(DatasetJobStage).filter(DatasetJobStage.project_id == project_id).filter( + DatasetJobStage.dataset_job_id == dataset_job_id) + if filter_exp is not None: + try: + query = self._filter_builder.build_query(query, filter_exp) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid filter: {str(e)}') from e + try: + if sorter_exp is not None: + sorter_exp = sorting.parse_expression(sorter_exp) + else: + # default sort is created_at desc + sorter_exp = sorting.SortExpression(field='created_at', is_asc=False) + query = self._sorter_builder.build_query(query, sorter_exp) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid sorter: {str(e)}') from e + pagination = paginate(query=query, page=page, page_size=page_size) + return make_flask_response( + data=[dataset_job_stage.to_ref() for dataset_job_stage in pagination.get_items()], + page_meta=pagination.get_metadata()) - @jwt_required() - def get(self): - # TODO: consider the security factor - if 'directory' in request.args: - directory = request.args['directory'] - else: - directory = os.path.join(current_app.config.get('STORAGE_ROOT'), - 'upload') - files = self._file_manager.ls(directory, recursive=True) - return {'data': [dict(file._asdict()) for file in files]} + +class DatasetJobStageApi(Resource): + + @credentials_required + def get(self, project_id: int, dataset_job_id: int, dataset_job_stage_id: int): + """Get details of given dataset job stage + --- + tags: + - dataset + description: Get details of given dataset job stage + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: dataset_job_id + schema: + type: integer + - in: path + name: dataset_job_stage_id + schema: + type: integer + responses: + 200: + description: dataset job stage details + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.DatasetJobStage' + """ + with db.session_scope() as session: + dataset_job_stage: DatasetJobStage = session.query(DatasetJobStage).filter( + DatasetJobStage.project_id == project_id).filter( + DatasetJobStage.dataset_job_id == dataset_job_id).filter( + DatasetJobStage.id == dataset_job_stage_id).first() + if not dataset_job_stage: + raise NotFoundException(f'Failed to find dataset job stage: {dataset_job_stage_id}') + dataset_job_stage_pb = dataset_job_stage.to_proto() + if not dataset_job_stage.is_coordinator(): + participant = session.query(Participant).get(dataset_job_stage.coordinator_id) + client = JobServiceClient.from_project_and_participant(participant.domain_name, + dataset_job_stage.project.name) + response = client.get_dataset_job_stage(dataset_job_stage_uuid=dataset_job_stage.uuid) + dataset_job_stage_pb.global_configs.MergeFrom(response.dataset_job_stage.global_configs) + + return make_flask_response(dataset_job_stage_pb) def initialize_dataset_apis(api: Api): api.add_resource(DatasetsApi, '/datasets') api.add_resource(DatasetApi, '/datasets/') + api.add_resource(DatasetPublishApi, '/datasets/:publish') + api.add_resource(DatasetAuthorizehApi, '/datasets/:authorize') + api.add_resource(DatasetFlushAuthStatusApi, '/datasets/:flush_auth_status') api.add_resource(BatchesApi, '/datasets//batches') + api.add_resource(BatchApi, '/datasets//batches/') + api.add_resource(ChildrenDatasetsApi, '/datasets//children_datasets') + api.add_resource(BatchAnalyzeApi, '/datasets//batches/:analyze') + api.add_resource(BatchMetricsApi, '/datasets//batches//feature_metrics') + api.add_resource(BatchRerunApi, '/datasets//batches/:rerun') api.add_resource(DatasetPreviewApi, '/datasets//preview') - api.add_resource(DatasetMetricsApi, - '/datasets//feature_metrics') - api.add_resource(FilesApi, '/files') + api.add_resource(DatasetLedgerApi, '/datasets//ledger') + api.add_resource(DatasetExportApi, '/datasets/:export') + api.add_resource(DatasetStateFixtApi, '/datasets/:state_fix') + + api.add_resource(DataSourcesApi, '/data_sources') + api.add_resource(DataSourceApi, '/data_sources/') + api.add_resource(DataSourceCheckConnectionApi, '/data_sources:check_connection') + api.add_resource(DataSourceTreeApi, '/data_sources//tree') + + api.add_resource(ParticipantDatasetsApi, '/project//participant_datasets') + + api.add_resource(DatasetJobDefinitionApi, '/dataset_job_definitions/') + api.add_resource(DatasetJobsApi, '/projects//dataset_jobs') + api.add_resource(DatasetJobApi, '/projects//dataset_jobs/') + api.add_resource(DatasetJobStopApi, '/projects//dataset_jobs/:stop') + api.add_resource(DatasetJobStopSchedulerApi, + '/projects//dataset_jobs/:stop_scheduler') + + api.add_resource(DatasetJobStagesApi, + '/projects//dataset_jobs//dataset_job_stages') + api.add_resource( + DatasetJobStageApi, + '/projects//dataset_jobs//dataset_job_stages/') + + schema_manager.append(DataSourceParameter) + schema_manager.append(DatasetJobConfigParameter) + schema_manager.append(DatasetParameter) + schema_manager.append(BatchParameter) + schema_manager.append(DatasetJobParameter) + schema_manager.append(TimeRangeParameter) diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/data_pipeline.py b/web_console_v2/api/fedlearner_webconsole/dataset/data_pipeline.py deleted file mode 100644 index 70b6d4588..000000000 --- a/web_console_v2/api/fedlearner_webconsole/dataset/data_pipeline.py +++ /dev/null @@ -1,185 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import io -import logging -import os -import tarfile -import traceback - -from enum import Enum -from copy import deepcopy -from typing import Tuple, Optional, List -from uuid import uuid4 - -from envs import Envs - -from fedlearner_webconsole.composer.interface import IItem, IRunner, ItemType -from fedlearner_webconsole.composer.models import Context, RunnerStatus -from fedlearner_webconsole.sparkapp.service import SparkAppService -from fedlearner_webconsole.sparkapp.schema import SparkAppConfig - - -class DataPipelineType(Enum): - ANALYZER = 'analyzer' - CONVERTER = 'converter' - TRANSFORMER = 'transformer' - - -class DataPipelineItem(IItem): - def __init__(self, task_id: int): - self.id = task_id - - def type(self) -> ItemType: - return ItemType.DATA_PIPELINE - - def get_id(self) -> int: - return self.id - - -class DataPipelineRunner(IRunner): - TYPE_PARAMS_MAPPER = { - DataPipelineType.ANALYZER: { - 'files_dir': 'fedlearner_webconsole/dataset/sparkapp/pipeline', - 'main_application': 'pipeline/analyzer.py', - }, - DataPipelineType.CONVERTER: { - 'files_dir': 'fedlearner_webconsole/dataset/sparkapp/pipeline', - 'main_application': 'pipeline/converter.py', - }, - DataPipelineType.TRANSFORMER: { - 'files_dir': 'fedlearner_webconsole/dataset/sparkapp/pipeline', - 'main_application': 'pipeline/transformer.py', - } - } - - SPARKAPP_STATE_TO_RUNNER_STATUS = { - '': RunnerStatus.RUNNING, - 'SUBMITTED': RunnerStatus.RUNNING, - 'PENDING_RERUN': RunnerStatus.RUNNING, - 'RUNNING': RunnerStatus.RUNNING, - 'COMPLETED': RunnerStatus.DONE, - 'SUCCEEDING': RunnerStatus.DONE, - 'FAILED': RunnerStatus.FAILED, - 'SUBMISSION_FAILED': RunnerStatus.FAILED, - 'INVALIDATING': RunnerStatus.FAILED, - 'FAILING': RunnerStatus.FAILED, - 'UNKNOWN': RunnerStatus.FAILED - } - - def __init__(self, task_id: int) -> None: - self.task_id = task_id - self.task_type = None - self.files_dir = None - self.files_path = None - self.main_application = None - self.command = [] - self.sparkapp_name = None - self.args = {} - self.started = False - self.error_msg = False - - self.spark_service = SparkAppService() - - def start(self, context: Context): - try: - self.started = True - self.args = deepcopy(context.data.get(str(self.task_id), {})) - self.task_type = DataPipelineType(self.args.pop('task_type')) - name = self.args.pop('sparkapp_name') - job_id = uuid4().hex - self.sparkapp_name = f'pipe-{self.task_type.value}-{job_id}-{name}' - - params = self.__class__.TYPE_PARAMS_MAPPER[self.task_type] - self.files_dir = os.path.join(Envs.BASE_DIR, params['files_dir']) - self.files_path = Envs.SPARKAPP_FILES_PATH - self.main_application = params['main_application'] - self.command = self.args.pop('input') - - files = None - if self.files_path is None: - files_obj = io.BytesIO() - with tarfile.open(fileobj=files_obj, mode='w') as f: - f.add(self.files_dir) - files = files_obj.getvalue() - - config = { - 'name': self.sparkapp_name, - 'files': files, - 'files_path': self.files_path, - 'image_url': Envs.SPARKAPP_IMAGE_URL, - 'volumes': gen_sparkapp_volumes(Envs.SPARKAPP_VOLUMES), - 'driver_config': { - 'cores': - 1, - 'memory': - '4g', - 'volume_mounts': - gen_sparkapp_volume_mounts(Envs.SPARKAPP_VOLUME_MOUNTS), - }, - 'executor_config': { - 'cores': - 2, - 'memory': - '4g', - 'instances': - 1, - 'volume_mounts': - gen_sparkapp_volume_mounts(Envs.SPARKAPP_VOLUME_MOUNTS), - }, - 'main_application': f'${{prefix}}/{self.main_application}', - 'command': self.command, - } - config_dict = SparkAppConfig.from_dict(config) - resp = self.spark_service.submit_sparkapp(config=config_dict) - logging.info( - f'created spark app, name: {name}, ' - f'config: {config_dict.__dict__}, resp: {resp.__dict__}') - except Exception as e: # pylint: disable=broad-except - self.error_msg = f'[composer] failed to run this item, err: {e}, \ - trace: {traceback.format_exc()}' - - def result(self, context: Context) -> Tuple[RunnerStatus, dict]: - if self.error_msg: - context.set_data(f'failed_{self.task_id}', - {'error': self.error_msg}) - return RunnerStatus.FAILED, {} - if not self.started: - return RunnerStatus.RUNNING, {} - resp = self.spark_service.get_sparkapp_info(self.sparkapp_name) - logging.info(f'sparkapp resp: {resp.__dict__}') - if not resp.state: - return RunnerStatus.RUNNING, {} - return self.__class__.SPARKAPP_STATE_TO_RUNNER_STATUS.get( - resp.state, RunnerStatus.FAILED), resp.to_dict() - - -def gen_sparkapp_volumes(value: str) -> Optional[List[dict]]: - if value != 'data': - return None - # TODO: better to read from conf - return [{ - 'name': 'data', - 'persistentVolumeClaim': { - 'claimName': 'pvc-fedlearner-default' - } - }] - - -def gen_sparkapp_volume_mounts(value: str) -> Optional[List[dict]]: - if value != 'data': - return None - # TODO: better to read from conf - return [{'name': 'data', 'mountPath': '/data'}] diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/import_handler.py b/web_console_v2/api/fedlearner_webconsole/dataset/import_handler.py deleted file mode 100644 index 38a800c07..000000000 --- a/web_console_v2/api/fedlearner_webconsole/dataset/import_handler.py +++ /dev/null @@ -1,143 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import logging -import threading -import os -from concurrent.futures.thread import ThreadPoolExecutor -from datetime import timedelta, datetime - -from fedlearner_webconsole.dataset.models import DataBatch, BatchState -from fedlearner_webconsole.db import db -from fedlearner_webconsole.utils.file_manager import FileManager -from fedlearner_webconsole.proto import dataset_pb2 - - -class ImportHandler(object): - def __init__(self): - self._executor = ThreadPoolExecutor(max_workers=os.cpu_count() * 3) - self._file_manager = FileManager() - self._pending_imports = set() - self._running_imports = set() - self._import_lock = threading.Lock() - self._app = None - - def __del__(self): - self._executor.shutdown() - - def init(self, app): - self._app = app - - def schedule_to_handle(self, dataset_batch_ids): - if isinstance(dataset_batch_ids, int): - dataset_batch_ids = [dataset_batch_ids] - self._pending_imports.update(dataset_batch_ids) - - def _copy_file(self, - source_path, - destination_path, - move=False, - num_retry=3): - logging.info('%s from %s to %s', 'moving' if move else 'copying', - source_path, destination_path) - # Creates parent folders if needed - parent_folder = os.path.dirname(destination_path) - self._file_manager.mkdir(parent_folder) - success = False - error_message = '' - for _ in range(num_retry): - try: - if move: - self._file_manager.move(source_path, destination_path) - else: - self._file_manager.copy(source_path, destination_path) - success = True - break - except Exception as e: # pylint: disable=broad-except - logging.error( - 'Error occurred when importing file from %s to %s', - source_path, destination_path) - error_message = str(e) - file = dataset_pb2.File(source_path=source_path, - destination_path=destination_path) - if not success: - file.error_message = error_message - file.state = dataset_pb2.File.State.FAILED - else: - file.size = self._file_manager.ls(destination_path)[0].size - file.state = dataset_pb2.File.State.COMPLETED - return file - - def _import_batch(self, batch_id): - self._import_lock.acquire() - if batch_id in self._running_imports: - return - self._running_imports.add(batch_id) - self._import_lock.release() - - # Pushes app context to make db session work - self._app.app_context().push() - - logging.info('Importing batch %d', batch_id) - batch = DataBatch.query.get(batch_id) - batch.state = BatchState.IMPORTING - db.session.commit() - db.session.refresh(batch) - details = batch.get_details() - - for file in details.files: - if file.state == dataset_pb2.File.State.UNSPECIFIED: - # Recovers the state - try: - destination_existed = len( - self._file_manager.ls(file.destination_path)) > 0 - except Exception: # pylint: disable=broad-except - destination_existed = False - if destination_existed: - file.state = dataset_pb2.File.State.COMPLETED - continue - # Moves/Copies - file.MergeFrom( - self._copy_file(source_path=file.source_path, - destination_path=file.destination_path, - move=batch.move)) - - batch.set_details(details) - db.session.commit() - - self._import_lock.acquire() - self._running_imports.remove(batch_id) - self._import_lock.release() - - def handle(self, pull=False): - """Handles all the batches in the queue or all batches which - should be imported.""" - batches_to_run = self._pending_imports - self._pending_imports = set() - if pull: - # TODO: should separate pull logic to a cron job, - # otherwise there will be a race condition that two handlers - # are trying to move the same batch - one_hour_ago = datetime.utcnow() - timedelta(hours=1) - pulled_batches = db.session.query(DataBatch.id).filter( - (DataBatch.state == BatchState.NEW) | - (DataBatch.state == BatchState.IMPORTING))\ - .filter(DataBatch.updated_at < one_hour_ago)\ - .all() - pulled_ids = [bid for bid, in pulled_batches] - batches_to_run.update(pulled_ids) - - for batch in batches_to_run: - self._executor.submit(self._import_batch, batch) diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/models.py b/web_console_v2/api/fedlearner_webconsole/dataset/models.py index 981a00f04..c85c73d10 100644 --- a/web_console_v2/api/fedlearner_webconsole/dataset/models.py +++ b/web_console_v2/api/fedlearner_webconsole/dataset/models.py @@ -1,29 +1,40 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - -# coding: utf-8 +# import enum +import os +from typing import Optional + from sqlalchemy.sql import func from sqlalchemy import UniqueConstraint -from fedlearner_webconsole.db import db -from fedlearner_webconsole.utils.mixins import to_dict_mixin +from google.protobuf import text_format +from fedlearner_webconsole.dataset.consts import ERROR_BATCH_SIZE from fedlearner_webconsole.proto import dataset_pb2 +from fedlearner_webconsole.proto.dataset_pb2 import (DatasetJobGlobalConfigs, DatasetRef, DatasetMetaInfo, + DatasetJobContext, DatasetJobStageContext, TimeRange) +from fedlearner_webconsole.utils.base_model.review_ticket_model import TicketStatus +from fedlearner_webconsole.utils.base_model.auth_model import AuthStatus +from fedlearner_webconsole.utils.base_model.review_ticket_and_auth_model import ReviewTicketAndAuthModel +from fedlearner_webconsole.utils.pp_datetime import to_timestamp +from fedlearner_webconsole.db import db, default_table_args +from fedlearner_webconsole.utils.base_model.softdelete_model import SoftDeleteModel +from fedlearner_webconsole.workflow.models import WorkflowExternalState class DatasetType(enum.Enum): - PSI = 'PSI' + PSI = 'PSI' # use PSI as none streaming dataset type STREAMING = 'STREAMING' @@ -32,88 +43,149 @@ class BatchState(enum.Enum): SUCCESS = 'SUCCESS' FAILED = 'FAILED' IMPORTING = 'IMPORTING' + UNKNOWN = 'UNKNOWN' -@to_dict_mixin( - extras={ - 'data_batches': - lambda dataset: - [data_batch.to_dict() for data_batch in dataset.data_batches] - }) -class Dataset(db.Model): - __tablename__ = 'datasets_v2' - __table_args__ = ({ - 'comment': 'This is webconsole dataset table', - 'mysql_engine': 'innodb', - 'mysql_charset': 'utf8mb4', - }) - - id = db.Column(db.Integer, - primary_key=True, - autoincrement=True, - comment='id') - name = db.Column(db.String(255), nullable=False, comment='dataset name') - dataset_type = db.Column(db.Enum(DatasetType, native_enum=False), - nullable=False, - comment='data type') - path = db.Column(db.String(512), comment='dataset path') - comment = db.Column('cmt', - db.Text(), - key='comment', - comment='comment of dataset') - created_at = db.Column(db.DateTime(timezone=True), - server_default=func.now(), - comment='created time') - updated_at = db.Column(db.DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - comment='updated time') - deleted_at = db.Column(db.DateTime(timezone=True), comment='deleted time') - project_id = db.Column(db.Integer, default=0, comment='project_id') +# used to represent dataset and data_batch frontend state +class ResourceState(enum.Enum): + PENDING = 'PENDING' + PROCESSING = 'PROCESSING' + SUCCEEDED = 'SUCCEEDED' + FAILED = 'FAILED' + + +class PublishFrontendState(enum.Enum): + UNPUBLISHED = 'UNPUBLISHED' + TICKET_PENDING = 'TICKET_PENDING' + TICKET_DECLINED = 'TICKET_DECLINED' + PUBLISHED = 'PUBLISHED' + + +class DatasetFormat(enum.Enum): + TABULAR = 0 + IMAGE = 1 + NONE_STRUCTURED = 2 + + +class ImportType(enum.Enum): + COPY = 'COPY' + NO_COPY = 'NO_COPY' + + +class DatasetKindV2(enum.Enum): + RAW = 'raw' + PROCESSED = 'processed' + SOURCE = 'source' + EXPORTED = 'exported' + INTERNAL_PROCESSED = 'internal_processed' # dataset generatred by internal module, like model or tee + + +class DatasetSchemaChecker(enum.Enum): + RAW_ID_CHECKER = 'RAW_ID_CHECKER' + NUMERIC_COLUMNS_CHECKER = 'NUMERIC_COLUMNS_CHECKER' + + +class DatasetJobKind(enum.Enum): + RSA_PSI_DATA_JOIN = 'RSA_PSI_DATA_JOIN' + LIGHT_CLIENT_RSA_PSI_DATA_JOIN = 'LIGHT_CLIENT_RSA_PSI_DATA_JOIN' + OT_PSI_DATA_JOIN = 'OT_PSI_DATA_JOIN' + LIGHT_CLIENT_OT_PSI_DATA_JOIN = 'LIGHT_CLIENT_OT_PSI_DATA_JOIN' + HASH_DATA_JOIN = 'HASH_DATA_JOIN' + DATA_JOIN = 'DATA_JOIN' + DATA_ALIGNMENT = 'DATA_ALIGNMENT' + IMPORT_SOURCE = 'IMPORT_SOURCE' + EXPORT = 'EXPORT' + ANALYZER = 'ANALYZER' + + +# micro dataset_job's input/output dataset is the same one +MICRO_DATASET_JOB = [DatasetJobKind.ANALYZER] + +LOCAL_DATASET_JOBS = [ + DatasetJobKind.IMPORT_SOURCE, + DatasetJobKind.ANALYZER, + DatasetJobKind.EXPORT, + DatasetJobKind.LIGHT_CLIENT_OT_PSI_DATA_JOIN, + DatasetJobKind.LIGHT_CLIENT_RSA_PSI_DATA_JOIN, +] + + +class DatasetJobState(enum.Enum): + PENDING = 'PENDING' + RUNNING = 'RUNNING' + SUCCEEDED = 'SUCCEEDED' + FAILED = 'FAILED' + STOPPED = 'STOPPED' + + +class StoreFormat(enum.Enum): + UNKNOWN = 'UNKNOWN' + CSV = 'CSV' + TFRECORDS = 'TFRECORDS' + + +class DatasetJobSchedulerState(enum.Enum): + PENDING = 'PENDING' + RUNNABLE = 'RUNNABLE' + STOPPED = 'STOPPED' + - data_batches = db.relationship( - 'DataBatch', primaryjoin='foreign(DataBatch.dataset_id) == Dataset.id') - project = db.relationship( - 'Project', primaryjoin='foreign(Dataset.project_id) == Project.id') +DATASET_STATE_CONVERT_MAP_V2 = { + DatasetJobState.PENDING: ResourceState.PENDING, + DatasetJobState.RUNNING: ResourceState.PROCESSING, + DatasetJobState.SUCCEEDED: ResourceState.SUCCEEDED, + DatasetJobState.FAILED: ResourceState.FAILED, + DatasetJobState.STOPPED: ResourceState.FAILED, +} + + +class DataSourceType(enum.Enum): + # hdfs datasource path, e.g. hdfs:///home/xxx + HDFS = 'hdfs' + # nfs datasource path, e.g. file:///data/xxx + FILE = 'file' + + +SOURCE_IS_DELETED = 'deleted' +WORKFLOW_STATUS_STATE_MAPPER = { + WorkflowExternalState.COMPLETED: ResourceState.SUCCEEDED, + WorkflowExternalState.FAILED: ResourceState.FAILED, + WorkflowExternalState.STOPPED: ResourceState.FAILED, + WorkflowExternalState.INVALID: ResourceState.FAILED, +} +DATASET_JOB_FINISHED_STATE = [DatasetJobState.SUCCEEDED, DatasetJobState.FAILED, DatasetJobState.STOPPED] -@to_dict_mixin(extras={'details': (lambda batch: batch.get_details())}) class DataBatch(db.Model): __tablename__ = 'data_batches_v2' - __table_args__ = ( - UniqueConstraint('event_time', - 'dataset_id', - name='uniq_event_time_dataset_id'), - { - 'comment': 'This is webconsole dataset table', - 'mysql_engine': 'innodb', - 'mysql_charset': 'utf8mb4', - }, - ) - id = db.Column(db.Integer, - primary_key=True, - autoincrement=True, - comment='id') - event_time = db.Column(db.TIMESTAMP(timezone=True), - nullable=False, - comment='event_time') + __table_args__ = (UniqueConstraint('event_time', 'dataset_id', name='uniq_event_time_dataset_id'), + default_table_args('This is webconsole dataset table')) + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='id') + name = db.Column(db.String(255), nullable=True, comment='data_batch name') + event_time = db.Column(db.TIMESTAMP(timezone=True), nullable=True, comment='event_time') dataset_id = db.Column(db.Integer, nullable=False, comment='dataset_id') path = db.Column(db.String(512), comment='path') - state = db.Column(db.Enum(BatchState, native_enum=False), + # TODO(wangsen.0914): gonna to deprecate + state = db.Column(db.Enum(BatchState, native_enum=False, create_constraint=False), default=BatchState.NEW, comment='state') + # move column will be deprecated after dataset refactor move = db.Column(db.Boolean, default=False, comment='move') # Serialized proto of DatasetBatch - details = db.Column(db.LargeBinary(), comment='details') - file_size = db.Column(db.Integer, default=0, comment='file_size') - num_imported_file = db.Column(db.Integer, - default=0, - comment='num_imported_file') - num_file = db.Column(db.Integer, default=0, comment='num_file') + file_size = db.Column(db.BigInteger, default=0, comment='file_size in bytes') + num_example = db.Column(db.BigInteger, default=0, comment='num_example') + num_feature = db.Column(db.BigInteger, default=0, comment='num_feature') + meta_info = db.Column(db.Text(16777215), comment='dataset meta info') comment = db.Column('cmt', db.Text(), key='comment', comment='comment') - created_at = db.Column(db.DateTime(timezone=True), - server_default=func.now(), - comment='created_at') + latest_parent_dataset_job_stage_id = db.Column(db.Integer, + nullable=False, + server_default=db.text('0'), + comment='latest parent dataset_job_stage id') + latest_analyzer_dataset_job_stage_id = db.Column(db.Integer, + nullable=False, + server_default=db.text('0'), + comment='latest analyzer dataset_job_stage id') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created_at') updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), server_onupdate=func.now(), @@ -125,30 +197,605 @@ class DataBatch(db.Model): 'foreign(DataBatch.dataset_id)', back_populates='data_batches') - def set_details(self, proto): - self.num_file = len(proto.files) - num_imported_file = 0 - num_failed_file = 0 + latest_parent_dataset_job_stage = db.relationship( + 'DatasetJobStage', + primaryjoin='DatasetJobStage.id == foreign(DataBatch.latest_parent_dataset_job_stage_id)', + # To disable the warning of back_populates + overlaps='data_batch') + + @property + def batch_name(self): + return self.name or os.path.basename(os.path.abspath(self.path)) + + def get_frontend_state(self) -> ResourceState: + # use dataset_job state to replace dataset_job_stage state when dataset_job_stage not support + if self.latest_parent_dataset_job_stage is None: + return self.dataset.get_frontend_state() + return DATASET_STATE_CONVERT_MAP_V2.get(self.latest_parent_dataset_job_stage.state) + + def is_available(self) -> bool: + return self.get_frontend_state() == ResourceState.SUCCEEDED + + def to_proto(self) -> dataset_pb2.DataBatch: + proto = dataset_pb2.DataBatch(id=self.id, + name=self.batch_name, + dataset_id=self.dataset_id, + path=self.path, + file_size=self.file_size, + num_example=self.num_example, + num_feature=self.num_feature, + comment=self.comment, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + event_time=to_timestamp(self.event_time) if self.event_time else 0, + latest_parent_dataset_job_stage_id=self.latest_parent_dataset_job_stage_id, + latest_analyzer_dataset_job_stage_id=self.latest_analyzer_dataset_job_stage_id) + proto.state = self.get_frontend_state().name + return proto + + +class Dataset(SoftDeleteModel, ReviewTicketAndAuthModel, db.Model): + __tablename__ = 'datasets_v2' + __table_args__ = (default_table_args('This is webconsole dataset table')) + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='id') + uuid = db.Column(db.String(255), nullable=True, comment='dataset uuid') + is_published = db.Column(db.Boolean, default=False, comment='dataset is published or not') + name = db.Column(db.String(255), nullable=False, comment='dataset name') + creator_username = db.Column(db.String(255), default='', comment='creator username') + dataset_type = db.Column(db.Enum(DatasetType, native_enum=False, create_constraint=False), + default=DatasetType.PSI, + nullable=False, + comment='data type') + path = db.Column(db.String(512), comment='dataset path') + comment = db.Column('cmt', db.Text(), key='comment', comment='comment of dataset') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created time') + updated_at = db.Column(db.DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + comment='updated time') + project_id = db.Column(db.Integer, default=0, comment='project_id') + # New version of dataset kind + dataset_kind = db.Column(db.Enum(DatasetKindV2, native_enum=False, length=32, create_constraint=False), + default=DatasetKindV2.RAW, + comment='new version of dataset kind, choices [raw, processed, ...]') + # DatasetFormat enum + dataset_format = db.Column(db.Integer, default=0, comment='dataset format') + # StoreFormat + store_format = db.Column(db.Enum(StoreFormat, native_enum=False, length=32, create_constraint=False), + default=StoreFormat.TFRECORDS, + comment='dataset store format, like CSV, TFRECORDS, ...') + meta_info = db.Column(db.Text(16777215), comment='dataset meta info') + + import_type = db.Column(db.Enum(ImportType, length=64, native_enum=False, create_constraint=False), + server_default=ImportType.COPY.name, + comment='import type') + + data_batches = db.relationship('DataBatch', + primaryjoin='foreign(DataBatch.dataset_id) == Dataset.id', + order_by='desc(DataBatch.id)') + project = db.relationship('Project', primaryjoin='foreign(Dataset.project_id) == Project.id') + + # dataset only has one main dataset_job as parent_dataset_job, but could have many micro dataset_job + @property + def parent_dataset_job(self): + return None if not db.object_session(self) else db.object_session(self).query(DatasetJob).filter( + DatasetJob.output_dataset_id == self.id).filter( + DatasetJob.kind.not_in(MICRO_DATASET_JOB)).execution_options(include_deleted=True).first() + + # dataset only has one analyzer dataset_job + def analyzer_dataset_job(self): + return None if not db.object_session(self) else db.object_session(self).query(DatasetJob).filter( + DatasetJob.output_dataset_id == self.id).filter( + DatasetJob.kind == DatasetJobKind.ANALYZER).execution_options(include_deleted=True).first() + + # single table inheritance + # Ref: https://docs.sqlalchemy.org/en/14/orm/inheritance.html + __mapper_args__ = {'polymorphic_identity': DatasetKindV2.RAW, 'polymorphic_on': dataset_kind} + + def get_frontend_state(self) -> ResourceState: + # if parent_dataset_job failed to generate, dataset state is failed + if self.parent_dataset_job is None: + return ResourceState.FAILED + return DATASET_STATE_CONVERT_MAP_V2.get(self.parent_dataset_job.state) + + def get_file_size(self) -> int: file_size = 0 - # Aggregates stats - for file in proto.files: - if file.state == dataset_pb2.File.State.COMPLETED: - num_imported_file += 1 - file_size += file.size - elif file.state == dataset_pb2.File.State.FAILED: - num_failed_file += 1 - if num_imported_file + num_failed_file == self.num_file: - if num_failed_file > 0: - self.state = BatchState.FAILED - else: - self.state = BatchState.SUCCESS - self.num_imported_file = num_imported_file - self.file_size = file_size - self.details = proto.SerializeToString() - - def get_details(self): - if self.details is None: + for batch in self.data_batches: + if not batch.file_size or batch.file_size == ERROR_BATCH_SIZE: + continue + file_size += batch.file_size + return file_size + + def get_num_example(self) -> int: + return sum([batch.num_example or 0 for batch in self.data_batches]) + + def get_num_feature(self) -> int: + if len(self.data_batches) != 0: + # num_feature is decided by the first data_batch + return self.data_batches[0].num_feature + return 0 + + # TODO(hangweiqiang): remove data_source after adapting fedlearner to dataset path + def get_data_source(self) -> Optional[str]: + if self.parent_dataset_job is not None: + dataset_job_stage = db.object_session(self).query(DatasetJobStage).filter_by( + dataset_job_id=self.parent_dataset_job.id).first() + if dataset_job_stage is not None: + return f'{dataset_job_stage.uuid}-psi-data-join-job' + if self.parent_dataset_job.workflow is not None: + return f'{self.parent_dataset_job.workflow.uuid}-psi-data-join-job' + return None + + @property + def publish_frontend_state(self) -> PublishFrontendState: + if not self.is_published: + return PublishFrontendState.UNPUBLISHED + if self.ticket_status == TicketStatus.APPROVED: + return PublishFrontendState.PUBLISHED + if self.ticket_status == TicketStatus.DECLINED: + return PublishFrontendState.TICKET_DECLINED + return PublishFrontendState.TICKET_PENDING + + def to_ref(self) -> DatasetRef: + # TODO(liuhehan): this is a lazy update of dataset store_format, remove it after release 2.4 + if self.dataset_kind in [DatasetKindV2.RAW, DatasetKindV2.PROCESSED] and self.store_format is None: + self.store_format = StoreFormat.TFRECORDS + # TODO(liuhehan): this is a lazy update for auth status, remove after release 2.4 + if self.auth_status is None: + self.auth_status = AuthStatus.AUTHORIZED + return DatasetRef(id=self.id, + uuid=self.uuid, + project_id=self.project_id, + name=self.name, + created_at=to_timestamp(self.created_at), + state_frontend=self.get_frontend_state().name, + path=self.path, + is_published=self.is_published, + dataset_format=DatasetFormat(self.dataset_format).name, + comment=self.comment, + dataset_kind=self.dataset_kind.name, + file_size=self.get_file_size(), + num_example=self.get_num_example(), + data_source=self.get_data_source(), + creator_username=self.creator_username, + dataset_type=self.dataset_type.name, + store_format=self.store_format.name if self.store_format else '', + import_type=self.import_type.name, + publish_frontend_state=self.publish_frontend_state.name, + auth_frontend_state=self.auth_frontend_state.name, + local_auth_status=self.auth_status.name, + participants_info=self.get_participants_info()) + + def to_proto(self) -> dataset_pb2.Dataset: + # TODO(liuhehan): this is a lazy update of dataset store_format, remove it after release 2.4 + if self.dataset_kind in [DatasetKindV2.RAW, DatasetKindV2.PROCESSED] and self.store_format is None: + self.store_format = StoreFormat.TFRECORDS + # TODO(liuhehan): this is a lazy update for auth status, remove after release 2.4 + if self.auth_status is None: + self.auth_status = AuthStatus.AUTHORIZED + meta_data = self.get_meta_info() + analyzer_dataset_job = self.analyzer_dataset_job() + # use newest data_batch updated_at time as dataset updated_at time if has data_batch + updated_at = self.data_batches[0].updated_at if self.data_batches else self.updated_at + return dataset_pb2.Dataset( + id=self.id, + uuid=self.uuid, + is_published=self.is_published, + project_id=self.project_id, + name=self.name, + workflow_id=self.parent_dataset_job.workflow_id if self.parent_dataset_job is not None else 0, + path=self.path, + created_at=to_timestamp(self.created_at), + data_source=self.get_data_source(), + file_size=self.get_file_size(), + num_example=self.get_num_example(), + comment=self.comment, + num_feature=self.get_num_feature(), + updated_at=to_timestamp(updated_at), + deleted_at=to_timestamp(self.deleted_at) if self.deleted_at else None, + parent_dataset_job_id=self.parent_dataset_job.id if self.parent_dataset_job is not None else 0, + dataset_format=DatasetFormat(self.dataset_format).name, + analyzer_dataset_job_id=analyzer_dataset_job.id if analyzer_dataset_job is not None else 0, + state_frontend=self.get_frontend_state().name, + dataset_kind=self.dataset_kind.name, + value=meta_data.value, + schema_checkers=meta_data.schema_checkers, + creator_username=self.creator_username, + import_type=self.import_type.name, + dataset_type=self.dataset_type.name, + store_format=self.store_format.name if self.store_format else '', + publish_frontend_state=self.publish_frontend_state.name, + auth_frontend_state=self.auth_frontend_state.name, + local_auth_status=self.auth_status.name, + participants_info=self.get_participants_info()) + + def is_tabular(self) -> bool: + return self.dataset_format == DatasetFormat.TABULAR.value + + def is_image(self) -> bool: + return self.dataset_format == DatasetFormat.IMAGE.value + + def set_meta_info(self, meta: DatasetMetaInfo): + if meta is None: + meta = DatasetMetaInfo() + self.meta_info = text_format.MessageToString(meta) + + def get_meta_info(self) -> DatasetMetaInfo: + meta = DatasetMetaInfo() + if self.meta_info is not None: + meta = text_format.Parse(self.meta_info, DatasetMetaInfo()) + return meta + + def get_single_batch(self) -> DataBatch: + """Get single batch of this dataset + + Returns: + DataBatch: according data batch + + Raises: + TypeError: when there's no data batch or more than one data batch + """ + if not self.data_batches: + raise TypeError(f'there is no data_batch for this dataset {self.id}') + if len(self.data_batches) != 1: + raise TypeError(f'there is more than one data_batch for this dataset {self.id}') + return self.data_batches[0] + + +class DataSource(Dataset): + + __mapper_args__ = {'polymorphic_identity': DatasetKindV2.SOURCE} + + def to_proto(self) -> dataset_pb2.DataSource: + meta_info = self.get_meta_info() + return dataset_pb2.DataSource( + id=self.id, + comment=self.comment, + uuid=self.uuid, + name=self.name, + type=meta_info.datasource_type, + url=self.path, + created_at=to_timestamp(self.created_at), + project_id=self.project_id, + is_user_upload=meta_info.is_user_upload, + is_user_export=meta_info.is_user_export, + creator_username=self.creator_username, + dataset_format=DatasetFormat(self.dataset_format).name, + store_format=self.store_format.name if self.store_format else '', + dataset_type=self.dataset_type.name, + ) + + +class ProcessedDataset(Dataset): + + __mapper_args__ = {'polymorphic_identity': DatasetKindV2.PROCESSED} + + +class ExportedDataset(Dataset): + + __mapper_args__ = {'polymorphic_identity': DatasetKindV2.EXPORTED} + + +class InternalProcessedDataset(Dataset): + + __mapper_args__ = {'polymorphic_identity': DatasetKindV2.INTERNAL_PROCESSED} + + def get_frontend_state(self) -> ResourceState: + # we just hack internal_processed dataset state to successded now + return ResourceState.SUCCEEDED + + +class DatasetJob(SoftDeleteModel, db.Model): + """ DatasetJob is the abstraction of basic action inside dataset module. + + UseCase 1: A import job from datasource to a dataset + { + "id": 1, + "uuid": u456, + "input_dataset_id": 5, + "output_dataset_id": 4, + "kind": "import_datasource", + "global_configs": map, + "workflow_id": 6, + "coordinator_id": 0, + } + + UseCase 2: A data join job between participants + coodinator: + { + "id": 1, + "uuid": u456, + "input_dataset_id": 2, + "output_dataset_id": 4, + "kind": "rsa_psi_data_join", + "global_configs": map, + "coordinator_id": 0, + "workflow_id": 6, + } + + participant: + { + "id": 1, + "uuid": u456, + "input_dataset_id": 4, + "output_dataset_id": 7, + "kind": "rsa_psi_data_join", + "global_configs": "", # pull from coodinator + "coordinator_id": 1, + "workflow_id": 7, + } + """ + __tablename__ = 'dataset_jobs_v2' + __table_args__ = (UniqueConstraint('uuid', name='uniq_dataset_job_uuid'), default_table_args('dataset_jobs_v2')) + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='id of dataset job') + uuid = db.Column(db.String(255), nullable=False, comment='dataset job uuid') + name = db.Column(db.String(255), nullable=True, comment='dataset job name') + + # state is updated to keep the same with the newest dataset_job_stage + state = db.Column(db.Enum(DatasetJobState, length=64, native_enum=False, create_constraint=False), + nullable=False, + default=DatasetJobState.PENDING, + comment='dataset job state') + project_id = db.Column(db.Integer, nullable=False, comment='project id') + + # If multiple dataset/datasource input is supported, the following two columns will be deprecated. + # Instead, a new table will be introduced. + input_dataset_id = db.Column(db.Integer, nullable=False, comment='input dataset id') + output_dataset_id = db.Column(db.Integer, nullable=False, comment='output dataset id') + + kind = db.Column(db.Enum(DatasetJobKind, length=128, native_enum=False, create_constraint=False), + nullable=False, + comment='dataset job kind') + # If batch update mode is supported, this column will be deprecated. + # Instead, a new table called DatasetStage and a new Column called Context will be introduced. + workflow_id = db.Column(db.Integer, nullable=True, default=0, comment='relating workflow id') + context = db.Column(db.Text(), nullable=True, default=None, comment='context info of dataset job') + + global_configs = db.Column( + db.Text(), comment='global configs of this job including related participants only appear in coordinator') + coordinator_id = db.Column(db.Integer, nullable=False, default=0, comment='participant id of this job coordinator') + + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created time') + updated_at = db.Column(db.DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + comment='updated time') + started_at = db.Column(db.DateTime(timezone=True), comment='started_at') + finished_at = db.Column(db.DateTime(timezone=True), comment='finished_at') + + # cron_job will use time_range to infer event_time for next data_batch + time_range = db.Column(db.Interval(native=False), nullable=True, comment='time_range to create new job_stage') + # cron_job will read event_time to get current data_batch, + # and update it to event_time + time_range when next new data_batch created + event_time = db.Column(db.DateTime(timezone=True), nullable=True, comment='event_time for current data_batch') + + # scheduler_state will be filter and change by job_scheduler_v2 + scheduler_state = db.Column(db.Enum(DatasetJobSchedulerState, length=64, native_enum=False, + create_constraint=False), + nullable=True, + default=DatasetJobSchedulerState.PENDING, + comment='dataset job scheduler state') + + creator_username = db.Column(db.String(255), nullable=True, comment='creator username') + + workflow = db.relationship('Workflow', primaryjoin='foreign(DatasetJob.workflow_id) == Workflow.id') + project = db.relationship('Project', primaryjoin='foreign(DatasetJob.project_id) == Project.id') + input_dataset = db.relationship('Dataset', primaryjoin='foreign(DatasetJob.input_dataset_id) == Dataset.id') + + @property + def output_dataset(self): + return None if not db.object_session(self) else db.object_session(self).query(Dataset).filter( + Dataset.id == self.output_dataset_id).execution_options(include_deleted=True).first() + + dataset_job_stages = db.relationship( + 'DatasetJobStage', + order_by='desc(DatasetJobStage.created_at)', + primaryjoin='DatasetJob.id == foreign(DatasetJobStage.dataset_job_id)', + # To disable the warning of back_populates + overlaps='dataset_job') + + def get_global_configs(self) -> Optional[DatasetJobGlobalConfigs]: + # For participant, global_config is empty text. + if self.global_configs is None or len(self.global_configs) == 0: return None - proto = dataset_pb2.DataBatch() - proto.ParseFromString(self.details) + return text_format.Parse(self.global_configs, DatasetJobGlobalConfigs()) + + def set_global_configs(self, global_configs: DatasetJobGlobalConfigs): + self.global_configs = text_format.MessageToString(global_configs) + + def get_context(self) -> DatasetJobContext: + context_pb = DatasetJobContext() + if self.context: + context_pb = text_format.Parse(self.context, context_pb) + return context_pb + + def set_context(self, context: DatasetJobContext): + self.context = text_format.MessageToString(context) + + def set_scheduler_message(self, scheduler_message: str): + context_pb = self.get_context() + context_pb.scheduler_message = scheduler_message + self.set_context(context=context_pb) + + @property + def time_range_pb(self) -> TimeRange: + time_range_pb = TimeRange() + if self.is_daily_cron(): + time_range_pb.days = self.time_range.days + elif self.is_hourly_cron(): + # convert seconds to hours + time_range_pb.hours = int(self.time_range.seconds / 3600) + return time_range_pb + + def to_proto(self) -> dataset_pb2.DatasetJob: + context = self.get_context() + proto = dataset_pb2.DatasetJob( + id=self.id, + uuid=self.uuid, + name=self.name, + project_id=self.project_id, + workflow_id=self.workflow_id, + coordinator_id=self.coordinator_id, + kind=self.kind.value, + state=self.state.name, + global_configs=self.get_global_configs(), + input_data_batch_num_example=context.input_data_batch_num_example, + output_data_batch_num_example=context.output_data_batch_num_example, + has_stages=context.has_stages, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + started_at=to_timestamp(self.started_at) if self.started_at else 0, + finished_at=to_timestamp(self.finished_at) if self.finished_at else 0, + creator_username=self.creator_username, + scheduler_state=self.scheduler_state.name if self.scheduler_state else '', + time_range=self.time_range_pb, + scheduler_message=context.scheduler_message, + ) + if self.output_dataset: + proto.result_dataset_uuid = self.output_dataset.uuid + proto.result_dataset_name = self.output_dataset.name + if self.workflow_id: + proto.is_ready = True return proto + + def to_ref(self) -> dataset_pb2.DatasetJobRef: + return dataset_pb2.DatasetJobRef( + uuid=self.uuid, + id=self.id, + name=self.name, + coordinator_id=self.coordinator_id, + project_id=self.project_id, + kind=self.kind.name, + result_dataset_id=self.output_dataset_id, + result_dataset_name=self.output_dataset.name if self.output_dataset else '', + state=self.state.name, + created_at=to_timestamp(self.created_at), + has_stages=self.get_context().has_stages, + creator_username=self.creator_username, + ) + + def is_coordinator(self) -> bool: + return self.coordinator_id == 0 + + def is_finished(self) -> bool: + return self.state in DATASET_JOB_FINISHED_STATE + + def is_cron(self) -> bool: + return self.time_range is not None + + def is_daily_cron(self) -> bool: + if self.time_range is None: + return False + return self.time_range.days > 0 + + def is_hourly_cron(self) -> bool: + if self.time_range is None: + return False + # hourly time_range is less than one day + return self.time_range.days == 0 + + +class DatasetJobStage(SoftDeleteModel, db.Model): + __tablename__ = 'dataset_job_stages_v2' + __table_args__ = (UniqueConstraint('uuid', + name='uniq_dataset_job_stage_uuid'), default_table_args('dataset_job_stages_v2')) + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='id of dataset job stage') + uuid = db.Column(db.String(255), nullable=False, comment='dataset job stage uuid') + name = db.Column(db.String(255), nullable=True, comment='dataset job stage name') + state = db.Column(db.Enum(DatasetJobState, length=64, native_enum=False, create_constraint=False), + nullable=False, + default=DatasetJobState.PENDING, + comment='dataset job stage state') + project_id = db.Column(db.Integer, nullable=False, comment='project id') + workflow_id = db.Column(db.Integer, nullable=True, default=0, comment='relating workflow id') + dataset_job_id = db.Column(db.Integer, nullable=False, comment='dataset_job id') + data_batch_id = db.Column(db.Integer, nullable=False, comment='data_batch id') + event_time = db.Column(db.DateTime(timezone=True), nullable=True, comment='event_time of data upload') + # store dataset_job global_configs to job_stage global_configs when job_stage created if is coordinator + global_configs = db.Column( + db.Text(), comment='global configs of this stage including related participants only appear in coordinator') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created time') + updated_at = db.Column(db.DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + comment='updated time') + started_at = db.Column(db.DateTime(timezone=True), comment='started_at') + finished_at = db.Column(db.DateTime(timezone=True), comment='finished_at') + + # dataset_job coordinator might be different with dataset_job_stage coordinator + coordinator_id = db.Column(db.Integer, + nullable=False, + server_default=db.text('0'), + comment='participant id of this dataset_job_stage, 0 if it is coordinator') + + context = db.Column(db.Text(), nullable=True, default=None, comment='context info of dataset job stage') + + workflow = db.relationship('Workflow', primaryjoin='foreign(DatasetJobStage.workflow_id) == Workflow.id') + project = db.relationship('Project', primaryjoin='foreign(DatasetJobStage.project_id) == Project.id') + dataset_job = db.relationship('DatasetJob', primaryjoin='foreign(DatasetJobStage.dataset_job_id) == DatasetJob.id') + data_batch = db.relationship('DataBatch', primaryjoin='foreign(DatasetJobStage.data_batch_id) == DataBatch.id') + + def get_global_configs(self) -> Optional[DatasetJobGlobalConfigs]: + # For participant, global_config is empty text. + if self.global_configs is None or len(self.global_configs) == 0: + return None + return text_format.Parse(self.global_configs, DatasetJobGlobalConfigs()) + + def set_global_configs(self, global_configs: DatasetJobGlobalConfigs): + self.global_configs = text_format.MessageToString(global_configs) + + def is_finished(self) -> bool: + return self.state in DATASET_JOB_FINISHED_STATE + + def to_ref(self) -> dataset_pb2.DatasetJobStageRef: + return dataset_pb2.DatasetJobStageRef(id=self.id, + name=self.name, + dataset_job_id=self.dataset_job_id, + output_data_batch_id=self.data_batch_id, + project_id=self.project_id, + state=self.state.name, + created_at=to_timestamp(self.created_at), + kind=self.dataset_job.kind.name if self.dataset_job else '') + + def to_proto(self) -> dataset_pb2.DatasetJobStage: + context = self.get_context() + return dataset_pb2.DatasetJobStage(id=self.id, + name=self.name, + uuid=self.uuid, + dataset_job_id=self.dataset_job_id, + output_data_batch_id=self.data_batch_id, + workflow_id=self.workflow_id, + project_id=self.project_id, + state=self.state.name, + event_time=to_timestamp(self.event_time) if self.event_time else 0, + global_configs=self.get_global_configs(), + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + started_at=to_timestamp(self.started_at) if self.started_at else 0, + finished_at=to_timestamp(self.finished_at) if self.finished_at else 0, + dataset_job_uuid=self.dataset_job.uuid if self.dataset_job else None, + is_ready=self.workflow is not None, + kind=self.dataset_job.kind.name if self.dataset_job else '', + input_data_batch_num_example=context.input_data_batch_num_example, + output_data_batch_num_example=context.output_data_batch_num_example, + scheduler_message=context.scheduler_message) + + def get_context(self) -> DatasetJobStageContext: + context_pb = DatasetJobStageContext() + if self.context: + context_pb = text_format.Parse(self.context, context_pb) + return context_pb + + def set_context(self, context: DatasetJobStageContext): + self.context = text_format.MessageToString(context) + + def set_scheduler_message(self, scheduler_message: str): + context_pb = self.get_context() + context_pb.scheduler_message = scheduler_message + self.set_context(context=context_pb) + + def is_coordinator(self) -> bool: + return self.coordinator_id == 0 diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/services.py b/web_console_v2/api/fedlearner_webconsole/dataset/services.py index a21b7cbeb..561bc4ed7 100644 --- a/web_console_v2/api/fedlearner_webconsole/dataset/services.py +++ b/web_console_v2/api/fedlearner_webconsole/dataset/services.py @@ -1,124 +1,717 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# -# coding: utf-8 import json import logging -from typing import List - -from sqlalchemy.orm import Session +import os +from datetime import datetime, timedelta +from typing import List, Optional, Tuple, Union +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session, joinedload, Query -from fedlearner_webconsole.dataset.models import Dataset -from fedlearner_webconsole.dataset.sparkapp.pipeline.util import \ - dataset_meta_path, dataset_features_path, dataset_hist_path -from fedlearner_webconsole.exceptions import NotFoundException +from fedlearner_webconsole.participant.services import ParticipantService +from fedlearner_webconsole.review.ticket_helper import get_ticket_helper +from fedlearner_webconsole.utils.filtering import SupportedField, FieldType, FilterBuilder +from fedlearner_webconsole.utils.base_model.auth_model import AuthStatus +from fedlearner_webconsole.utils.flask_utils import get_current_user +from fedlearner_webconsole.utils.resource_name import resource_uuid +from fedlearner_webconsole.utils.workflow import fill_variables +from fedlearner_webconsole.utils.pp_datetime import from_timestamp, to_timestamp, now from fedlearner_webconsole.utils.file_manager import FileManager +from fedlearner_webconsole.exceptions import (InvalidArgumentException, NotFoundException, MethodNotAllowedException, + ResourceConflictException) +from fedlearner_webconsole.project.models import Project +from fedlearner_webconsole.dataset.models import (DATASET_JOB_FINISHED_STATE, DATASET_STATE_CONVERT_MAP_V2, + LOCAL_DATASET_JOBS, MICRO_DATASET_JOB, DatasetFormat, ResourceState, + DatasetJobKind, DatasetJobStage, DatasetJobState, DatasetKindV2, + StoreFormat, DatasetType, Dataset, ImportType, DataBatch, DatasetJob, + DataSource, ProcessedDataset, DatasetJobSchedulerState) +from fedlearner_webconsole.dataset.meta_data import MetaData, ImageMetaData +from fedlearner_webconsole.dataset.delete_dependency import DatasetDeleteDependency +from fedlearner_webconsole.dataset.dataset_directory import DatasetDirectory +from fedlearner_webconsole.dataset.job_configer.dataset_job_configer import DatasetJobConfiger +from fedlearner_webconsole.dataset.filter_funcs import dataset_format_filter_op_equal, dataset_format_filter_op_in +from fedlearner_webconsole.dataset.util import get_dataset_path, parse_event_time_to_daily_folder_name, \ + parse_event_time_to_hourly_folder_name +from fedlearner_webconsole.dataset.metrics import emit_dataset_job_submission_store, emit_dataset_job_duration_store +from fedlearner_webconsole.setting.service import SettingService +from fedlearner_webconsole.proto.cleanup_pb2 import CleanupParameter, CleanupPayload +from fedlearner_webconsole.proto.dataset_pb2 import CronType, DatasetJobGlobalConfigs +from fedlearner_webconsole.proto import dataset_pb2 +from fedlearner_webconsole.proto.filtering_pb2 import FilterExpression, FilterOp +from fedlearner_webconsole.proto.review_pb2 import TicketDetails, TicketType +from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition +from fedlearner_webconsole.cleanup.models import ResourceType +from fedlearner_webconsole.cleanup.services import CleanupService + + +class DataReader(object): + + def __init__(self, dataset_path: str): + self._path = dataset_path + self._dataset_directory = DatasetDirectory(dataset_path=dataset_path) + self._file_manager = FileManager() + + # meta is generated from sparkapp/pipeline/analyzer.py + def metadata(self, batch_name: str) -> MetaData: + meta_path = self._dataset_directory.batch_meta_file(batch_name=batch_name) + try: + return MetaData(json.loads(self._file_manager.read(meta_path))) + except Exception as e: # pylint: disable=broad-except + logging.info(f'failed to read meta file, path: {meta_path}, err: {e}') + return MetaData() + + def image_metadata(self, thumbnail_dir_path: str, batch_name: str) -> ImageMetaData: + meta_path = self._dataset_directory.batch_meta_file(batch_name=batch_name) + try: + return ImageMetaData(thumbnail_dir_path, json.loads(self._file_manager.read(meta_path))) + except Exception as e: # pylint: disable=broad-except + logging.info(f'failed to read meta file, path: {meta_path}, err: {e}') + return ImageMetaData(thumbnail_dir_path) class DatasetService(object): + + DATASET_CLEANUP_DEFAULT_DELAY = timedelta(days=7) + PUBLISHED_DATASET_FILTER_FIELDS = { + 'uuid': + SupportedField(type=FieldType.STRING, ops={FilterOp.EQUAL: None}), + 'kind': + SupportedField(type=FieldType.STRING, ops={ + FilterOp.IN: None, + FilterOp.EQUAL: None + }), + 'dataset_format': + SupportedField(type=FieldType.STRING, + ops={ + FilterOp.IN: dataset_format_filter_op_in, + FilterOp.EQUAL: dataset_format_filter_op_equal + }), + } + def __init__(self, session: Session): self._session = session self._file_manager = FileManager() + self._published_dataset_filter_builder = FilterBuilder(model_class=Dataset, + supported_fields=self.PUBLISHED_DATASET_FILTER_FIELDS) - def get_dataset_preview(self, dataset_id: int = 0) -> dict: - dataset = self._session.query(Dataset).filter( - Dataset.id == dataset_id).first() + @staticmethod + def filter_dataset_state(query: Query, frontend_states: List[ResourceState]) -> Query: + if len(frontend_states) == 0: + return query + dataset_job_states = [] + for k, v in DATASET_STATE_CONVERT_MAP_V2.items(): + if v in frontend_states: + dataset_job_states.append(k) + state_filter = DatasetJob.state.in_(dataset_job_states) + # internal_processed dataset is now hack to succeeded, + # so here we add all internal_processed dataset when filter succeeded dataset + if ResourceState.SUCCEEDED in frontend_states: + state_filter = or_(state_filter, Dataset.dataset_kind == DatasetKindV2.INTERNAL_PROCESSED) + return query.filter(state_filter) + + def query_dataset_with_parent_job(self) -> Query: + return self._session.query(Dataset).outerjoin( + DatasetJob, and_(DatasetJob.output_dataset_id == Dataset.id, DatasetJob.input_dataset_id != Dataset.id)) + + def create_dataset(self, dataset_parameter: dataset_pb2.DatasetParameter) -> Dataset: + # check project existense + project = self._session.query(Project).get(dataset_parameter.project_id) + if project is None: + raise NotFoundException(message=f'cannot found project with id: {dataset_parameter.project_id}') + + # Create dataset + dataset = Dataset( + name=dataset_parameter.name, + uuid=dataset_parameter.uuid or resource_uuid(), + is_published=dataset_parameter.is_published, + dataset_type=DatasetType(dataset_parameter.type), + comment=dataset_parameter.comment, + project_id=dataset_parameter.project_id, + dataset_kind=DatasetKindV2(dataset_parameter.kind), + dataset_format=DatasetFormat[dataset_parameter.format].value, + # set participant dataset creator_username to empty if dataset is created by coordinator + # TODO(liuhehan): set participant dataset creator_username to username who authorize it + creator_username=get_current_user().username if get_current_user() else '', + ) + if dataset_parameter.path and dataset.dataset_kind in [ + DatasetKindV2.EXPORTED, DatasetKindV2.INTERNAL_PROCESSED + ]: + dataset.path = dataset_parameter.path + else: + dataset.path = get_dataset_path(dataset_name=dataset.name, uuid=dataset.uuid) + if dataset_parameter.import_type: + dataset.import_type = ImportType(dataset_parameter.import_type) + if dataset_parameter.store_format: + dataset.store_format = StoreFormat(dataset_parameter.store_format) + if dataset_parameter.auth_status: + dataset.auth_status = AuthStatus[dataset_parameter.auth_status] + if dataset_parameter.creator_username: + dataset.creator_username = dataset_parameter.creator_username + elif get_current_user(): + dataset.creator_username = get_current_user().username + meta_info = dataset_pb2.DatasetMetaInfo(need_publish=dataset_parameter.need_publish, + value=dataset_parameter.value, + schema_checkers=dataset_parameter.schema_checkers) + dataset.set_meta_info(meta_info) + self._session.add(dataset) + return dataset + + def get_dataset(self, dataset_id: int = 0) -> Union[dict, dataset_pb2.Dataset]: + dataset = self._session.query(Dataset).with_polymorphic([ProcessedDataset, + Dataset]).filter(Dataset.id == dataset_id).first() + if not dataset: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + return dataset.to_proto() + + def get_dataset_preview(self, dataset_id: int, batch_id: int) -> dict: + batch = self._session.query(DataBatch).get(batch_id) + if batch is None: + raise NotFoundException(f'Failed to find data batch: {batch_id}') + dataset = self._session.query(Dataset).filter(Dataset.id == dataset_id).first() if not dataset: raise NotFoundException(f'Failed to find dataset: {dataset_id}') - dataset_path = dataset.path - # meta is generated from sparkapp/pipeline/analyzer.py - meta_path = dataset_meta_path(dataset_path) - # data format: - # { - # 'dtypes': { - # 'f01': 'bigint' - # }, - # 'samples': [ - # [1], - # [0], - # ], - # 'metrics': { - # 'f01': { - # 'count': '2', - # 'mean': '0.0015716767309123998', - # 'stddev': '0.03961485047808605', - # 'min': '0', - # 'max': '1', - # 'missing_count': '0' - # } - # } - # } + reader = DataReader(dataset.path) + if dataset.is_image(): + thumbnail_dir_path = DatasetDirectory(dataset_path=dataset.path).thumbnails_path( + batch_name=batch.batch_name) + meta = reader.image_metadata(thumbnail_dir_path=thumbnail_dir_path, batch_name=batch.batch_name) + else: + meta = reader.metadata(batch_name=batch.batch_name) + return meta.get_preview() + + def feature_metrics(self, name: str, dataset_id: int, data_batch_id: int) -> dict: + dataset = self._session.query(Dataset).get(dataset_id) + if dataset is None: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + batch = self._session.query(DataBatch).get(data_batch_id) + if batch is None: + raise NotFoundException(f'Failed to find data batch: {data_batch_id}') + meta = DataReader(dataset.path).metadata(batch_name=batch.batch_name) val = {} - try: - val = json.loads(self._file_manager.read(meta_path)) - except Exception as e: # pylint: disable=broad-except - logging.info( - f'failed to read meta file, path: {meta_path}, err: {e}') - return {} - # feature is generated from sparkapp/pipeline/analyzer.py - feature_path = dataset_features_path(dataset_path) - try: - val['metrics'] = json.loads(self._file_manager.read(feature_path)) - except Exception as e: # pylint: disable=broad-except - logging.info( - f'failed to read feature file, path: {feature_path}, err: {e}') + val['name'] = name + val['metrics'] = meta.get_metrics_by_name(name) + val['hist'] = meta.get_hist_by_name(name) return val - def feature_metrics(self, name: str, dataset_id: int = 0) -> dict: - dataset = self._session.query(Dataset).filter( - Dataset.id == dataset_id).first() + def get_published_datasets(self, + project_id: int, + kind: Optional[DatasetJobKind] = None, + uuid: Optional[str] = None, + state: Optional[ResourceState] = None, + filter_exp: Optional[FilterExpression] = None, + time_range: Optional[timedelta] = None) -> List[dataset_pb2.ParticipantDatasetRef]: + query = self.query_dataset_with_parent_job() + query = query.options(joinedload(Dataset.data_batches)) + query = query.filter(Dataset.project_id == project_id) + query = query.filter(Dataset.is_published.is_(True)) + if kind is not None: + query = query.filter(Dataset.dataset_kind == kind) + if uuid is not None: + query = query.filter(Dataset.uuid == uuid) + if state is not None: + query = self.filter_dataset_state(query, frontend_states=[state]) + if filter_exp is not None: + query = self._published_dataset_filter_builder.build_query(query, filter_exp) + if time_range: + query = query.filter(DatasetJob.time_range == time_range) + query = query.order_by(Dataset.id.desc()) + datasets_ref = [] + for dataset in query.all(): + meta_info = dataset.get_meta_info() + dataset_ref = dataset_pb2.ParticipantDatasetRef( + uuid=dataset.uuid, + name=dataset.name, + format=DatasetFormat(dataset.dataset_format).name, + file_size=dataset.get_file_size(), + updated_at=to_timestamp(dataset.updated_at), + value=meta_info.value, + dataset_kind=dataset.dataset_kind.name, + dataset_type=dataset.dataset_type.name, + auth_status=dataset.auth_status.name if dataset.auth_status else '') + datasets_ref.append(dataset_ref) + return datasets_ref + + def publish_dataset(self, dataset_id: int, value: int = 0) -> Dataset: + dataset: Dataset = self._session.query(Dataset).get(dataset_id) if not dataset: raise NotFoundException(f'Failed to find dataset: {dataset_id}') - dataset_path = dataset.path - feature_path = dataset_features_path(dataset_path) - # data format: - # { - # 'name': 'f01', - # 'metrics': { - # 'count': '2', - # 'mean': '0.0015716767309123998', - # 'stddev': '0.03961485047808605', - # 'min': '0', - # 'max': '1', - # 'missing_count': '0' - # }, - # 'hist': { - # 'x': [0.0, 0.1, 0.2, 0.30000000000000004, 0.4, 0.5, - # 0.6000000000000001, 0.7000000000000001, 0.8, 0.9, 1], - # 'y': [12070, 0, 0, 0, 0, 0, 0, 0, 0, 19] - # } - # } - val = {} + if dataset.dataset_kind != DatasetKindV2.RAW: + raise MethodNotAllowedException( + f'{dataset.dataset_kind.value} dataset cannot publish, dataset_id: {dataset.id}') + dataset.is_published = True + meta_info = dataset.get_meta_info() + meta_info.value = value + dataset.set_meta_info(meta_info) + # TODO(liuhehan): a hack to add uuid for old dataset when publish, remove in the feature + if dataset.uuid is None: + dataset.uuid = resource_uuid() + + # create review ticket + if dataset.ticket_uuid is None: + ticket_helper = get_ticket_helper(session=self._session) + ticket_helper.create_ticket(TicketType.PUBLISH_DATASET, TicketDetails(uuid=dataset.uuid)) + + return dataset + + def withdraw_dataset(self, dataset_id: int): + dataset = self._session.query(Dataset).get(dataset_id) + if not dataset: + raise NotFoundException(f'Failed to find dataset: {dataset_id}') + dataset.is_published = False + + # reset ticket + dataset.ticket_uuid = None + dataset.ticket_status = None + + def cleanup_dataset(self, dataset: Dataset, delay_time: Optional[timedelta] = None) -> Tuple[bool, List[str]]: + """ Register the dataset and underlying files to be cleaned with the cleanup module. + + Args: + dataset: dataset which needs an exclusive lock to this row + delay_time: delay time to start the cleanup task afterwards + + Raises: + ResourceConflictException: if the `dataset` can not be deleted + """ + if not delay_time: + delay_time = self.DATASET_CLEANUP_DEFAULT_DELAY + target_start_at = to_timestamp(now() + delay_time) + is_deletable, error_msgs = DatasetDeleteDependency(self._session).is_deletable(dataset) + if not is_deletable: + error = {dataset.id: error_msgs} + raise ResourceConflictException(f'{error}') + logging.info(f'will mark the dataset:{dataset.id} is deleted') + payload = CleanupPayload(paths=[dataset.path]) + dataset_cleanup_parm = CleanupParameter(resource_id=dataset.id, + resource_type=ResourceType.DATASET.name, + payload=payload, + target_start_at=target_start_at) + CleanupService(self._session).create_cleanup(cleanup_parmeter=dataset_cleanup_parm) + dataset.deleted_at = now() + logging.info(f'Has registered a cleanup for dataset:{dataset.id}') + + def get_data_batch(self, dataset: Dataset, event_time: Optional[datetime] = None) -> Optional[DataBatch]: + if dataset.dataset_type == DatasetType.PSI: + return self._session.query(DataBatch).filter(DataBatch.dataset_id == dataset.id).first() + return self._session.query(DataBatch).filter(DataBatch.dataset_id == dataset.id).filter( + DataBatch.event_time == event_time).first() + + +class DataSourceService(object): + + def __init__(self, session: Session): + self._session = session + + def create_data_source(self, data_source_parameter: dataset_pb2.DataSource) -> DataSource: + # check project existense + project = self._session.query(Project).get(data_source_parameter.project_id) + if project is None: + raise NotFoundException(message=f'cannot found project with id: {data_source_parameter.project_id}') + + data_source = DataSource( + name=data_source_parameter.name, + comment=data_source_parameter.comment, + uuid=resource_uuid(), + is_published=False, + path=data_source_parameter.url, + project_id=data_source_parameter.project_id, + creator_username=get_current_user().username, + dataset_format=DatasetFormat[data_source_parameter.dataset_format].value, + store_format=StoreFormat(data_source_parameter.store_format), + dataset_type=DatasetType(data_source_parameter.dataset_type), + ) + meta_info = dataset_pb2.DatasetMetaInfo(datasource_type=data_source_parameter.type, + is_user_upload=data_source_parameter.is_user_upload, + is_user_export=data_source_parameter.is_user_export) + data_source.set_meta_info(meta_info) + self._session.add(data_source) + return data_source + + def get_data_sources(self, project_id: int) -> List[dataset_pb2.DataSource]: + data_sources = self._session.query(DataSource).order_by(Dataset.created_at.desc()) + if project_id > 0: + data_sources = data_sources.filter_by(project_id=project_id) + data_source_ref = [] + for data_source in data_sources.all(): + # ignore user upload data_source and user export data_source + meto_info = data_source.get_meta_info() + if not meto_info.is_user_upload and not meto_info.is_user_export: + data_source_ref.append(data_source.to_proto()) + return data_source_ref + + def delete_data_source(self, data_source_id: int): + data_source = self._session.query(DataSource).get(data_source_id) + if not data_source: + raise NotFoundException(message=f'cannot find data_source with id: {data_source_id}') + dataset_jobs = self._session.query(DatasetJob).filter_by(input_dataset_id=data_source.id).all() + for dataset_job in dataset_jobs: + if not dataset_job.is_finished(): + message = f'data_source {data_source.name} is still being processed by dataset_job {dataset_job.id}' + logging.error(message) + raise ResourceConflictException(message=message) + + data_source.deleted_at = now() + + +class BatchService(object): + + def __init__(self, session: Session): + self._session = session + + def create_batch(self, batch_parameter: dataset_pb2.BatchParameter) -> DataBatch: + dataset: Dataset = self._session.query(Dataset).filter_by(id=batch_parameter.dataset_id).first() + if dataset is None: + message = f'Failed to find dataset: {batch_parameter.dataset_id}' + logging.error(message) + raise NotFoundException(message=message) + if dataset.dataset_type == DatasetType.PSI: + # There should be one batch of a dataset in PSI mode. + # So the naming convention of batch is `{dataset_path}/batch/0`. + if len(dataset.data_batches) != 0: + raise InvalidArgumentException(details='there should be one batch for PSI dataset') + batch_folder_name = '0' + event_time = None + elif dataset.dataset_type == DatasetType.STREAMING: + if batch_parameter.event_time == 0: + raise InvalidArgumentException( + details='event time should be specified when create batch of streaming dataset') + event_time = from_timestamp(batch_parameter.event_time) + if batch_parameter.cron_type == CronType.DAILY: + batch_folder_name = parse_event_time_to_daily_folder_name(event_time=event_time) + elif batch_parameter.cron_type == CronType.HOURLY: + batch_folder_name = parse_event_time_to_hourly_folder_name(event_time=event_time) + else: + # old data may not has cron_tpye, we just set to daily cron_type by default + batch_folder_name = parse_event_time_to_daily_folder_name(event_time=event_time) + batch_parameter.path = os.path.join(dataset.path, 'batch', batch_folder_name) + # Create batch + batch = DataBatch(dataset_id=dataset.id, + event_time=event_time, + comment=batch_parameter.comment, + path=batch_parameter.path, + name=batch_folder_name) + self._session.add(batch) + + return batch + + def get_next_batch(self, data_batch: DataBatch) -> Optional[DataBatch]: + parent_dataset_job_stage: DatasetJobStage = data_batch.latest_parent_dataset_job_stage + if not parent_dataset_job_stage: + logging.warning(f'not found parent_dataset_job_stage, data_batch id: {data_batch.id}') + return None + parent_dataset_job: DatasetJob = parent_dataset_job_stage.dataset_job + if not parent_dataset_job: + logging.warning(f'not found parent_dataset_job, data_batch id: {data_batch.id}') + return None + if not parent_dataset_job.is_cron(): + logging.warning(f'data_batch {data_batch.id} belongs to a non-cron dataset_job, has no next batch') + return None + next_time = data_batch.event_time + parent_dataset_job.time_range + return self._session.query(DataBatch).filter(DataBatch.dataset_id == data_batch.dataset_id).filter( + DataBatch.event_time == next_time).first() + + +class DatasetJobService(object): + + def __init__(self, session: Session): + self._session = session + + def is_local(self, dataset_job_kind: DatasetJobKind) -> bool: + return dataset_job_kind in LOCAL_DATASET_JOBS + + def need_distribute(self, dataset_job: DatasetJob) -> bool: + # coordinator_id != 0 means it is a participant, + # and dataset_job need to distribute when it has participants + if dataset_job.coordinator_id != 0: + return True + return not self.is_local(dataset_job.kind) + + # filter participants which need to distribute dataset_job + def get_participants_need_distribute(self, dataset_job: DatasetJob) -> List: + participants = [] + if self.need_distribute(dataset_job): + participants = ParticipantService(self._session).get_platform_participants_by_project( + dataset_job.project_id) + return participants + + def create_as_coordinator(self, + project_id: int, + kind: DatasetJobKind, + output_dataset_id: int, + global_configs: DatasetJobGlobalConfigs, + time_range: timedelta = None) -> DatasetJob: + my_domain_name = SettingService.get_system_info().pure_domain_name + input_dataset_uuid = global_configs.global_configs[my_domain_name].dataset_uuid + input_dataset = self._session.query(Dataset).filter(Dataset.uuid == input_dataset_uuid).first() + if input_dataset is None: + raise InvalidArgumentException(f'failed to find dataset {input_dataset_uuid}') + output_dataset = self._session.query(Dataset).get(output_dataset_id) + if output_dataset is None: + return InvalidArgumentException(details=f'failed to find dataset id {output_dataset_id}') + configer = DatasetJobConfiger.from_kind(kind, self._session) + config = configer.get_config() try: - feature_data = json.loads(self._file_manager.read(feature_path)) - val['name'] = name - val['metrics'] = feature_data.get(name, {}) - except Exception as e: # pylint: disable=broad-except - logging.info( - f'failed to read feature file, path: {feature_path}, err: {e}') - # hist is generated from sparkapp/pipeline/analyzer.py - hist_path = dataset_hist_path(dataset_path) + global_configs = configer.auto_config_variables(global_configs) + fill_variables(config, global_configs.global_configs[my_domain_name].variables, dry_run=True) + except TypeError as err: + raise InvalidArgumentException(details=err.args) from err + + dataset_job = DatasetJob() + dataset_job.uuid = resource_uuid() + dataset_job.project_id = project_id + dataset_job.coordinator_id = 0 + dataset_job.input_dataset_id = input_dataset.id + dataset_job.output_dataset_id = output_dataset_id + dataset_job.name = output_dataset.name + dataset_job.kind = kind + dataset_job.time_range = time_range + dataset_job.set_global_configs(global_configs) + dataset_job.set_context(dataset_pb2.DatasetJobContext(has_stages=True)) + current_user = get_current_user() + if current_user is not None: + dataset_job.creator_username = current_user.username + + self._session.add(dataset_job) + + emit_dataset_job_submission_store(uuid=dataset_job.uuid, kind=dataset_job.kind, coordinator_id=0) + + return dataset_job + + def create_as_participant(self, + project_id: int, + kind: DatasetJobKind, + global_configs: DatasetJobGlobalConfigs, + config: WorkflowDefinition, + output_dataset_id: int, + coordinator_id: int, + uuid: str, + creator_username: str, + time_range: timedelta = None) -> DatasetJob: + my_domain_name = SettingService.get_system_info().pure_domain_name + my_dataset_job_config = global_configs.global_configs[my_domain_name] + + input_dataset = self._session.query(Dataset).filter(Dataset.uuid == my_dataset_job_config.dataset_uuid).first() + if input_dataset is None: + return InvalidArgumentException(details=f'failed to find dataset {my_dataset_job_config.dataset_uuid}') + output_dataset = self._session.query(Dataset).get(output_dataset_id) + if output_dataset is None: + return InvalidArgumentException(details=f'failed to find dataset id {output_dataset_id}') try: - hist_data = json.loads(self._file_manager.read(hist_path)) - val['hist'] = hist_data.get(name, {}) - except Exception as e: # pylint: disable=broad-except - logging.info( - f'failed to read hist file, path: {hist_path}, err: {e}') - return val + fill_variables(config, my_dataset_job_config.variables, dry_run=True) + except TypeError as err: + raise InvalidArgumentException(details=err.args) from err - def get_datasets(self, project_id: int = 0) -> List[Dataset]: - q = self._session.query(Dataset).order_by(Dataset.created_at.desc()) - if project_id > 0: - q = q.filter(Dataset.project_id == project_id) - return q.all() + dataset_job = DatasetJob() + dataset_job.uuid = uuid + dataset_job.project_id = project_id + dataset_job.input_dataset_id = input_dataset.id + dataset_job.output_dataset_id = output_dataset_id + dataset_job.name = output_dataset.name + dataset_job.coordinator_id = coordinator_id + dataset_job.kind = kind + dataset_job.time_range = time_range + dataset_job.creator_username = creator_username + dataset_job.set_context(dataset_pb2.DatasetJobContext(has_stages=True)) + + self._session.add(dataset_job) + + emit_dataset_job_submission_store(uuid=dataset_job.uuid, + kind=dataset_job.kind, + coordinator_id=dataset_job.coordinator_id) + + return dataset_job + + def start_dataset_job(self, dataset_job: DatasetJob): + dataset_job.state = DatasetJobState.RUNNING + dataset_job.started_at = now() + + def finish_dataset_job(self, dataset_job: DatasetJob, finish_state: DatasetJobState): + if finish_state not in DATASET_JOB_FINISHED_STATE: + raise ValueError(f'get invalid finish state: [{finish_state}] when try to finish dataset_job') + dataset_job.state = finish_state + dataset_job.finished_at = now() + duration = to_timestamp(dataset_job.finished_at) - to_timestamp(dataset_job.created_at) + emit_dataset_job_duration_store(duration=duration, + uuid=dataset_job.uuid, + kind=dataset_job.kind, + coordinator_id=dataset_job.coordinator_id, + state=finish_state) + + def start_cron_scheduler(self, dataset_job: DatasetJob): + if not dataset_job.is_cron(): + logging.warning(f'[dataset_job_service]: failed to start schedule a non-cron dataset_job {dataset_job.id}') + return + dataset_job.scheduler_state = DatasetJobSchedulerState.RUNNABLE + + def stop_cron_scheduler(self, dataset_job: DatasetJob): + if not dataset_job.is_cron(): + logging.warning(f'[dataset_job_service]: failed to stop schedule a non-cron dataset_job {dataset_job.id}') + return + dataset_job.scheduler_state = DatasetJobSchedulerState.STOPPED + + def delete_dataset_job(self, dataset_job: DatasetJob): + if not dataset_job.is_finished(): + message = f'Failed to delete dataset_job: {dataset_job.id}; ' \ + f'reason: dataset_job state is {dataset_job.state.name}' + logging.error(message) + raise ResourceConflictException(message) + dataset_job.deleted_at = now() + + +class DatasetJobStageService(object): + + def __init__(self, session: Session): + self._session = session + + # TODO(liuhehan): delete in the near future after we use as_coordinator func + def create_dataset_job_stage(self, + project_id: int, + dataset_job_id: int, + output_data_batch_id: int, + uuid: Optional[str] = None, + name: Optional[str] = None): + dataset_job: DatasetJob = self._session.query(DatasetJob).get(dataset_job_id) + if dataset_job is None: + raise InvalidArgumentException(details=f'failed to find dataset_job, id: {dataset_job_id}') + output_data_batch: DataBatch = self._session.query(DataBatch).get(output_data_batch_id) + if output_data_batch is None: + raise InvalidArgumentException(details=f'failed to find output_data_batch, id: {output_data_batch_id}') + + dataset_job_stages: DatasetJobStage = self._session.query(DatasetJobStage).filter( + DatasetJobStage.data_batch_id == output_data_batch_id).filter( + DatasetJobStage.dataset_job_id == dataset_job_id).order_by(DatasetJobStage.created_at.desc()).all() + index = len(dataset_job_stages) + if index != 0 and not dataset_job_stages[0].is_finished(): + raise InvalidArgumentException( + details=f'newest dataset_job_stage is still running, id: {dataset_job_stages[0].id}') + + dataset_job_stage = DatasetJobStage() + dataset_job_stage.uuid = uuid or resource_uuid() + dataset_job_stage.name = name or f'{output_data_batch.name}-stage{index}' + dataset_job_stage.event_time = output_data_batch.event_time + dataset_job_stage.dataset_job_id = dataset_job_id + dataset_job_stage.data_batch_id = output_data_batch_id + dataset_job_stage.project_id = project_id + if dataset_job.coordinator_id == 0: + dataset_job_stage.set_global_configs(dataset_job.get_global_configs()) + self._session.add(dataset_job_stage) + + self._session.flush() + if dataset_job.kind not in MICRO_DATASET_JOB: + output_data_batch.latest_parent_dataset_job_stage_id = dataset_job_stage.id + elif dataset_job.kind == DatasetJobKind.ANALYZER: + output_data_batch.latest_analyzer_dataset_job_stage_id = dataset_job_stage.id + + dataset_job.state = DatasetJobState.PENDING + + return dataset_job_stage + + def create_dataset_job_stage_as_coordinator(self, project_id: int, dataset_job_id: int, output_data_batch_id: int, + global_configs: DatasetJobGlobalConfigs): + dataset_job: DatasetJob = self._session.query(DatasetJob).get(dataset_job_id) + if dataset_job is None: + raise InvalidArgumentException(details=f'failed to find dataset_job, id: {dataset_job_id}') + output_data_batch: DataBatch = self._session.query(DataBatch).get(output_data_batch_id) + if output_data_batch is None: + raise InvalidArgumentException(details=f'failed to find output_data_batch, id: {output_data_batch_id}') + + dataset_job_stages: DatasetJobStage = self._session.query(DatasetJobStage).filter( + DatasetJobStage.data_batch_id == output_data_batch_id).filter( + DatasetJobStage.dataset_job_id == dataset_job_id).order_by(DatasetJobStage.id.desc()).all() + index = len(dataset_job_stages) + if index != 0 and not dataset_job_stages[0].is_finished(): + raise InvalidArgumentException( + details=f'newest dataset_job_stage is still running, id: {dataset_job_stages[0].id}') + + dataset_job_stage = DatasetJobStage() + dataset_job_stage.uuid = resource_uuid() + dataset_job_stage.name = f'{output_data_batch.name}-stage{index}' + dataset_job_stage.event_time = output_data_batch.event_time + dataset_job_stage.dataset_job_id = dataset_job_id + dataset_job_stage.data_batch_id = output_data_batch_id + dataset_job_stage.project_id = project_id + dataset_job_stage.coordinator_id = 0 + dataset_job_stage.set_global_configs(global_configs) + self._session.add(dataset_job_stage) + + self._session.flush() + if dataset_job.kind not in MICRO_DATASET_JOB: + output_data_batch.latest_parent_dataset_job_stage_id = dataset_job_stage.id + elif dataset_job.kind == DatasetJobKind.ANALYZER: + output_data_batch.latest_analyzer_dataset_job_stage_id = dataset_job_stage.id + + dataset_job.state = DatasetJobState.PENDING + + return dataset_job_stage + + def create_dataset_job_stage_as_participant(self, project_id: int, dataset_job_id: int, output_data_batch_id: int, + uuid: str, name: str, coordinator_id: int): + dataset_job: DatasetJob = self._session.query(DatasetJob).get(dataset_job_id) + if dataset_job is None: + raise InvalidArgumentException(details=f'failed to find dataset_job, id: {dataset_job_id}') + output_data_batch: DataBatch = self._session.query(DataBatch).get(output_data_batch_id) + if output_data_batch is None: + raise InvalidArgumentException(details=f'failed to find output_data_batch, id: {output_data_batch_id}') + + dataset_job_stages: DatasetJobStage = self._session.query(DatasetJobStage).filter( + DatasetJobStage.data_batch_id == output_data_batch_id).filter( + DatasetJobStage.dataset_job_id == dataset_job_id).order_by(DatasetJobStage.id.desc()).all() + index = len(dataset_job_stages) + if index != 0 and not dataset_job_stages[0].is_finished(): + raise InvalidArgumentException( + details=f'newest dataset_job_stage is still running, id: {dataset_job_stages[0].id}') + + dataset_job_stage = DatasetJobStage() + dataset_job_stage.uuid = uuid + dataset_job_stage.name = name + dataset_job_stage.event_time = output_data_batch.event_time + dataset_job_stage.dataset_job_id = dataset_job_id + dataset_job_stage.data_batch_id = output_data_batch_id + dataset_job_stage.project_id = project_id + dataset_job_stage.coordinator_id = coordinator_id + self._session.add(dataset_job_stage) + + self._session.flush() + if dataset_job.kind not in MICRO_DATASET_JOB: + output_data_batch.latest_parent_dataset_job_stage_id = dataset_job_stage.id + elif dataset_job.kind == DatasetJobKind.ANALYZER: + output_data_batch.latest_analyzer_dataset_job_stage_id = dataset_job_stage.id + + dataset_job.state = DatasetJobState.PENDING + + return dataset_job_stage + + def start_dataset_job_stage(self, dataset_job_stage: DatasetJobStage): + dataset_job_stage.state = DatasetJobState.RUNNING + dataset_job_stage.started_at = now() + + newest_job_stage_id, *_ = self._session.query( + DatasetJobStage.id).filter(DatasetJobStage.dataset_job_id == dataset_job_stage.dataset_job_id).order_by( + DatasetJobStage.created_at.desc()).first() + if newest_job_stage_id == dataset_job_stage.id: + dataset_job_stage.dataset_job.state = DatasetJobState.RUNNING + + def finish_dataset_job_stage(self, dataset_job_stage: DatasetJobStage, finish_state: DatasetJobState): + if finish_state not in DATASET_JOB_FINISHED_STATE: + raise ValueError(f'get invalid finish state: [{finish_state}] when try to finish dataset_job') + dataset_job_stage.state = finish_state + dataset_job_stage.finished_at = now() + + newest_job_stage_id, *_ = self._session.query( + DatasetJobStage.id).filter(DatasetJobStage.dataset_job_id == dataset_job_stage.dataset_job_id).order_by( + DatasetJobStage.created_at.desc()).first() + if newest_job_stage_id == dataset_job_stage.id: + dataset_job_stage.dataset_job.state = finish_state diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/analyzer.py b/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/analyzer.py deleted file mode 100644 index 5759b334e..000000000 --- a/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/analyzer.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import os -import sys -import json -import logging - -import fsspec -import pandas - -from pyspark.sql import SparkSession -from pyspark.sql.functions import col, lit, sum -from util import dataset_features_path, dataset_meta_path, dataset_hist_path - - -def analyze(dataset_path: str, wildcard: str): - # for example: - # dataset_path: /data/fl_v2_fish_fooding/dataset/20210527_221741_pipeline/ - # wildcard: rds/** - spark = SparkSession.builder.getOrCreate() - files = os.path.join(dataset_path, wildcard) - logging.info(f'### loading df..., input files path: {files}') - df = spark.read.format('tfrecords').load(files) - # df_stats - df_missing = df.select(*(sum(col(c).isNull().cast('int')).alias(c) - for c in df.columns)).withColumn( - 'summary', lit('missing_count')) - df_stats = df.describe().unionByName(df_missing) - df_stats = df_stats.toPandas().set_index('summary').transpose() - features_path = dataset_features_path(dataset_path) - logging.info(f'### writing features, features path is {features_path}') - content = json.dumps(df_stats.to_dict(orient='index')) - with fsspec.open(features_path, mode='w') as f: - f.write(content) - # meta - meta = {} - # dtypes - logging.info('### loading dtypes...') - dtypes = {} - for d in df.dtypes: - k, v = d # (feature, type) - dtypes[k] = v - meta['dtypes'] = dtypes - # sample count - logging.info('### loading count...') - meta['count'] = df.count() - # sample - logging.info('### loading sample...') - meta['sample'] = df.head(20) - # meta - meta_path = dataset_meta_path(dataset_path) - logging.info(f'### writing meta, path is {meta_path}') - with fsspec.open(meta_path, mode='w') as f: - f.write(json.dumps(meta)) - # feature histogram - logging.info('### loading hist...') - hist = {} - for c in df.columns: - # TODO: histogram is too slow and needs optimization - x, y = df.select(c).rdd.flatMap(lambda x: x).histogram(10) - hist[c] = {'x': x, 'y': y} - hist_path = dataset_hist_path(dataset_path) - logging.info(f'### writing hist, path is {hist_path}') - with fsspec.open(hist_path, mode='w') as f: - f.write(json.dumps(hist)) - - spark.stop() - - -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO) - if len(sys.argv) != 3: - logging.error( - f'spark-submit {sys.argv[0]} [dataset_path] [file_wildcard]') - sys.exit(-1) - - dataset_path, wildcard = sys.argv[1], sys.argv[2] - analyze(dataset_path, wildcard) diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/converter.py b/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/converter.py deleted file mode 100644 index 248210d4d..000000000 --- a/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/converter.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import sys -import os -import logging - -from pyspark.sql import SparkSession -from util import dataset_rds_path - - -def convert(dataset_path: str, wildcard: str): - # for example: - # dataset_path: /data/fl_v2_fish_fooding/dataset/20210527_221741_pipeline/ - # wildcard: batch/**/*.csv - files = os.path.join(dataset_path, wildcard) - logging.info(f'### input files path: {files}') - spark = SparkSession.builder.getOrCreate() - if wildcard.endswith('*.csv'): - df = spark.read.format('csv').option('header', 'true').option( - 'inferSchema', 'true').load(files) - elif wildcard.endswith('*.rd') or wildcard.endswith('*.tfrecords'): - df = spark.read.format('tfrecords').load(files) - else: - logging.error(f'### no valid file wildcard, wildcard: {wildcard}') - return - - df.printSchema() - save_path = dataset_rds_path(dataset_path) - logging.info(f'### saving to {save_path}, in tfrecords') - df.write.format('tfrecords').save(save_path, mode='overwrite') - spark.stop() - - -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO) - if len(sys.argv) != 3: - logging.error( - f'spark-submit {sys.argv[0]} [dataset_path] [file_wildcard]') - sys.exit(-1) - - dataset_path, wildcard = sys.argv[1], sys.argv[2] - convert(dataset_path, wildcard) diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/transformer.py b/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/transformer.py deleted file mode 100644 index 4c6620de0..000000000 --- a/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/transformer.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import json -import sys -import logging - -from pyspark.sql import SparkSession -from util import dataset_transformer_path - - -def transform(dataset_path: str, wildcard: str, conf: str): - # for example: - # dataset_path: /data/fl_v2_fish_fooding/dataset/20210527_221741_pipeline/ - # wildcard: rds/** or data_block/**/*.data - # conf: {"f00001": 0.0, "f00002": 1.0} - spark = SparkSession.builder.getOrCreate() - files = os.path.join(dataset_path, wildcard) - conf_dict = json.loads(conf) - logging.info(f'### input files path: {files}, config: {conf_dict}') - df = spark.read.format('tfrecords').load(files) - filled_df = df.fillna(conf_dict) - save_path = dataset_transformer_path(dataset_path) - logging.info(f'### saving to {save_path}') - filled_df.write.format('tfrecords').save(save_path, mode='overwrite') - spark.stop() - - -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO) - if len(sys.argv) != 4: - logging.error( - f'spark-submit {sys.argv[0]} [dataset_path] [wildcard] [config]') - sys.exit(-1) - - dataset_path, wildcard, conf = sys.argv[1], sys.argv[2], sys.argv[3] - transform(dataset_path, wildcard, conf) diff --git a/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/util.py b/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/util.py deleted file mode 100644 index 14085e93e..000000000 --- a/web_console_v2/api/fedlearner_webconsole/dataset/sparkapp/pipeline/util.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os - - -def dataset_rds_path(dataset_path: str) -> str: - return os.path.join(dataset_path, 'rds/') - - -def dataset_features_path(dataset_path: str) -> str: - return os.path.join(dataset_path, '_FEATURES') - - -def dataset_meta_path(dataset_path: str) -> str: - return os.path.join(dataset_path, '_META') - - -def dataset_hist_path(dataset_path: str) -> str: - return os.path.join(dataset_path, '_HIST') - - -def dataset_transformer_path(dataset_path: str) -> str: - return os.path.join(dataset_path, 'fe/') diff --git a/web_console_v2/api/fedlearner_webconsole/db.py b/web_console_v2/api/fedlearner_webconsole/db.py index b40ff033b..edca200ca 100644 --- a/web_console_v2/api/fedlearner_webconsole/db.py +++ b/web_console_v2/api/fedlearner_webconsole/db.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,29 +15,28 @@ # coding: utf-8 import os from contextlib import contextmanager -from typing import ContextManager, Callable - +from typing import ContextManager +from pymysql.constants.CLIENT import FOUND_ROWS import sqlalchemy as sa - +from sqlalchemy import orm, event, null from sqlalchemy.engine import Engine, create_engine -from sqlalchemy.ext.declarative.api import DeclarativeMeta, declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import sessionmaker, DeclarativeMeta, declarative_base from sqlalchemy.orm.session import Session -from flask_sqlalchemy import SQLAlchemy from envs import Envs +from fedlearner_webconsole.utils.base_model.softdelete_model import SoftDeleteModel + BASE_DIR = Envs.BASE_DIR # Explicitly set autocommit and autoflush # Disables autocommit to make developers to commit manually # Enables autoflush to make changes visible in the same session # Disable expire_on_commit to make it possible that object can detach -SESSION_OPTIONS = { - 'autocommit': False, - 'autoflush': True, - 'expire_on_commit': False -} -ENGINE_OPTIONS = {} +SESSION_OPTIONS = {'autocommit': False, 'autoflush': True, 'expire_on_commit': False} +# Add flag FOUND_ROWS to make update statement return matched rows but not changed rows. +# When use Sqlalchemy, must set this flag to make update statement validation bug free. +MYSQL_OPTIONS = {'connect_args': {'client_flag': FOUND_ROWS}} +SQLITE_OPTIONS = {} def default_table_args(comment: str) -> dict: @@ -48,7 +47,22 @@ def default_table_args(comment: str) -> dict: } -def _turn_db_timezone_to_utc(original_uri: str) -> str: +# an option is added to all SELECT statements that will limit all queries against Dataset to filter on deleted == null +# global WHERE/ON criteria eg: https://docs.sqlalchemy.org/en/14/_modules/examples/extending_query/filter_public.html +# normal orm execution wont get the soft-deleted data, eg: session.query(A).get(1) +# use options can get the soft-deleted data eg: session.query(A).execution_options(include_deleted=True).get(1) +@event.listens_for(Session, 'do_orm_execute') +def _add_filtering_criteria(execute_state): + if (not execute_state.is_column_load and not execute_state.execution_options.get('include_deleted', False)): + execute_state.statement = execute_state.statement.options( + orm.with_loader_criteria( + SoftDeleteModel, + lambda cls: cls.deleted_at == null(), + include_aliases=True, + )) + + +def turn_db_timezone_to_utc(original_uri: str) -> str: """ string operator that make any db into utc timezone Args: @@ -101,17 +115,15 @@ def get_database_uri() -> str: Returns: str: database uri with utc timezone """ - uri = '' - if 'SQLALCHEMY_DATABASE_URI' in os.environ: - uri = os.getenv('SQLALCHEMY_DATABASE_URI') - else: - uri = 'sqlite:///{}?check_same_thread=False'.format( - os.path.join(BASE_DIR, 'app.db')) - return _turn_db_timezone_to_utc(uri) + uri = Envs.SQLALCHEMY_DATABASE_URI + if not uri: + db_path = os.path.join(BASE_DIR, 'app.db') + uri = f'sqlite:///{db_path}?check_same_thread=False' + return turn_db_timezone_to_utc(uri) -def get_engine(database_uri: str) -> Engine: - """get engine according to database uri +def _get_engine(database_uri: str) -> Engine: + """Gets engine according to database uri. Args: database_uri (str): database uri used for create engine @@ -119,7 +131,12 @@ def get_engine(database_uri: str) -> Engine: Returns: Engine: engine used for managing connections """ - return create_engine(database_uri, **ENGINE_OPTIONS) + engine_options = {} + if database_uri.startswith('mysql'): + engine_options = MYSQL_OPTIONS + elif database_uri.startswith('sqlite'): + engine_options = SQLITE_OPTIONS + return create_engine(database_uri, **engine_options) @contextmanager @@ -133,8 +150,8 @@ def get_session(db_engine: Engine) -> ContextManager[Session]: """ try: session: Session = sessionmaker(bind=db_engine, **SESSION_OPTIONS)() - except Exception: - raise Exception('unknown db engine') + except Exception as e: + raise Exception('unknown db engine') from e try: yield session @@ -145,40 +162,12 @@ def get_session(db_engine: Engine) -> ContextManager[Session]: session.close() -def make_session_context() -> Callable[[], ContextManager[Session]]: - """A functional closure that will store engine - Call it n times if you want to n connection pools - - Returns: - Callable[[], Callable[[], ContextManager[Session]]] - a function that return a contextmanager - - - Examples: - # First initialize a connection pool, - # when you want to a new connetion pool - session_context = make_session_context() - ... - # You use it multiple times as follows. - with session_context() as session: - session.query(SomeMapperClass).filter_by(id=1).one() - """ - engine = None - - def wrapper_get_session(): - nonlocal engine - if engine is None: - engine = get_engine(get_database_uri()) - return get_session(engine) - - return wrapper_get_session - - class DBHandler(object): + def __init__(self) -> None: super().__init__() - self.engine: Engine = get_engine(get_database_uri()) + self.engine: Engine = _get_engine(get_database_uri()) self.Model: DeclarativeMeta = declarative_base(bind=self.engine) for module in sa, sa.orm: for key in module.__all__: @@ -193,7 +182,7 @@ def metadata(self) -> DeclarativeMeta: return self.Model.metadata def rebind(self, database_uri: str): - self.engine = get_engine(database_uri) + self.engine = _get_engine(database_uri) self.Model = declarative_base(bind=self.engine, metadata=self.metadata) def create_all(self): @@ -203,9 +192,6 @@ def drop_all(self): return self.metadata.drop_all() -# now db_handler and db are alive at the same time -# db will be replaced by db_handler in the near future -db_handler = DBHandler() -db = SQLAlchemy(session_options=SESSION_OPTIONS, - engine_options=ENGINE_OPTIONS, - metadata=db_handler.metadata) +# now db and db are alive at the same time +# db will be replaced by db in the near future +db = DBHandler() diff --git a/web_console_v2/api/fedlearner_webconsole/debug/__init__.py b/web_console_v2/api/fedlearner_webconsole/debug/__init__.py index 3e28547fe..c13b80f8f 100644 --- a/web_console_v2/api/fedlearner_webconsole/debug/__init__.py +++ b/web_console_v2/api/fedlearner_webconsole/debug/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/web_console_v2/api/fedlearner_webconsole/debug/apis.py b/web_console_v2/api/fedlearner_webconsole/debug/apis.py index 4c74e9a80..ab58f220f 100644 --- a/web_console_v2/api/fedlearner_webconsole/debug/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/debug/apis.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,80 +13,154 @@ # limitations under the License. # coding: utf-8 +import datetime import json -from flask_restful import Resource, Api, request +import tensorflow as tf +import yaml +from flask_restful import Resource, Api, request, reqparse -from fedlearner_webconsole.composer.composer import composer -from fedlearner_webconsole.composer.runner import MemoryItem -from fedlearner_webconsole.dataset.data_pipeline import DataPipelineItem, \ - DataPipelineType +from fedlearner_webconsole.composer.composer_service import ComposerService +from fedlearner_webconsole.composer.models import SchedulerRunner, \ + SchedulerItem +from fedlearner_webconsole.utils.tfrecords_reader import tf_record_reader +from fedlearner_webconsole.exceptions import InvalidArgumentException +from fedlearner_webconsole.k8s.k8s_cache import k8s_cache +from fedlearner_webconsole.k8s.k8s_client import k8s_client +from fedlearner_webconsole.db import db -class ComposerApi(Resource): +class DebugComposerApi(Resource): + def get(self, name): - interval = request.args.get('interval', -1) + cron_config = request.args.get('cron_config') finish = request.args.get('finish', 0) - if int(finish) == 1: - composer.finish(name) - else: - composer.collect( - name, - [MemoryItem(1), MemoryItem(2)], - { # meta data - 1: { - 'input': 'fs://data/memory_1', - }, - 2: { - 'input': 'fs://data/memory_2', - } - }, - interval=int(interval), - ) - return {'data': {'name': name}} - - -class DataPipelineApi(Resource): - def get(self, name: str): - # '/data/fl_v2_fish_fooding/dataset/20210527_221741_pipeline' - input_dir = request.args.get('input_dir', None) - if not input_dir: - return {'msg': 'no input dir'} - if 'pipe' in name: - composer.collect( - name, - [DataPipelineItem(1), DataPipelineItem(2)], - { # meta data - 1: { # convertor - 'sparkapp_name': '1', - 'task_type': DataPipelineType.CONVERTER.value, - 'input': [input_dir, 'batch/**/*.csv'], - }, - 2: { # analyzer - 'sparkapp_name': '2', - 'task_type': DataPipelineType.ANALYZER.value, - 'input': [input_dir, 'rds/**'], - }, - }, - ) - elif 'fe' in name: - composer.collect( - name, - [DataPipelineItem(1)], - { # meta data - 1: { # transformer - 'sparkapp_name': '1', - 'task_type': DataPipelineType.TRANSFORMER.value, - 'input': [input_dir, 'rds/**', json.dumps({ - 'f00000': 1.0, - 'f00010': 0.0, - })], - }, - }, - ) - return {'data': {'name': name}} + with db.session_scope() as session: + service = ComposerService(session) + if int(finish) == 1: + service.finish(name) + session.commit() + return {'data': {'name': name}} + + +class DebugSparkAppApi(Resource): + + def post(self, name: str): + data = yaml.load(f""" +apiVersion: "sparkoperator.k8s.io/v1beta2" +kind: SparkApplication +metadata: + name: {name} + namespace: default +spec: + type: Python + pythonVersion: "3" + mode: cluster + image: "registry.cn-beijing.aliyuncs.com/fedlearner/spark-tfrecord:latest" + imagePullPolicy: Always + volumes: + - name: data + persistentVolumeClaim: + claimName: pvc-fedlearner-default + mainApplicationFile: local:///data/sparkapp_test/tyt_test/schema_check.py + arguments: + - /data/sparkapp_test/tyt_test/test.csv + - /data/sparkapp_test/tyt_test/schema.json + sparkVersion: "3.0.0" + restartPolicy: + type: OnFailure + onFailureRetries: 3 + onFailureRetryInterval: 10 + onSubmissionFailureRetries: 5 + onSubmissionFailureRetryInterval: 20 + driver: + cores: 1 + coreLimit: "1200m" + memory: "512m" + labels: + version: 3.0.0 + serviceAccount: spark + volumeMounts: + - name: data + mountPath: /data + executor: + cores: 1 + instances: 1 + memory: "512m" + labels: + version: 3.0.0 + volumeMounts: + - name: data + mountPath: /data +""", + Loader=None) + data = k8s_client.create_sparkapplication(data) + return {'data': data} + + +class DebugK8sCacheApi(Resource): + + def get(self): + + def default(o): + if isinstance(o, (datetime.date, datetime.datetime)): + return o.isoformat() + return str(o) + + return {'data': json.dumps(k8s_cache.inspect(), default=default)} + + +class DebugTfRecordApi(Resource): + + def get(self): + path = request.args.get('path', None) + + if path is None or not tf.io.gfile.exists(path): + raise InvalidArgumentException('path is not found') + + lines = request.args.get('lines', 25, int) + tf_matrix = tf_record_reader(path, lines, matrix_view=True) + + return {'data': tf_matrix} + + +class DebugSchedulerItemsApi(Resource): + + def get(self): + parser = reqparse.RequestParser() + parser.add_argument('status', type=int, location='args', required=False, choices=[0, 1]) + parser.add_argument('id', type=int, location='args', required=False) + data = parser.parse_args() + with db.session_scope() as session: + items = session.query(SchedulerItem) + if data['status'] is not None: + items = items.filter_by(status=data['status']) + if data['id'] is not None: + runners = session.query(SchedulerRunner).filter_by(item_id=data['id']).order_by( + SchedulerRunner.updated_at.desc()).limit(10).all() + return {'data': [runner.to_dict() for runner in runners]} + items = items.order_by(SchedulerItem.created_at.desc()).all() + return {'data': [item.to_dict() for item in items]} + + +class DebugSchedulerRunnersApi(Resource): + + def get(self): + parser = reqparse.RequestParser() + parser.add_argument('status', type=int, location='args', required=False, choices=[0, 1, 2, 3]) + data = parser.parse_args() + with db.session_scope() as session: + runners = session.query(SchedulerRunner) + if data['status'] is not None: + runners = runners.filter_by(status=data['status']) + runners = runners.order_by(SchedulerRunner.updated_at.desc()).all() + return {'data': [runner.to_dict() for runner in runners]} def initialize_debug_apis(api: Api): - api.add_resource(ComposerApi, '/debug/composer/') - api.add_resource(DataPipelineApi, '/debug/pipeline/') + api.add_resource(DebugComposerApi, '/debug/composer/') + api.add_resource(DebugSparkAppApi, '/debug/sparkapp/') + api.add_resource(DebugK8sCacheApi, '/debug/k8scache/') + api.add_resource(DebugTfRecordApi, '/debug/tfrecord') + api.add_resource(DebugSchedulerItemsApi, '/debug/scheduler_items') + api.add_resource(DebugSchedulerRunnersApi, '/debug/scheduler_runners') diff --git a/web_console_v2/api/fedlearner_webconsole/exceptions.py b/web_console_v2/api/fedlearner_webconsole/exceptions.py index 3de880de0..1d1695639 100644 --- a/web_console_v2/api/fedlearner_webconsole/exceptions.py +++ b/web_console_v2/api/fedlearner_webconsole/exceptions.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ class WebConsoleApiException(Exception): + def __init__(self, status_code, error_code, message, details=None): Exception.__init__(self) self.status_code = status_code @@ -40,40 +41,52 @@ def to_dict(self): class InvalidArgumentException(WebConsoleApiException): + + def __init__(self, details): + WebConsoleApiException.__init__(self, HTTPStatus.BAD_REQUEST, 400, 'Invalid argument or payload.', details) + + +class NetworkException(WebConsoleApiException): + def __init__(self, details): - WebConsoleApiException.__init__(self, HTTPStatus.BAD_REQUEST, 400, - 'Invalid argument or payload.', details) + WebConsoleApiException.__init__(self, HTTPStatus.BAD_REQUEST, 400, 'Network exception', details) class NotFoundException(WebConsoleApiException): + def __init__(self, message=None): - WebConsoleApiException.__init__( - self, HTTPStatus.NOT_FOUND, 404, - message if message else 'Resource not found.') + WebConsoleApiException.__init__(self, HTTPStatus.NOT_FOUND, 404, message if message else 'Resource not found.') class UnauthorizedException(WebConsoleApiException): + def __init__(self, message): - WebConsoleApiException.__init__(self, HTTPStatus.UNAUTHORIZED, - 401, message) + WebConsoleApiException.__init__(self, HTTPStatus.UNAUTHORIZED, 401, message) class NoAccessException(WebConsoleApiException): + def __init__(self, message): - WebConsoleApiException.__init__(self, HTTPStatus.FORBIDDEN, - 403, message) + WebConsoleApiException.__init__(self, HTTPStatus.FORBIDDEN, 403, message) + + +class MethodNotAllowedException(WebConsoleApiException): + + def __init__(self, message): + WebConsoleApiException.__init__(self, HTTPStatus.METHOD_NOT_ALLOWED, 405, message) class ResourceConflictException(WebConsoleApiException): + def __init__(self, message): WebConsoleApiException.__init__(self, HTTPStatus.CONFLICT, 409, message) class InternalException(WebConsoleApiException): + def __init__(self, details=None): - WebConsoleApiException.__init__( - self, HTTPStatus.INTERNAL_SERVER_ERROR, 500, - 'Internal Error met when handling the request', details) + WebConsoleApiException.__init__(self, HTTPStatus.INTERNAL_SERVER_ERROR, 500, + 'Internal Error met when handling the request', details) def make_response(exception: WebConsoleApiException): diff --git a/web_console_v2/api/fedlearner_webconsole/initial_db.py b/web_console_v2/api/fedlearner_webconsole/initial_db.py index da9997ba0..a5e80487d 100644 --- a/web_console_v2/api/fedlearner_webconsole/initial_db.py +++ b/web_console_v2/api/fedlearner_webconsole/initial_db.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,30 +11,251 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import collections +import json +import os -from fedlearner_webconsole.auth.models import User, Role, State -from fedlearner_webconsole.db import db_handler as db +from pathlib import Path + +from sqlalchemy.orm import Session +from google.protobuf.json_format import ParseDict + +from fedlearner_webconsole.auth.models import Role, State, User +from fedlearner_webconsole.composer.interface import ItemType +from fedlearner_webconsole.db import db +from fedlearner_webconsole.proto.composer_pb2 import RunnerInput +from fedlearner_webconsole.proto.setting_pb2 import SystemVariables +from fedlearner_webconsole.setting.models import Setting +from fedlearner_webconsole.setting.service import SettingService +from fedlearner_webconsole.workflow_template.models import WorkflowTemplate, WorkflowTemplateKind +from fedlearner_webconsole.proto.workflow_definition_pb2 import (WorkflowDefinition, WorkflowTemplateEditorInfo) +from fedlearner_webconsole.composer.composer_service import ComposerService +from fedlearner_webconsole.flag.models import Flag + +SettingTuple = collections.namedtuple('SettingTuple', ['key', 'value']) INITIAL_USER_INFO = [{ 'username': 'ada', - 'password': 'fl@123.', + 'password': 'fl@12345.', 'name': 'ada', 'email': 'ada@fedlearner.com', 'role': Role.USER, 'state': State.ACTIVE, }, { 'username': 'admin', - 'password': 'fl@123.', + 'password': 'fl@12345.', 'name': 'admin', 'email': 'admin@fedlearner.com', 'role': Role.ADMIN, 'state': State.ACTIVE, +}, { + 'username': 'robot', + 'password': 'fl@12345.', + 'name': 'robot', + 'email': 'robot@fedlearner.com', + 'role': Role.ADMIN, + 'state': State.ACTIVE, }] +INITIAL_SYSTEM_VARIABLES = ParseDict( + { + 'variables': [{ + 'name': 'labels', + 'value': {}, + 'value_type': 'OBJECT', + 'fixed': True + }, { + 'name': 'volume_mounts_list', + 'value': [{ + 'mountPath': '/data', + 'name': 'data' + }], + 'value_type': 'LIST', + 'fixed': True + }, { + 'name': 'volumes_list', + 'value': [{ + 'persistentVolumeClaim': { + 'claimName': 'pvc-fedlearner-default' + }, + 'name': 'data' + }], + 'value_type': 'LIST', + 'fixed': True + }, { + 'name': 'envs_list', + 'value': [{ + 'name': 'HADOOP_HOME', + 'value': '' + }, { + 'name': 'MANUFACTURER', + 'value': 'dm9sY2VuZ2luZQ==' + }], + 'value_type': 'LIST', + 'fixed': True + }, { + 'name': 'namespace', + 'value': 'default', + 'value_type': 'STRING', + 'fixed': True + }, { + 'name': 'serving_image', + 'value': 'artifact.bytedance.com/fedlearner/' + 'privacy_perserving_computing_serving:7359b10685e1646450dfda389d228066', + 'value_type': 'STRING', + 'fixed': True + }, { + 'name': 'spark_image', + 'value': 'artifact.bytedance.com/fedlearner/pp_data_inspection:2.2.4.1', + 'value_type': 'STRING', + 'fixed': True + }, { + 'name': 'image_repo', + 'value': 'artifact.bytedance.com/fedlearner', + 'value_type': 'STRING', + 'fixed': False + }] + }, SystemVariables()) + +INITIAL_EMAIL_GROUP = SettingTuple(key='sys_email_group', value='privacy_computing@bytedance.com') + + +def _insert_setting_if_not_exists(session: Session, st: SettingTuple): + if session.query(Setting).filter_by(uniq_key=st.key).first() is None: + setting = Setting(uniq_key=st.key, value=st.value) + session.add(setting) + + +def migrate_system_variables(session: Session, initial_vars: SystemVariables): + setting_service = SettingService(session) + origin_sys_vars = setting_service.get_system_variables() + result = merge_system_variables(initial_vars, origin_sys_vars) + setting_service.set_system_variables(result) + + +def merge_system_variables(extend: SystemVariables, origin: SystemVariables) -> SystemVariables: + """Merge two Systemvariables, when two SystemVariable has the same name, use origin's value.""" + key_map = {var.name: var for var in extend.variables} + for var in origin.variables: + key_map[var.name] = var + return SystemVariables(variables=[key_map[key] for key in key_map]) + + +def _insert_or_update_templates(session: Session): + path = Path(__file__, '../sys_preset_templates/').resolve() + template_files = path.rglob('*.json') + for template_file in template_files: + with open(os.path.join(path, template_file), encoding='utf-8') as f: + data = json.load(f) + template_proto = ParseDict(data['config'], WorkflowDefinition(), ignore_unknown_fields=True) + editor_info_proto = ParseDict(data['editor_info'], WorkflowTemplateEditorInfo(), ignore_unknown_fields=True) + template = session.query(WorkflowTemplate).filter_by(name=data['name']).first() + if template is None: + template = WorkflowTemplate(name=data['name']) + template.comment = data['comment'] + template.group_alias = template_proto.group_alias + template.kind = WorkflowTemplateKind.PRESET.value + template.set_config(template_proto) + template.set_editor_info(editor_info_proto) + session.add(template) + + +def _insert_schedule_workflow_item(session): + composer_service = ComposerService(session) + # Finishes the old one + composer_service.finish('workflow_scheduler') + composer_service.collect_v2( + 'workflow_scheduler_v2', + items=[(ItemType.SCHEDULE_WORKFLOW, RunnerInput())], + # cron job at every 1 minute, specific time to avoid congestion. + cron_config='* * * * * 45') + composer_service.collect_v2( + 'job_scheduler_v2', + items=[(ItemType.SCHEDULE_JOB, RunnerInput())], + # cron job at every 1 minute, specific time to avoid congestion. + cron_config='* * * * * 15') + + +def _insert_dataset_job_scheduler_item(session): + composer_service = ComposerService(session) + # finish the old scheduler + composer_service.finish('dataset_job_scheduler') + composer_service.finish('dataset_cron_job_scheduler') + # insert new scheduler + composer_service.collect_v2( + 'dataset_short_period_scheduler', + items=[(ItemType.DATASET_SHORT_PERIOD_SCHEDULER, RunnerInput())], + # cron job at every 30 seconds + cron_config='* * * * * */30') + composer_service.collect_v2( + 'dataset_long_period_scheduler', + items=[(ItemType.DATASET_LONG_PERIOD_SCHEDULER, RunnerInput())], + # cron job at every 30 min + cron_config='*/30 * * * *') + + +def _insert_cleanup_cronjob_item(session): + composer_service = ComposerService(session) + composer_service.collect_v2( + 'cleanup_cron_job', + items=[(ItemType.CLEANUP_CRON_JOB, RunnerInput())], + # cron job at every 30 min + cron_config='*/30 * * * *') + + +def _insert_tee_runner_item(session): + if not Flag.TRUSTED_COMPUTING_ENABLED.value: + return + composer_service = ComposerService(session) + composer_service.collect_v2( + 'tee_create_runner', + items=[(ItemType.TEE_CREATE_RUNNER, RunnerInput())], + # cron job at every 30 seconds + cron_config='* * * * * */30') + composer_service.collect_v2( + 'tee_resource_check_runner', + items=[(ItemType.TEE_RESOURCE_CHECK_RUNNER, RunnerInput())], + # cron job at every 30 min + cron_config='*/30 * * * *') + + +def _insert_project_runner_item(session): + if not Flag.PENDING_PROJECT_ENABLED.value: + return + composer_service = ComposerService(session) + composer_service.collect_v2( + 'project_scheduler_v2', + items=[(ItemType.SCHEDULE_PROJECT, RunnerInput())], + # cron job at every 1 minute, specific time to avoid congestion. + cron_config='* * * * * 30') + + +def _insert_model_job_scheduler_runner_item(session: Session): + if not Flag.MODEL_JOB_GLOBAL_CONFIG_ENABLED: + return + composer_service = ComposerService(session) + composer_service.collect_v2('model_job_scheduler_runner', + items=[(ItemType.SCHEDULE_MODEL_JOB, RunnerInput())], + cron_config='* * * * * */30') + + +def _insert_model_job_group_scheduler_runner_item(session: Session): + if not Flag.MODEL_JOB_GLOBAL_CONFIG_ENABLED: + return + composer_service = ComposerService(session) + composer_service.collect_v2('model_job_group_scheduler_runner', + items=[(ItemType.SCHEDULE_MODEL_JOB_GROUP, RunnerInput())], + cron_config='* * * * * */30') + composer_service.collect_v2( + 'model_job_group_long_period_scheduler_runner', + items=[(ItemType.SCHEDULE_LONG_PERIOD_MODEL_JOB_GROUP, RunnerInput())], + # cron job at every 30 min + cron_config='*/30 * * * *') + def initial_db(): with db.session_scope() as session: - # initial user info first + # Initializes user info first for u_info in INITIAL_USER_INFO: username = u_info['username'] password = u_info['password'] @@ -42,13 +263,19 @@ def initial_db(): email = u_info['email'] role = u_info['role'] state = u_info['state'] - if session.query(User).filter_by( - username=username).first() is None: - user = User(username=username, - name=name, - email=email, - role=role, - state=state) + if session.query(User).filter_by(username=username).first() is None: + user = User(username=username, name=name, email=email, role=role, state=state) user.set_password(password=password) session.add(user) + # Initializes settings + _insert_setting_if_not_exists(session, INITIAL_EMAIL_GROUP) + migrate_system_variables(session, INITIAL_SYSTEM_VARIABLES) + _insert_or_update_templates(session) + _insert_schedule_workflow_item(session) + _insert_dataset_job_scheduler_item(session) + _insert_cleanup_cronjob_item(session) + _insert_tee_runner_item(session) + _insert_project_runner_item(session) + _insert_model_job_scheduler_runner_item(session) + _insert_model_job_group_scheduler_runner_item(session) session.commit() diff --git a/web_console_v2/api/fedlearner_webconsole/job/apis.py b/web_console_v2/api/fedlearner_webconsole/job/apis.py index d9a073dbe..83208d251 100644 --- a/web_console_v2/api/fedlearner_webconsole/job/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/job/apis.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,331 +15,545 @@ import json import logging import time +from typing import Optional -from flask_restful import Resource, reqparse, abort +from flask_restful import Resource, reqparse from google.protobuf.json_format import MessageToDict +from webargs.flaskparser import use_kwargs +from marshmallow import fields +from sqlalchemy.orm.session import Session from envs import Envs -from fedlearner_webconsole.exceptions import ( - NotFoundException, InternalException -) +from fedlearner_webconsole.db import db +from fedlearner_webconsole.exceptions import (NotFoundException, InternalException, InvalidArgumentException) from fedlearner_webconsole.job.metrics import JobMetricsBuilder from fedlearner_webconsole.job.models import Job +from fedlearner_webconsole.job.service import JobService +from fedlearner_webconsole.participant.models import Participant from fedlearner_webconsole.proto import common_pb2 from fedlearner_webconsole.rpc.client import RpcClient -from fedlearner_webconsole.utils.decorators import jwt_required +from fedlearner_webconsole.auth.third_party_sso import credentials_required from fedlearner_webconsole.utils.es import es from fedlearner_webconsole.utils.kibana import Kibana from fedlearner_webconsole.workflow.models import Workflow +from fedlearner_webconsole.utils.flask_utils import make_flask_response -def _get_job(job_id): - result = Job.query.filter_by(id=job_id).first() +def _get_job(job_id, session: Session): + result = session.query(Job).filter_by(id=job_id).first() if result is None: raise NotFoundException(f'Failed to find job_id: {job_id}') return result class JobApi(Resource): - @jwt_required() - def get(self, job_id): - job = _get_job(job_id) - return {'data': job.to_dict()} - # TODO: manual start jobs + @credentials_required + def get(self, job_id): + """Get job details. + --- + tags: + - job + description: Get job details. + parameters: + - in: path + name: job_id + schema: + type: integer + responses: + 200: + description: Detail of job + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.JobPb' + """ + with db.session_scope() as session: + job = _get_job(job_id, session) + result = job.to_proto() + result.pods.extend(JobService.get_pods(job)) + result.snapshot = JobService.get_job_yaml(job) + return make_flask_response(result) class PodLogApi(Resource): - @jwt_required() - def get(self, job_id, pod_name): - parser = reqparse.RequestParser() - parser.add_argument('start_time', type=int, location='args', - required=False, - help='start_time must be timestamp') - parser.add_argument('max_lines', type=int, location='args', - required=True, - help='max_lines is required') - data = parser.parse_args() - start_time = data['start_time'] - max_lines = data['max_lines'] - job = _get_job(job_id) - if start_time is None: - start_time = job.workflow.start_at - return {'data': es.query_log(Envs.ES_INDEX, '', pod_name, - start_time * 1000, - int(time.time() * 1000))[:max_lines][::-1]} + + @credentials_required + @use_kwargs({ + 'start_time': fields.Int(required=False, load_default=None), + 'max_lines': fields.Int(required=True) + }, + location='query') + def get(self, start_time: Optional[int], max_lines: int, job_id: int, pod_name: str): + """Get pod logs. + --- + tags: + - job + description: Get pod logs. + parameters: + - in: path + name: job_id + schema: + type: integer + - in: path + name: pod_name + schema: + type: string + - in: query + description: timestamp in seconds + name: start_time + schema: + type: integer + - in: query + name: max_lines + schema: + type: integer + required: true + responses: + 200: + description: List of pod logs + content: + application/json: + schema: + type: array + items: + type: string + + """ + with db.session_scope() as session: + job = _get_job(job_id, session) + if start_time is None and job.workflow: + start_time = job.workflow.start_at + return make_flask_response( + es.query_log(Envs.ES_INDEX, '', pod_name, (start_time or 0) * 1000)[:max_lines][::-1]) class JobLogApi(Resource): - @jwt_required() - def get(self, job_id): - parser = reqparse.RequestParser() - parser.add_argument('start_time', type=int, location='args', - required=False, - help='project_id must be timestamp') - parser.add_argument('max_lines', type=int, location='args', - required=True, - help='max_lines is required') - data = parser.parse_args() - start_time = data['start_time'] - max_lines = data['max_lines'] - job = _get_job(job_id) - if start_time is None: - start_time = job.workflow.start_at - return { - 'data': es.query_log( - Envs.ES_INDEX, job.name, - 'fedlearner-operator', - start_time * 1000, - int(time.time() * 1000), - Envs.OPERATOR_LOG_MATCH_PHRASE)[:max_lines][::-1] - } + + @credentials_required + @use_kwargs({ + 'start_time': fields.Int(required=False, load_default=None), + 'max_lines': fields.Int(required=True) + }, + location='query') + def get(self, start_time: Optional[int], max_lines: int, job_id: int): + """Get job logs. + --- + tags: + - job + description: Get job logs. + parameters: + - in: path + name: job_id + schema: + type: integer + - in: query + description: timestamp in seconds + name: start_time + schema: + type: integer + - in: query + name: max_lines + schema: + type: integer + required: true + responses: + 200: + description: List of job logs + content: + application/json: + schema: + type: array + items: + type: string + """ + with db.session_scope() as session: + job = _get_job(job_id, session) + if start_time is None and job.workflow: + start_time = job.workflow.start_at + return make_flask_response( + es.query_log(Envs.ES_INDEX, + job.name, + 'fedlearner-operator', (start_time or 0) * 1000, + match_phrase=Envs.OPERATOR_LOG_MATCH_PHRASE)[:max_lines][::-1]) class JobMetricsApi(Resource): - @jwt_required() - def get(self, job_id): - job = _get_job(job_id) - try: - metrics = JobMetricsBuilder(job).plot_metrics() - # Metrics is a list of dict. Each dict can be rendered by frontend - # with mpld3.draw_figure('figure1', json) - return {'data': metrics} - except Exception as e: # pylint: disable=broad-except - logging.warning('Error building metrics: %s', repr(e)) - abort(400, message=repr(e)) + + @credentials_required + @use_kwargs({ + 'raw': fields.Bool(required=False, load_default=False), + }, location='query') + def get(self, job_id: int, raw: bool): + """Get job Metrics. + --- + tags: + - job + description: Get job metrics. + parameters: + - in: path + name: job_id + schema: + type: integer + - in: query + name: raw + schema: + type: boolean + responses: + 200: + description: List of job metrics + content: + application/json: + schema: + type: array + items: + type: object + """ + with db.session_scope() as session: + job = _get_job(job_id, session) + try: + builder = JobMetricsBuilder(job) + if raw: + return make_flask_response(data=builder.query_metrics()) + # Metrics is a list of dict. Each dict can be rendered by frontend + # with mpld3.draw_figure('figure1', json) + return make_flask_response(data=builder.plot_metrics()) + except Exception as e: # pylint: disable=broad-except + logging.warning('Error building metrics: %s', repr(e)) + raise InvalidArgumentException(details=repr(e)) from e class PeerJobMetricsApi(Resource): - @jwt_required() - def get(self, workflow_uuid, participant_id, job_name): - workflow = Workflow.query.filter_by(uuid=workflow_uuid).first() - if workflow is None: - raise NotFoundException( - f'Failed to find workflow: {workflow_uuid}') - project_config = workflow.project.get_config() - party = project_config.participants[participant_id] - client = RpcClient(project_config, party) - resp = client.get_job_metrics(job_name) - if resp.status.code != common_pb2.STATUS_SUCCESS: - raise InternalException(resp.status.msg) - metrics = json.loads(resp.metrics) + @credentials_required + def get(self, workflow_uuid: str, participant_id: int, job_name: str): + """Get peer job metrics. + --- + tags: + - job + description: Get peer Job metrics. + parameters: + - in: path + name: workflow_uuid + schema: + type: string + - in: path + name: participant_id + schema: + type: integer + - in: path + name: job_name + schema: + type: string + responses: + 200: + description: List of job metrics + content: + application/json: + schema: + type: array + items: + type: object + """ + with db.session_scope() as session: + workflow = session.query(Workflow).filter_by(uuid=workflow_uuid).first() + if workflow is None: + raise NotFoundException(f'Failed to find workflow: {workflow_uuid}') + participant = session.query(Participant).filter_by(id=participant_id).first() + client = RpcClient.from_project_and_participant(workflow.project.name, workflow.project.token, + participant.domain_name) + resp = client.get_job_metrics(job_name) + if resp.status.code != common_pb2.STATUS_SUCCESS: + raise InternalException(resp.status.msg) + + metrics = json.loads(resp.metrics) - # Metrics is a list of dict. Each dict can be rendered by frontend with - # mpld3.draw_figure('figure1', json) - return {'data': metrics} + # Metrics is a list of dict. Each dict can be rendered by frontend with + # mpld3.draw_figure('figure1', json) + return make_flask_response(metrics) class JobEventApi(Resource): # TODO(xiangyuxuan): need test - @jwt_required() - def get(self, job_id): - parser = reqparse.RequestParser() - parser.add_argument('start_time', type=int, location='args', - required=False, - help='start_time must be timestamp') - parser.add_argument('max_lines', type=int, location='args', - required=True, - help='max_lines is required') - data = parser.parse_args() - start_time = data['start_time'] - max_lines = data['max_lines'] - job = _get_job(job_id) - if start_time is None: - start_time = job.workflow.start_at - return {'data': es.query_events(Envs.ES_INDEX, job.name, - 'fedlearner-operator', - start_time, - int(time.time() * 1000 - ), - Envs.OPERATOR_LOG_MATCH_PHRASE - )[:max_lines][::-1]} + @credentials_required + @use_kwargs({ + 'start_time': fields.Int(required=False, load_default=None), + 'max_lines': fields.Int(required=True) + }, + location='query') + def get(self, start_time: Optional[int], max_lines: int, job_id: int): + """Get job events. + --- + tags: + - job + description: Get job events. + parameters: + - in: path + name: job_id + schema: + type: integer + - in: query + description: timestamp in seconds + name: start_time + schema: + type: integer + - in: query + name: max_lines + schema: + type: integer + required: true + responses: + 200: + description: List of job events + content: + application/json: + schema: + type: array + items: + type: string + """ + with db.session_scope() as session: + job = _get_job(job_id, session) + if start_time is None and job.workflow: + start_time = job.workflow.start_at + return make_flask_response( + es.query_events(Envs.ES_INDEX, job.name, 'fedlearner-operator', start_time, int(time.time() * 1000), + Envs.OPERATOR_LOG_MATCH_PHRASE)[:max_lines][::-1]) class PeerJobEventsApi(Resource): - @jwt_required() - def get(self, workflow_uuid, participant_id, job_name): - parser = reqparse.RequestParser() - parser.add_argument('start_time', type=int, location='args', - required=False, - help='project_id must be timestamp') - parser.add_argument('max_lines', type=int, location='args', - required=True, - help='max_lines is required') - data = parser.parse_args() - start_time = data['start_time'] - max_lines = data['max_lines'] - workflow = Workflow.query.filter_by(uuid=workflow_uuid).first() - if workflow is None: - raise NotFoundException( - f'Failed to find workflow: {workflow_uuid}') - if start_time is None: - start_time = workflow.start_at - project_config = workflow.project.get_config() - party = project_config.participants[participant_id] - client = RpcClient(project_config, party) - resp = client.get_job_events(job_name=job_name, - start_time=start_time, - max_lines=max_lines) - if resp.status.code != common_pb2.STATUS_SUCCESS: - raise InternalException(resp.status.msg) - peer_events = MessageToDict( - resp, - preserving_proto_field_name=True, - including_default_value_fields=True)['logs'] - return {'data': peer_events} + + @credentials_required + @use_kwargs({ + 'start_time': fields.Int(required=False, load_default=None), + 'max_lines': fields.Int(required=True) + }, + location='query') + def get(self, start_time: Optional[int], max_lines: int, workflow_uuid: str, participant_id: int, job_name: str): + """Get peer job events. + --- + tags: + - job + description: Get peer job events. + parameters: + - in: path + name: workflow_uuid + schema: + type: string + - in: path + name: participant_id + schema: + type: integer + - in: path + name: job_name + schema: + type: string + responses: + 200: + description: List of peer job events + content: + application/json: + schema: + type: array + items: + type: string + """ + with db.session_scope() as session: + workflow = session.query(Workflow).filter_by(uuid=workflow_uuid).first() + if workflow is None: + raise NotFoundException(f'Failed to find workflow: {workflow_uuid}') + if start_time is None: + start_time = workflow.start_at + participant = session.query(Participant).filter_by(id=participant_id).first() + client = RpcClient.from_project_and_participant(workflow.project.name, workflow.project.token, + participant.domain_name) + resp = client.get_job_events(job_name=job_name, start_time=start_time, max_lines=max_lines) + if resp.status.code != common_pb2.STATUS_SUCCESS: + raise InternalException(resp.status.msg) + peer_events = MessageToDict(resp, preserving_proto_field_name=True, + including_default_value_fields=True)['logs'] + return make_flask_response(peer_events) class KibanaMetricsApi(Resource): - @jwt_required() + + @credentials_required def get(self, job_id): - job = _get_job(job_id) parser = reqparse.RequestParser() - parser.add_argument('type', type=str, location='args', + parser.add_argument('type', + type=str, + location='args', required=True, - choices=('Rate', 'Ratio', 'Numeric', - 'Time', 'Timer'), + choices=('Rate', 'Ratio', 'Numeric', 'Time', 'Timer'), help='Visualization type is required. Choices: ' - 'Rate, Ratio, Numeric, Time, Timer') - parser.add_argument('interval', type=str, location='args', + 'Rate, Ratio, Numeric, Time, Timer') + parser.add_argument('interval', + type=str, + location='args', default='', help='Time bucket interval length, ' - 'defaults to be automated by Kibana.') - parser.add_argument('x_axis_field', type=str, location='args', + 'defaults to be automated by Kibana.') + parser.add_argument('x_axis_field', + type=str, + location='args', default='tags.event_time', help='Time field (X axis) is required.') - parser.add_argument('query', type=str, location='args', - help='Additional query string to the graph.') - parser.add_argument('start_time', type=int, location='args', + parser.add_argument('query', type=str, location='args', help='Additional query string to the graph.') + parser.add_argument('start_time', + type=int, + location='args', default=-1, help='Earliest time of data.' - 'Unix timestamp in secs.') - parser.add_argument('end_time', type=int, location='args', + 'Unix timestamp in secs.') + parser.add_argument('end_time', + type=int, + location='args', default=-1, help='Latest time of data.' - 'Unix timestamp in secs.') + 'Unix timestamp in secs.') # (Joined) Rate visualization is fixed and only interval, query and # x_axis_field can be modified # Ratio visualization - parser.add_argument('numerator', type=str, location='args', + parser.add_argument('numerator', + type=str, + location='args', help='Numerator is required in Ratio ' - 'visualization. ' - 'A query string similar to args::query.') - parser.add_argument('denominator', type=str, location='args', + 'visualization. ' + 'A query string similar to args::query.') + parser.add_argument('denominator', + type=str, + location='args', help='Denominator is required in Ratio ' - 'visualization. ' - 'A query string similar to args::query.') + 'visualization. ' + 'A query string similar to args::query.') # Numeric visualization - parser.add_argument('aggregator', type=str, location='args', + parser.add_argument('aggregator', + type=str, + location='args', default='Average', - choices=('Average', 'Sum', 'Max', 'Min', 'Variance', - 'Std. Deviation', 'Sum of Squares'), + choices=('Average', 'Sum', 'Max', 'Min', 'Variance', 'Std. Deviation', 'Sum of Squares'), help='Aggregator type is required in Numeric and ' - 'Timer visualization.') - parser.add_argument('value_field', type=str, location='args', + 'Timer visualization.') + parser.add_argument('value_field', + type=str, + location='args', help='The field to be aggregated on is required ' - 'in Numeric visualization.') + 'in Numeric visualization.') # No additional arguments in Time visualization # # Timer visualization - parser.add_argument('timer_names', type=str, location='args', + parser.add_argument('timer_names', + type=str, + location='args', help='Names of timers is required in ' - 'Timer visualization.') - parser.add_argument('split', type=int, location='args', - default=0, - help='Whether to plot timers individually.') + 'Timer visualization.') + parser.add_argument('split', type=int, location='args', default=0, help='Whether to plot timers individually.') args = parser.parse_args() - try: - if args['type'] in Kibana.TSVB: - return {'data': Kibana.create_tsvb(job, args)} - if args['type'] in Kibana.TIMELION: - return {'data': Kibana.create_timelion(job, args)} - return {'data': []} - except Exception as e: # pylint: disable=broad-except - abort(400, message=repr(e)) + with db.session_scope() as session: + job = _get_job(job_id, session) + try: + if args['type'] in Kibana.TSVB: + return {'data': Kibana.create_tsvb(job, args)} + if args['type'] in Kibana.TIMELION: + return {'data': Kibana.create_timelion(job, args)} + return {'data': []} + except Exception as e: # pylint: disable=broad-except + raise InvalidArgumentException(details=repr(e)) from e class PeerKibanaMetricsApi(Resource): - @jwt_required() + + @credentials_required def get(self, workflow_uuid, participant_id, job_name): parser = reqparse.RequestParser() - parser.add_argument('type', type=str, location='args', + parser.add_argument('type', + type=str, + location='args', required=True, choices=('Ratio', 'Numeric'), help='Visualization type is required. Choices: ' - 'Rate, Ratio, Numeric, Time, Timer') - parser.add_argument('interval', type=str, location='args', + 'Rate, Ratio, Numeric, Time, Timer') + parser.add_argument('interval', + type=str, + location='args', default='', help='Time bucket interval length, ' - 'defaults to be automated by Kibana.') - parser.add_argument('x_axis_field', type=str, location='args', + 'defaults to be automated by Kibana.') + parser.add_argument('x_axis_field', + type=str, + location='args', default='tags.event_time', help='Time field (X axis) is required.') - parser.add_argument('query', type=str, location='args', - help='Additional query string to the graph.') - parser.add_argument('start_time', type=int, location='args', + parser.add_argument('query', type=str, location='args', help='Additional query string to the graph.') + parser.add_argument('start_time', + type=int, + location='args', default=-1, help='Earliest time of data.' - 'Unix timestamp in secs.') - parser.add_argument('end_time', type=int, location='args', + 'Unix timestamp in secs.') + parser.add_argument('end_time', + type=int, + location='args', default=-1, help='Latest time of data.' - 'Unix timestamp in secs.') + 'Unix timestamp in secs.') # Ratio visualization - parser.add_argument('numerator', type=str, location='args', + parser.add_argument('numerator', + type=str, + location='args', help='Numerator is required in Ratio ' - 'visualization. ' - 'A query string similar to args::query.') - parser.add_argument('denominator', type=str, location='args', + 'visualization. ' + 'A query string similar to args::query.') + parser.add_argument('denominator', + type=str, + location='args', help='Denominator is required in Ratio ' - 'visualization. ' - 'A query string similar to args::query.') + 'visualization. ' + 'A query string similar to args::query.') # Numeric visualization - parser.add_argument('aggregator', type=str, location='args', + parser.add_argument('aggregator', + type=str, + location='args', default='Average', - choices=('Average', 'Sum', 'Max', 'Min', 'Variance', - 'Std. Deviation', 'Sum of Squares'), + choices=('Average', 'Sum', 'Max', 'Min', 'Variance', 'Std. Deviation', 'Sum of Squares'), help='Aggregator type is required in Numeric and ' - 'Timer visualization.') - parser.add_argument('value_field', type=str, location='args', + 'Timer visualization.') + parser.add_argument('value_field', + type=str, + location='args', help='The field to be aggregated on is required ' - 'in Numeric visualization.') + 'in Numeric visualization.') args = parser.parse_args() - workflow = Workflow.query.filter_by(uuid=workflow_uuid).first() - if workflow is None: - raise NotFoundException( - f'Failed to find workflow: {workflow_uuid}') - project_config = workflow.project.get_config() - party = project_config.participants[participant_id] - client = RpcClient(project_config, party) - resp = client.get_job_kibana(job_name, json.dumps(args)) - if resp.status.code != common_pb2.STATUS_SUCCESS: - raise InternalException(resp.status.msg) - metrics = json.loads(resp.metrics) - # metrics is a list of 2-element lists, - # each 2-element list is a [x, y] pair. - return {'data': metrics} + with db.session_scope() as session: + workflow = session.query(Workflow).filter_by(uuid=workflow_uuid).first() + if workflow is None: + raise NotFoundException(f'Failed to find workflow: {workflow_uuid}') + participant = session.query(Participant).filter_by(id=participant_id).first() + client = RpcClient.from_project_and_participant(workflow.project.name, workflow.project.token, + participant.domain_name) + resp = client.get_job_kibana(job_name, json.dumps(args)) + if resp.status.code != common_pb2.STATUS_SUCCESS: + raise InternalException(resp.status.msg) + metrics = json.loads(resp.metrics) + # metrics is a list of 2-element lists, + # each 2-element list is a [x, y] pair. + return {'data': metrics} def initialize_job_apis(api): api.add_resource(JobApi, '/jobs/') - api.add_resource(PodLogApi, - '/jobs//pods//log') - api.add_resource(JobLogApi, - '/jobs//log') - api.add_resource(JobMetricsApi, - '/jobs//metrics') - api.add_resource(KibanaMetricsApi, - '/jobs//kibana_metrics') - api.add_resource(PeerJobMetricsApi, - '/workflows//peer_workflows' - '//jobs//metrics') - api.add_resource(PeerKibanaMetricsApi, - '/workflows//peer_workflows' - '//jobs/' - '/kibana_metrics') + api.add_resource(PodLogApi, '/jobs//pods//log') + api.add_resource(JobLogApi, '/jobs//log') + api.add_resource(JobMetricsApi, '/jobs//metrics') + api.add_resource(KibanaMetricsApi, '/jobs//kibana_metrics') + api.add_resource( + PeerJobMetricsApi, '/workflows//peer_workflows' + '//jobs//metrics') + api.add_resource( + PeerKibanaMetricsApi, '/workflows//peer_workflows' + '//jobs/' + '/kibana_metrics') api.add_resource(JobEventApi, '/jobs//events') - api.add_resource(PeerJobEventsApi, - '/workflows//peer_workflows' - '//jobs//events') + api.add_resource( + PeerJobEventsApi, '/workflows//peer_workflows' + '//jobs//events') diff --git a/web_console_v2/api/fedlearner_webconsole/job/metrics.py b/web_console_v2/api/fedlearner_webconsole/job/metrics.py index cda672b54..c9a8d1565 100644 --- a/web_console_v2/api/fedlearner_webconsole/job/metrics.py +++ b/web_console_v2/api/fedlearner_webconsole/job/metrics.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,43 +13,60 @@ # limitations under the License. # coding: utf-8 -from datetime import datetime - import mpld3 +from datetime import datetime +from typing import List from matplotlib.figure import Figure - -from fedlearner_webconsole.job.models import JobType from fedlearner_webconsole.utils.es import es +from fedlearner_webconsole.job.models import Job, JobType +from fedlearner_webconsole.utils.job_metrics import get_feature_importance +from fedlearner_webconsole.proto.metrics_pb2 import ModelJobMetrics, Metric + +_CONF_METRIC_LIST = ['tp', 'tn', 'fp', 'fn'] +_TREE_METRIC_LIST = ['acc', 'auc', 'precision', 'recall', 'f1', 'ks', 'mse', 'msre', 'abs'] + _CONF_METRIC_LIST +_NN_METRIC_LIST = ['acc', 'auc', 'loss', 'mse', 'abs'] class JobMetricsBuilder(object): - def __init__(self, job): + + def __init__(self, job: Job): self._job = job def _to_datetime(self, timestamp): if timestamp is None: return None - return datetime.fromtimestamp(timestamp/1000.0) + return datetime.fromtimestamp(timestamp / 1000.0) + + def _is_nn_job(self): + return self._job.job_type in [JobType.NN_MODEL_TRANINING, JobType.NN_MODEL_EVALUATION] + + def _is_tree_job(self): + return self._job.job_type in [JobType.TREE_MODEL_TRAINING, JobType.TREE_MODEL_EVALUATION] + + def query_metrics(self): + if self._is_tree_job(): + return self.query_tree_metrics(need_feature_importance=True) + if self._is_nn_job(): + return self.query_nn_metrics() + return [] def plot_metrics(self, num_buckets=30): + figs = [] if self._job.job_type == JobType.DATA_JOIN: - metrics = self.plot_data_join_metrics(num_buckets) - elif self._job.job_type in [ - JobType.NN_MODEL_TRANINING, JobType.NN_MODEL_EVALUATION]: - metrics = self.plot_nn_metrics(num_buckets) - elif self._job.job_type in [JobType.TREE_MODEL_TRAINING, - JobType.TREE_MODEL_EVALUATION]: - metrics = self.plot_tree_metrics() + figs = self.plot_data_join_metrics(num_buckets) + elif self._is_nn_job(): + metrics = self.query_nn_metrics(num_buckets) + figs = self.plot_nn_metrics(metrics) + elif self._is_tree_job(): + metrics = self.query_tree_metrics(False) + figs = self.plot_tree_metrics(metrics) elif self._job.job_type == JobType.RAW_DATA: - metrics = self.plot_raw_data_metrics(num_buckets) - else: - metrics = [] - return metrics + figs = self.plot_raw_data_metrics(num_buckets) + return figs def plot_data_join_metrics(self, num_buckets=30): res = es.query_data_join_metrics(self._job.name, num_buckets) - time_res = es.query_time_metrics(self._job.name, num_buckets, - index='data_join*') + time_res = es.query_time_metrics(self._job.name, num_buckets, index='data_join*') metrics = [] if not res['aggregations']['OVERALL']['buckets']: return metrics @@ -57,9 +74,7 @@ def plot_data_join_metrics(self, num_buckets=30): # plot pie chart for overall join rate overall = res['aggregations']['OVERALL']['buckets'][0] labels = ['joined', 'fake', 'unjoined'] - sizes = [ - overall['JOINED']['doc_count'], overall['FAKE']['doc_count'], - overall['UNJOINED']['doc_count']] + sizes = [overall['JOINED']['doc_count'], overall['FAKE']['doc_count'], overall['UNJOINED']['doc_count']] fig = Figure() ax = fig.add_subplot(111) ax.pie(sizes, labels=labels, autopct='%1.1f%%') @@ -73,16 +88,14 @@ def plot_data_join_metrics(self, num_buckets=30): et_unjoined = [buck['UNJOINED']['doc_count'] for buck in by_et] fig = Figure() ax = fig.add_subplot(111) - ax.stackplot( - et_index, et_joined, et_faked, et_unjoined, labels=labels) + ax.stackplot(et_index, et_joined, et_faked, et_unjoined, labels=labels) twin_ax = ax.twinx() twin_ax.patch.set_alpha(0.0) et_rate = [buck['JOIN_RATE']['value'] for buck in by_et] et_rate_fake = [buck['JOIN_RATE_WITH_FAKE']['value'] for buck in by_et] twin_ax.plot(et_index, et_rate, label='join rate', color='black') - twin_ax.plot(et_index, et_rate_fake, - label='join rate w/ fake', color='#8f8f8f') # grey color + twin_ax.plot(et_index, et_rate_fake, label='join rate w/ fake', color='#8f8f8f') # grey color ax.xaxis_date() ax.legend() @@ -94,53 +107,123 @@ def plot_data_join_metrics(self, num_buckets=30): return metrics - def plot_nn_metrics(self, num_buckets=30): - res = es.query_nn_metrics(self._job.name, num_buckets) - metrics = [] - if not res['aggregations']['PROCESS_TIME']['buckets']: - return metrics - - buckets = res['aggregations']['PROCESS_TIME']['buckets'] - time = [self._to_datetime(buck['key']) for buck in buckets] - - # plot auc curve - auc = [buck['AUC']['value'] for buck in buckets] - fig = Figure() - ax = fig.add_subplot(111) - ax.plot(time, auc, label='auc') - ax.legend() - metrics.append(mpld3.fig_to_dict(fig)) - + def query_nn_metrics(self, num_buckets: int = 30) -> ModelJobMetrics: + res = es.query_nn_metrics(job_name=self._job.name, metric_list=_NN_METRIC_LIST, num_buckets=num_buckets) + metrics = ModelJobMetrics() + aggregations = res['aggregations'] + for metric in _NN_METRIC_LIST: + buckets = aggregations[metric]['PROCESS_TIME']['buckets'] + if len(buckets) == 0: + continue + times = [buck['key'] for buck in buckets] + values = [buck['VALUE']['value'] for buck in buckets] + # filter none value in times and values + time_values = [(t, v) for t, v in zip(times, values) if t is not None and v is not None] + times, values = zip(*time_values) + if len(values) == 0: + continue + metrics.train[metric].steps.extend(times) + metrics.train[metric].values.extend(values) + metrics.eval[metric].steps.extend(times) + metrics.eval[metric].values.extend(values) return metrics - def plot_tree_metrics(self): - metric_list = ['acc', 'auc', 'precision', 'recall', - 'f1', 'ks', 'mse', 'msre', 'abs'] - metrics = [] - aggregations = es.query_tree_metrics(self._job.name, metric_list) - for name in metric_list: + def plot_nn_metrics(self, metrics: ModelJobMetrics): + figs = [] + for name in metrics.train: + fig = Figure() + ax = fig.add_subplot(111) + timestamp = [self._to_datetime(t) for t in metrics.train[name].steps] + values = metrics.train[name].values + ax.plot(timestamp, values, label=name) + ax.legend() + figs.append(mpld3.fig_to_dict(fig)) + return figs + + @staticmethod + def _average_value_by_iteration(metrics: [List[int], List[int]]) -> [List[int], List[int]]: + iter_to_value = {} + for iteration, value in zip(*metrics): + if iteration not in iter_to_value: + iter_to_value[iteration] = [] + iter_to_value[iteration].append(value) + iterations = [] + values = [] + for key, value_list in iter_to_value.items(): + iterations.append(key) + values.append(sum(value_list) / len(value_list)) + return [iterations, values] + + def _get_iter_val(self, records: dict) -> Metric: + iterations = [item['_source']['tags']['iteration'] for item in records] + values = [item['_source']['value'] for item in records] + iterations, values = self._average_value_by_iteration([iterations, values]) + return Metric(steps=iterations, values=values) + + @staticmethod + def _set_confusion_metric(metrics: ModelJobMetrics): + + def _is_training() -> bool: + iter_vals = metrics.train.get('tp') + if iter_vals is not None and len(iter_vals.values) > 0: + return True + return False + + def _get_last_values(name: str, is_training: bool) -> int: + if is_training: + iter_vals = metrics.train.get(name) + else: + iter_vals = metrics.eval.get(name) + if iter_vals is not None and len(iter_vals.values) > 0: + return int(iter_vals.values[-1]) + return 0 + + _is_training = _is_training() + metrics.confusion_matrix.tp = _get_last_values('tp', _is_training) + metrics.confusion_matrix.tn = _get_last_values('tn', _is_training) + metrics.confusion_matrix.fp = _get_last_values('fp', _is_training) + metrics.confusion_matrix.fn = _get_last_values('fn', _is_training) + # remove confusion relevant metrics from train metrics + for key in _CONF_METRIC_LIST: + metrics.train.pop(key) + metrics.eval.pop(key) + + def query_tree_metrics(self, need_feature_importance=False) -> ModelJobMetrics: + job_name = self._job.name + aggregations = es.query_tree_metrics(job_name, _TREE_METRIC_LIST)['aggregations'] + metrics = ModelJobMetrics() + for name in _TREE_METRIC_LIST: train_ = aggregations[name.upper()]['TRAIN']['TOP']['hits']['hits'] eval_ = aggregations[name.upper()]['EVAL']['TOP']['hits']['hits'] - if len(train_) == 0 and len(eval_) == 0: + if len(train_) > 0: + metrics.train[name].MergeFrom(self._get_iter_val(train_)) + if len(eval_) > 0: + metrics.eval[name].MergeFrom(self._get_iter_val(eval_)) + self._set_confusion_metric(metrics) + if need_feature_importance: + metrics.feature_importance.update(get_feature_importance(self._job)) + return metrics + + def plot_tree_metrics(self, metrics: ModelJobMetrics): + metric_list = set.union(set(metrics.train.keys()), set(metrics.eval.keys())) + figs = [] + for name in metric_list: + train_metric = metrics.train.get(name) + eval_metric = metrics.eval.get(name) + if train_metric is None and eval_metric is None: continue fig = Figure() ax = fig.add_subplot(111) - if len(train_) > 0: - train_metric = [(item['_source']['tags']['iteration'], - item['_source']['value']) - for item in train_] - ax.plot(*zip(*train_metric), label='train', color='blue') - if len(eval_) > 0: - eval_metric = [(item['_source']['tags']['iteration'], - item['_source']['value']) - for item in eval_] - ax.plot(*zip(*eval_metric), label='eval', color='red') + if train_metric is not None: + ax.plot(train_metric.steps, train_metric.values, label='train', color='blue') + if eval_metric is not None: + ax.plot(eval_metric.steps, eval_metric.values, label='eval', color='red') ax.legend() ax.set_title(name) ax.set_xlabel('iteration') ax.set_ylabel('value') - metrics.append(mpld3.fig_to_dict(fig)) - return metrics + figs.append(mpld3.fig_to_dict(fig)) + return figs def plot_raw_data_metrics(self, num_buckets=30): res = es.query_time_metrics(self._job.name, num_buckets) @@ -158,19 +241,9 @@ def _plot_pt_vs_et(self, res): for buck in by_pt] fig = Figure() ax = fig.add_subplot(111) - pt_index = [ - idx for idx, time in zip(pt_index, pt_min) if time is not None - ] - ax.plot( - pt_index, - list(filter(lambda x: x is not None, pt_min)), - label='min event time' - ) - ax.plot( - pt_index, - list(filter(lambda x: x is not None, pt_max)), - label='max event time' - ) + pt_index = [idx for idx, time in zip(pt_index, pt_min) if time is not None] + ax.plot(pt_index, list(filter(lambda x: x is not None, pt_min)), label='min event time') + ax.plot(pt_index, list(filter(lambda x: x is not None, pt_max)), label='max event time') ax.xaxis_date() ax.yaxis_date() diff --git a/web_console_v2/api/fedlearner_webconsole/job/models.py b/web_console_v2/api/fedlearner_webconsole/job/models.py index c9b00aff6..d00479640 100644 --- a/web_console_v2/api/fedlearner_webconsole/job/models.py +++ b/web_console_v2/api/fedlearner_webconsole/job/models.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,18 +13,22 @@ # limitations under the License. # coding: utf-8 -import datetime -import logging import enum import json +from typing import Optional + +from google.protobuf import text_format from sqlalchemy.sql import func from sqlalchemy.sql.schema import Index +from fedlearner_webconsole.job.crd import CrdService +from fedlearner_webconsole.k8s.models import K8sApp, PodState +from fedlearner_webconsole.project.models import Project +from fedlearner_webconsole.utils.pp_datetime import to_timestamp from fedlearner_webconsole.utils.mixins import to_dict_mixin from fedlearner_webconsole.db import db -from fedlearner_webconsole.k8s.models import FlApp, Pod, FlAppState -from fedlearner_webconsole.utils.k8s_client import k8s_client from fedlearner_webconsole.proto.workflow_definition_pb2 import JobDefinition +from fedlearner_webconsole.proto.job_pb2 import CrdMetaData, JobPb, JobErrorMessage class JobState(enum.Enum): @@ -35,13 +39,13 @@ class JobState(enum.Enum): # 4. WAITING -> NEW: triggered by user, stop workflow # 4. STARTED -> STOPPED: triggered by user, stop workflow # 5. STARTED -> COMPLETED/FAILED: triggered by k8s_watcher - INVALID = 0 # INVALID STATE - STOPPED = 1 # STOPPED BY USER - WAITING = 2 # SCHEDULED, WAITING FOR RUNNING - STARTED = 3 # RUNNING - NEW = 4 # BEFORE SCHEDULE - COMPLETED = 5 # SUCCEEDED JOB - FAILED = 6 # FAILED JOB + INVALID = 0 # INVALID STATE + STOPPED = 1 # STOPPED BY USER + WAITING = 2 # SCHEDULED, WAITING FOR RUNNING + STARTED = 3 # RUNNING + NEW = 4 # BEFORE SCHEDULE + COMPLETED = 5 # SUCCEEDED JOB + FAILED = 6 # FAILED JOB # must be consistent with JobType in proto @@ -54,22 +58,12 @@ class JobType(enum.Enum): TREE_MODEL_TRAINING = 5 NN_MODEL_EVALUATION = 6 TREE_MODEL_EVALUATION = 7 + TRANSFORMER = 8 + ANALYZER = 9 + CUSTOMIZED = 10 -def merge(x, y): - """Given two dictionaries, merge them into a new dict as a shallow copy.""" - z = x.copy() - z.update(y) - return z - - -@to_dict_mixin( - extras={ - 'state': (lambda job: job.get_state_for_frontend()), - 'pods': (lambda job: job.get_pods_for_frontend()), - 'config': (lambda job: job.get_config()), - 'complete_at': (lambda job: job.get_complete_at()) - }) +@to_dict_mixin(ignores=['config'], extras={'complete_at': (lambda job: job.get_complete_at())}) class Job(db.Model): __tablename__ = 'job_v2' __table_args__ = (Index('idx_workflow_id', 'workflow_id'), { @@ -77,15 +71,12 @@ class Job(db.Model): 'mysql_engine': 'innodb', 'mysql_charset': 'utf8mb4', }) - id = db.Column(db.Integer, - primary_key=True, - autoincrement=True, - comment='id') + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='id') name = db.Column(db.String(255), unique=True, comment='name') - job_type = db.Column(db.Enum(JobType, native_enum=False), + job_type = db.Column(db.Enum(JobType, native_enum=False, create_constraint=False), nullable=False, comment='job type') - state = db.Column(db.Enum(JobState, native_enum=False), + state = db.Column(db.Enum(JobState, native_enum=False, create_constraint=False), nullable=False, default=JobState.INVALID, comment='state') @@ -95,157 +86,113 @@ class Job(db.Model): workflow_id = db.Column(db.Integer, nullable=False, comment='workflow id') project_id = db.Column(db.Integer, nullable=False, comment='project id') - flapp_snapshot = db.Column(db.Text(16777215), comment='flapp snapshot') - pods_snapshot = db.Column(db.Text(16777215), comment='pods snapshot') + flapp_snapshot = db.Column(db.Text(16777215), comment='flapp snapshot') # deprecated + sparkapp_snapshot = db.Column(db.Text(16777215), comment='sparkapp snapshot') # deprecated + # Format like {'app': app_status_dict, 'pods': {'items': pod_list}}. + snapshot = db.Column(db.Text(16777215), comment='snapshot') error_message = db.Column(db.Text(), comment='error message') + crd_meta = db.Column(db.Text(), comment='metadata') + # Use string but not enum, in order to support all kinds of crd to create and delete, + # but only FLApp SparkApplication and FedApp support getting pods and auto finish. + crd_kind = db.Column(db.String(255), comment='kind') - created_at = db.Column(db.DateTime(timezone=True), - server_default=func.now(), - comment='created at') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created at') updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment='updated at') deleted_at = db.Column(db.DateTime(timezone=True), comment='deleted at') - project = db.relationship('Project', - primaryjoin='Project.id == ' - 'foreign(Job.project_id)') - workflow = db.relationship('Workflow', - primaryjoin='Workflow.id == ' - 'foreign(Job.workflow_id)') + project = db.relationship(Project.__name__, primaryjoin='Project.id == ' 'foreign(Job.project_id)') + workflow = db.relationship('Workflow', primaryjoin='Workflow.id == ' 'foreign(Job.workflow_id)') - def get_config(self): + def get_config(self) -> Optional[JobDefinition]: if self.config is not None: proto = JobDefinition() proto.ParseFromString(self.config) return proto return None - def set_config(self, proto): + def set_config(self, proto: JobDefinition): if proto is not None: self.config = proto.SerializeToString() else: self.config = None - def _set_snapshot_flapp(self): - def default(o): - if isinstance(o, (datetime.date, datetime.datetime)): - return o.isoformat() - return str(o) - - flapp = k8s_client.get_flapp(self.name) - if flapp: - self.flapp_snapshot = json.dumps(flapp, default=default) - else: - self.flapp_snapshot = None - - def get_flapp_details(self): - if self.state == JobState.STARTED: - flapp = k8s_client.get_flapp(self.name) - elif self.flapp_snapshot is not None: - flapp = json.loads(self.flapp_snapshot) - # aims to support old job - if 'flapp' not in flapp: - flapp['flapp'] = None - if 'pods' not in flapp and self.pods_snapshot: - flapp['pods'] = json.loads(self.pods_snapshot)['pods'] - else: - flapp = {'flapp': None, 'pods': {'items': []}} - return flapp - - def get_pods_for_frontend(self, include_private_info=True): - flapp_details = self.get_flapp_details() - flapp = FlApp.from_json(flapp_details.get('flapp', None)) - pods_json = None - if 'pods' in flapp_details: - pods_json = flapp_details['pods'].get('items', None) - pods = [] - if pods_json is not None: - pods = [Pod.from_json(p) for p in pods_json] - - # deduplication pods both in pods and flapp - result = {} - for pod in flapp.pods: - result[pod.name] = pod - for pod in pods: - result[pod.name] = pod - return [pod.to_dict(include_private_info) for pod in result.values()] - - def get_state_for_frontend(self): - return self.state.name - - def is_flapp_failed(self): - # TODO: make the getter more efficient - flapp = FlApp.from_json(self.get_flapp_details()['flapp']) - return flapp.state in [FlAppState.FAILED, FlAppState.SHUTDOWN] - - def is_flapp_complete(self): - # TODO: make the getter more efficient - flapp = FlApp.from_json(self.get_flapp_details()['flapp']) - return flapp.state == FlAppState.COMPLETED - - def get_complete_at(self): - # TODO: make the getter more efficient - flapp = FlApp.from_json(self.get_flapp_details()['flapp']) - return flapp.completed_at - - def stop(self): - if self.state not in [JobState.WAITING, JobState.STARTED, - JobState.COMPLETED, JobState.FAILED]: - logging.warning('illegal job state, name: %s, state: %s', - self.name, self.state) - return - if self.state == JobState.STARTED: - self._set_snapshot_flapp() - k8s_client.delete_flapp(self.name) - # state change: - # WAITING -> NEW - # STARTED -> STOPPED - # COMPLETED/FAILED unchanged - if self.state == JobState.STARTED: - self.state = JobState.STOPPED - if self.state == JobState.WAITING: - self.state = JobState.NEW - - def schedule(self): - # COMPLETED/FAILED Job State can be scheduled since stop action - # will not change the state of completed or failed job - assert self.state in [JobState.NEW, JobState.STOPPED, - JobState.COMPLETED, JobState.FAILED] - self.pods_snapshot = None - self.flapp_snapshot = None - self.state = JobState.WAITING - - def start(self): - assert self.state == JobState.WAITING - self.state = JobState.STARTED - - def complete(self): - assert self.state == JobState.STARTED, 'Job State is not STARTED' - self._set_snapshot_flapp() - k8s_client.delete_flapp(self.name) - self.state = JobState.COMPLETED - - def fail(self): - assert self.state == JobState.STARTED, 'Job State is not STARTED' - self._set_snapshot_flapp() - k8s_client.delete_flapp(self.name) - self.state = JobState.FAILED + # TODO(xiangyuxuan.prs): Remove this func and get_completed_at from model to service. + def get_k8s_app(self) -> K8sApp: + snapshot = None + if self.state != JobState.STARTED: + snapshot = self.snapshot or '{}' + snapshot = json.loads(snapshot) + return self.build_crd_service().get_k8s_app(snapshot) + + def build_crd_service(self) -> CrdService: + if self.crd_kind is not None: + return CrdService(self.crd_kind, self.get_crd_meta().api_version, self.name) + # TODO(xiangyuxuan.prs): Adapt to old data, remove in the future. + if self.job_type in [JobType.TRANSFORMER]: + return CrdService('SparkApplication', 'sparkoperator.k8s.io/v1beta2', self.name) + return CrdService('FLApp', 'fedlearner.k8s.io/v1alpha1', self.name) + + def is_training_job(self): + return self.job_type in [JobType.NN_MODEL_TRANINING, JobType.TREE_MODEL_TRAINING] + + def get_complete_at(self) -> Optional[int]: + crd_obj = self.get_k8s_app() + return crd_obj.completed_at + + def get_start_at(self) -> int: + crd_obj = self.get_k8s_app() + return crd_obj.creation_timestamp + + def get_crd_meta(self) -> CrdMetaData: + crd_meta_obj = CrdMetaData() + if self.crd_meta is not None: + return text_format.Parse(self.crd_meta, crd_meta_obj) + return crd_meta_obj + + def set_crd_meta(self, crd_meta: Optional[CrdMetaData] = None): + if crd_meta is None: + crd_meta = CrdMetaData() + self.crd_meta = text_format.MessageToString(crd_meta) + + def get_error_message_with_pods(self) -> JobErrorMessage: + failed_pods_msg = {} + for pod in self.get_k8s_app().pods: + if pod.state != PodState.FAILED: + continue + pod_error_msg = pod.get_message(include_private_info=True).summary + if pod_error_msg: + failed_pods_msg[pod.name] = pod_error_msg + return JobErrorMessage(app=self.error_message, pods=failed_pods_msg) + + def to_proto(self) -> JobPb: + return JobPb(id=self.id, + name=self.name, + job_type=self.job_type.value, + state=self.state.name, + is_disabled=self.is_disabled, + workflow_id=self.workflow_id, + project_id=self.project_id, + snapshot=self.snapshot, + error_message=self.get_error_message_with_pods(), + crd_meta=self.get_crd_meta(), + crd_kind=self.crd_kind, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + complete_at=self.get_complete_at(), + start_at=self.get_start_at()) class JobDependency(db.Model): __tablename__ = 'job_dependency_v2' - __table_args__ = (Index('idx_src_job_id', 'src_job_id'), - Index('idx_dst_job_id', 'dst_job_id'), { - 'comment': 'record job dependencies', - 'mysql_engine': 'innodb', - 'mysql_charset': 'utf8mb4', - }) - id = db.Column(db.Integer, - primary_key=True, - autoincrement=True, - comment='id') + __table_args__ = (Index('idx_src_job_id', 'src_job_id'), Index('idx_dst_job_id', 'dst_job_id'), { + 'comment': 'record job dependencies', + 'mysql_engine': 'innodb', + 'mysql_charset': 'utf8mb4', + }) + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='id') src_job_id = db.Column(db.Integer, comment='src job id') dst_job_id = db.Column(db.Integer, comment='dst job id') dep_index = db.Column(db.Integer, comment='dep index') diff --git a/web_console_v2/api/fedlearner_webconsole/job/service.py b/web_console_v2/api/fedlearner_webconsole/job/service.py index fc015dfb6..cf351c7dd 100644 --- a/web_console_v2/api/fedlearner_webconsole/job/service.py +++ b/web_console_v2/api/fedlearner_webconsole/job/service.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,13 +13,27 @@ # limitations under the License. # coding: utf-8 - +import datetime +import json import logging +from typing import List + from sqlalchemy.orm.session import Session -from fedlearner_webconsole.rpc.client import RpcClient -from fedlearner_webconsole.job.models import Job, JobDependency, JobState -from fedlearner_webconsole.proto import common_pb2 -from fedlearner_webconsole.utils.metrics import emit_counter + +from fedlearner_webconsole.proto.job_pb2 import CrdMetaData, PodPb +from fedlearner_webconsole.proto.workflow_definition_pb2 import JobDefinition +from fedlearner_webconsole.job.models import Job, JobDependency, \ + JobState +from fedlearner_webconsole.utils.metrics import emit_store +from fedlearner_webconsole.utils.pp_datetime import to_timestamp +from fedlearner_webconsole.utils.pp_yaml import compile_yaml_template +from fedlearner_webconsole.job.utils import DurationState, emit_job_duration_store + + +def serialize_to_json(o): + if isinstance(o, (datetime.date, datetime.datetime)): + return o.isoformat() + return str(o) class JobService: @@ -28,42 +42,91 @@ def __init__(self, session: Session): self._session = session def is_ready(self, job: Job) -> bool: - deps = self._session.query(JobDependency).filter_by( - dst_job_id=job.id).all() + deps = self._session.query(JobDependency).filter_by(dst_job_id=job.id).all() for dep in deps: src_job = self._session.query(Job).get(dep.src_job_id) - assert src_job is not None, 'Job {} not found'.format( - dep.src_job_id) + assert src_job is not None, f'Job {dep.src_job_id} not found' if not src_job.state == JobState.COMPLETED: return False return True - @staticmethod - def is_peer_ready(job: Job) -> bool: - project_config = job.project.get_config() - for party in project_config.participants: - client = RpcClient(project_config, party) - resp = client.check_job_ready(job.name) - if resp.status.code != common_pb2.STATUS_SUCCESS: - emit_counter('check_peer_ready_failed', 1) - return True - if not resp.is_ready: - return False - return True - - def update_running_state(self, job_name): + def update_running_state(self, job_name: str) -> JobState: job = self._session.query(Job).filter_by(name=job_name).first() if job is None: - emit_counter('[JobService]job_not_found', 1) - return + emit_store('job.service.update_running_state_error', + 1, + tags={ + 'job_name': job_name, + 'reason': 'job_not_found' + }) + return None if not job.state == JobState.STARTED: - emit_counter('[JobService]wrong_job_state', 1) - return - if job.is_flapp_complete(): - job.complete() - logging.debug('[JobService]change job %s state to %s', - job.name, JobState(job.state)) - elif job.is_flapp_failed(): - job.fail() - logging.debug('[JobService]change job %s state to %s', - job.name, JobState(job.state)) + emit_store('job.service.update_running_state_error', + 1, + tags={ + 'job_name': job_name, + 'reason': 'wrong_job_state' + }) + return job.state + if job.get_k8s_app().is_completed: + self.complete(job) + logging.debug('[JobService]change job %s state to %s', job.name, JobState(job.state)) + elif job.get_k8s_app().is_failed: + self.fail(job) + logging.debug('[JobService]change job %s state to %s', job.name, JobState(job.state)) + return job.state + + @staticmethod + def get_pods(job: Job, include_private_info=True) -> List[PodPb]: + crd_obj = job.get_k8s_app() + if crd_obj: + return [pod.to_proto(include_private_info) for pod in crd_obj.pods] + return [] + + @staticmethod + def set_config_and_crd_info(job: Job, proto: JobDefinition): + job.set_config(proto) + yaml = {} + try: + yaml = compile_yaml_template(job.get_config().yaml_template, post_processors=[], ignore_variables=True) + except Exception as e: # pylint: disable=broad-except + # Don't raise exception because of old templates, default None will use FLApp. + logging.error( + f'Failed format yaml for job {job.name} when try to get the kind and api_version. msg: {str(e)}') + kind = yaml.get('kind', None) + api_version = yaml.get('apiVersion', None) + job.crd_kind = kind + job.set_crd_meta(CrdMetaData(api_version=api_version)) + + @staticmethod + def complete(job: Job): + assert job.state == JobState.STARTED, 'Job State is not STARTED' + JobService.set_status_to_snapshot(job) + job.build_crd_service().delete_app() + job.state = JobState.COMPLETED + emit_job_duration_store(duration=job.get_complete_at() - to_timestamp(job.created_at), + job_name=job.name, + state=DurationState.COMPLETED) + + @staticmethod + def fail(job: Job): + assert job.state == JobState.STARTED, 'Job State is not STARTED' + JobService.set_status_to_snapshot(job) + job.build_crd_service().delete_app() + job.state = JobState.FAILED + job.error_message = job.get_k8s_app().error_message + emit_job_duration_store(duration=job.get_complete_at() - to_timestamp(job.created_at), + job_name=job.name, + state=DurationState.FAILURE) + + @staticmethod + def set_status_to_snapshot(job: Job): + app = job.build_crd_service().get_k8s_app_cache() + job.snapshot = json.dumps(app, default=serialize_to_json) + + @staticmethod + def get_job_yaml(job: Job) -> str: + # Can't query from k8s api server when job is not started. + if job.state != JobState.STARTED: + return job.snapshot or '' + return json.dumps(job.build_crd_service().get_k8s_app_cache(), default=serialize_to_json) diff --git a/web_console_v2/api/fedlearner_webconsole/job/yaml_formatter.py b/web_console_v2/api/fedlearner_webconsole/job/yaml_formatter.py index bac0d80cc..80a4dbed2 100644 --- a/web_console_v2/api/fedlearner_webconsole/job/yaml_formatter.py +++ b/web_console_v2/api/fedlearner_webconsole/job/yaml_formatter.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,100 +13,88 @@ # limitations under the License. # coding: utf-8 +import base64 import json import tarfile from io import BytesIO -import base64 -from string import Template -from flatten_dict import flatten -from fedlearner_webconsole.utils.system_envs import get_system_envs -from fedlearner_webconsole.proto import common_pb2 - - -class _YamlTemplate(Template): - delimiter = '$' - # Which placeholders in the template should be interpreted - idpattern = r'[a-zA-Z_\-\[0-9\]]+(\.[a-zA-Z_\-\[0-9\]]+)*' +from fedlearner_webconsole.k8s.models import CrdKind +from fedlearner_webconsole.rpc.client import gen_egress_authority +from fedlearner_webconsole.proto import common_pb2 +from fedlearner_webconsole.utils.const import DEFAULT_OWNER_FOR_JOB_WITHOUT_WORKFLOW +from fedlearner_webconsole.utils.proto import to_dict +from fedlearner_webconsole.utils.pp_yaml import compile_yaml_template, \ + add_username_in_label, GenerateDictService -def format_yaml(yaml, **kwargs): - """Formats a yaml template. - - Example usage: - format_yaml('{"abc": ${x.y}}', x={'y': 123}) - output should be '{"abc": 123}' - """ - template = _YamlTemplate(yaml) - try: - return template.substitute(flatten(kwargs or {}, - reducer='dot')) - except KeyError as e: - raise RuntimeError( - 'Unknown placeholder: {}'.format(e.args[0])) from e +CODE_TAR_FOLDER = 'code_tar' +CODE_TAR_FILE_NAME = 'code_tar.tar.gz' def make_variables_dict(variables): - var_dict = { - var.name: ( - code_dict_encode(json.loads(var.value)) - if var.value_type == common_pb2.Variable.ValueType.CODE \ - else var.value) - for var in variables - } - return var_dict - - -def generate_system_dict(): - return {'basic_envs': get_system_envs()} - - -def generate_project_dict(proj): - project = proj.to_dict() - project['variables'] = make_variables_dict( - proj.get_config().variables) - participants = project['config']['participants'] - for index, participant in enumerate(participants): - project[f'participants[{index}]'] = {} - project[f'participants[{index}]']['egress_domain'] = \ - participant['domain_name'] - project[f'participants[{index}]']['egress_host'] = \ - participant['grpc_spec']['authority'] - return project - - -def generate_workflow_dict(wf): - workflow = wf.to_dict() - workflow['variables'] = make_variables_dict( - wf.get_config().variables) - workflow['jobs'] = {} - for j in wf.get_jobs(): - variables = make_variables_dict(j.get_config().variables) - j_dic = j.to_dict() - j_dic['variables'] = variables - workflow['jobs'][j.get_config().name] = j_dic - return workflow - - -def generate_self_dict(j): - job = j.to_dict() - job['variables'] = make_variables_dict( - j.get_config().variables - ) - return job + var_dict = {} + for var in variables: + typed_value = to_dict(var.typed_value) + if var.value_type == common_pb2.Variable.CODE: + # if use or, then {} will be ignored. + var_dict[var.name] = code_dict_encode(typed_value if typed_value is not None else json.loads(var.value)) + else: + var_dict[var.name] = typed_value if typed_value is not None else var.value + return var_dict -def generate_job_run_yaml(job): - yaml = format_yaml(job.get_config().yaml_template, - workflow=generate_workflow_dict(job.workflow), - project=generate_project_dict(job.project), - system=generate_system_dict(), - self=generate_self_dict(job)) - try: - loaded = json.loads(yaml) - except Exception as e: # pylint: disable=broad-except - raise ValueError(f'Invalid json {repr(e)}: {yaml}') - return loaded +class YamlFormatterService: + + def __init__(self, session): + self._session = session + + @staticmethod + def generate_project_dict(proj): + project = to_dict(proj.to_proto()) + variables = proj.get_variables() + project['variables'] = make_variables_dict(variables) + project['participants'] = [] + for index, participant in enumerate(proj.participants): + # TODO(xiangyuxuan.prs): remove keys such as participants[0] in future. + project[f'participants[{index}]'] = {} + project[f'participants[{index}]']['egress_domain'] = \ + participant.domain_name + project[f'participants[{index}]']['egress_host'] = gen_egress_authority(participant.domain_name) + project['participants'].append(project[f'participants[{index}]']) + return project + + def generate_workflow_dict(self, wf: 'Workflow'): + workflow = wf.to_dict() + workflow['variables'] = make_variables_dict(wf.get_config().variables) + workflow['jobs'] = {} + jobs = wf.get_jobs(self._session) + for j in jobs: + variables = make_variables_dict(j.get_config().variables) + j_dic = j.to_dict() + j_dic['variables'] = variables + workflow['jobs'][j.get_config().name] = j_dic + return workflow + + @staticmethod + def generate_self_dict(j: 'Job'): + job = j.to_dict() + job['variables'] = make_variables_dict(j.get_config().variables) + return job + + def generate_job_run_yaml(self, job: 'Job') -> dict: + result_dict = compile_yaml_template(job.get_config().yaml_template, + use_old_formater=job.crd_kind is None or + job.crd_kind == CrdKind.FLAPP.value, + post_processors=[ + lambda loaded_json: add_username_in_label( + loaded_json, job.workflow.creator + if job.workflow else DEFAULT_OWNER_FOR_JOB_WITHOUT_WORKFLOW) + ], + workflow=job.workflow and self.generate_workflow_dict(job.workflow), + project=self.generate_project_dict(job.project), + system=GenerateDictService(self._session).generate_system_dict(), + self=self.generate_self_dict(job)) + return result_dict def code_dict_encode(data_dict): diff --git a/web_console_v2/api/fedlearner_webconsole/k8s/models.py b/web_console_v2/api/fedlearner_webconsole/k8s/models.py index 458f39f81..23969acff 100644 --- a/web_console_v2/api/fedlearner_webconsole/k8s/models.py +++ b/web_console_v2/api/fedlearner_webconsole/k8s/models.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. @@ -17,28 +17,17 @@ from abc import ABCMeta, abstractmethod from datetime import datetime, timezone from enum import Enum, unique -from typing import Optional, List +from typing import Optional, List, Dict, NamedTuple +from google.protobuf.json_format import ParseDict +from kubernetes.client import V1ObjectMeta +from fedlearner_webconsole.proto.job_pb2 import PodPb +from fedlearner_webconsole.proto.k8s_pb2 import Condition +from fedlearner_webconsole.utils.pp_datetime import to_timestamp -# Please keep the value consistent with operator's definition -@unique -class PodType(Enum): - UNKNOWN = 'UNKNOWN' - # Parameter server - PS = 'PS' - # Master worker - MASTER = 'MASTER' - WORKER = 'WORKER' - - @staticmethod - def from_value(value: str) -> 'PodType': - try: - if isinstance(value, str): - value = value.upper() - return PodType(value) - except ValueError: - logging.error(f'Unexpected value of PodType: {value}') - return PodType.UNKNOWN +class PodMessage(NamedTuple): + summary: Optional[str] + details: str @unique @@ -64,16 +53,30 @@ def from_value(value: str) -> 'PodState': return PodState.UNKNOWN +class CrdKind(Enum): + FLAPP = 'FLApp' + SPARKAPPLICATION = 'SparkApplication' + FEDAPP = 'FedApp' + UNKNOWN = 'Unknown' + + @staticmethod + def from_value(value: str) -> 'CrdKind': + try: + return CrdKind(value) + except ValueError: + return CrdKind.UNKNOWN + + class MessageProvider(metaclass=ABCMeta): + @abstractmethod def get_message(self, private: bool = False) -> Optional[str]: pass class ContainerState(MessageProvider): - def __init__(self, state: str, - message: Optional[str] = None, - reason: Optional[str] = None): + + def __init__(self, state: str, message: Optional[str] = None, reason: Optional[str] = None): self.state = state self.message = message self.reason = reason @@ -95,9 +98,8 @@ def __eq__(self, other): class PodCondition(MessageProvider): - def __init__(self, cond_type: str, - message: Optional[str] = None, - reason: Optional[str] = None): + + def __init__(self, cond_type: str, message: Optional[str] = None, reason: Optional[str] = None): self.cond_type = cond_type self.message = message self.reason = reason @@ -119,19 +121,24 @@ def __eq__(self, other): class Pod(object): + def __init__(self, name: str, state: PodState, - pod_type: PodType, + pod_type: str = 'UNKNOWN', pod_ip: str = None, container_states: List[ContainerState] = None, - pod_conditions: List[PodCondition] = None): + pod_conditions: List[PodCondition] = None, + creation_timestamp: int = None, + status_message: str = None): self.name = name self.state = state or PodState.UNKNOWN self.pod_type = pod_type self.pod_ip = pod_ip self.container_states = container_states or [] self.pod_conditions = pod_conditions or [] + self.creation_timestamp = creation_timestamp or 0 + self.status_message = status_message or '' def __eq__(self, other): if not isinstance(other, Pod): @@ -149,27 +156,32 @@ def __eq__(self, other): return self.name == other.name and \ self.state == other.state and \ self.pod_type == other.pod_type and \ - self.pod_ip == other.pod_ip + self.pod_ip == other.pod_ip and \ + self.creation_timestamp == self.creation_timestamp - def to_dict(self, include_private_info: bool = False): - # TODO: to reuse to_dict from db.py - messages = [] + def to_proto(self, include_private_info: bool = False) -> PodPb: + + return PodPb(name=self.name, + pod_type=self.pod_type, + state=self.state.name, + pod_ip=self.pod_ip, + creation_timestamp=self.creation_timestamp, + message=self.get_message(include_private_info).details) + + def get_message(self, include_private_info: bool = False) -> PodMessage: + summary = None + messages = [self.status_message] if self.status_message else [] for container_state in self.container_states: message = container_state.get_message(include_private_info) if message is not None: messages.append(message) + if container_state.state == 'terminated': + summary = message for pod_condition in self.pod_conditions: message = pod_condition.get_message(include_private_info) if message is not None: messages.append(message) - - return { - 'name': self.name, - 'pod_type': self.pod_type.name, - 'state': self.state.name, - 'pod_ip': self.pod_ip, - 'message': ', '.join(messages) - } + return PodMessage(summary=summary, details=', '.join(messages)) @classmethod def from_json(cls, p: dict) -> 'Pod': @@ -179,32 +191,83 @@ def from_json(cls, p: dict) -> 'Pod': master/v1.6.5-standalone/pod.json""" container_states: List[ContainerState] = [] pod_conditions: List[PodCondition] = [] - if 'containerStatuses' in p['status'] and \ - isinstance(p['status']['containerStatuses'], list) and \ - len(p['status']['containerStatuses']) > 0: + if 'container_statuses' in p['status'] and \ + isinstance(p['status']['container_statuses'], list) and \ + len(p['status']['container_statuses']) > 0: for state, detail in \ - p['status']['containerStatuses'][0]['state'].items(): - container_states.append(ContainerState( - state=state, - message=detail.get('message'), - reason=detail.get('reason') - )) + p['status']['container_statuses'][0]['state'].items(): + # detail may be None, so add a conditional judgement('and') + # short-circuit operation + container_states.append( + ContainerState(state=state, + message=detail and detail.get('message'), + reason=detail and detail.get('reason'))) if 'conditions' in p['status'] and \ isinstance(p['status']['conditions'], list): for cond in p['status']['conditions']: - pod_conditions.append(PodCondition( - cond_type=cond['type'], - message=cond.get('message'), - reason=cond.get('reason') - )) - return cls( - name=p['metadata']['name'], - pod_type=PodType.from_value( - p['metadata']['labels']['fl-replica-type']), - state=PodState.from_value(p['status']['phase']), - pod_ip=p['status'].get('pod_ip'), - container_states=container_states, - pod_conditions=pod_conditions) + pod_conditions.append( + PodCondition(cond_type=cond['type'], message=cond.get('message'), reason=cond.get('reason'))) + + return cls(name=p['metadata']['name'], + pod_type=get_pod_type(p), + state=PodState.from_value(p['status']['phase']), + pod_ip=p['status'].get('pod_ip'), + container_states=container_states, + pod_conditions=pod_conditions, + creation_timestamp=to_timestamp(p['metadata']['creation_timestamp']), + status_message=p['status'].get('message')) + + +def get_pod_type(pod: dict) -> str: + labels = pod['metadata']['labels'] + # SparkApplication -> pod.metadata.labels.spark-role + # FlApp -> pod.metadata.labels.fl-replica-type + pod_type = labels.get('fl-replica-type', None) or labels.get('spark-role', 'UNKNOWN') + return pod_type.upper() + + +def get_creation_timestamp_from_k8s_app(app: dict) -> int: + if 'metadata' in app and 'creationTimestamp' in app['metadata']: + return to_timestamp(app['metadata']['creationTimestamp']) + return 0 + + +class K8sApp(metaclass=ABCMeta): + + @classmethod + @abstractmethod + def from_json(cls, app_detail: dict): + pass + + @property + @abstractmethod + def is_completed(self) -> bool: + pass + + @property + @abstractmethod + def is_failed(self) -> bool: + pass + + @property + @abstractmethod + def completed_at(self) -> int: + pass + + @property + @abstractmethod + def pods(self) -> List[Pod]: + pass + + @property + @abstractmethod + def error_message(self) -> Optional[str]: + pass + + @property + @abstractmethod + def creation_timestamp(self) -> int: + pass # Please keep the value consistent with operator's definition @@ -229,14 +292,19 @@ def from_value(value: str) -> 'FlAppState': return FlAppState.UNKNOWN -class FlApp(object): +class FlApp(K8sApp): + def __init__(self, state: FlAppState = FlAppState.UNKNOWN, pods: Optional[List[Pod]] = None, - completed_at: Optional[int] = None): + completed_at: Optional[int] = None, + creation_timestamp: Optional[int] = None): self.state = state - self.pods = pods or [] - self.completed_at = completed_at + self._pods = pods or [] + self._completed_at = completed_at + self._is_failed = self.state == FlAppState.FAILED + self._is_completed = self.state == FlAppState.COMPLETED + self._creation_timestamp = creation_timestamp def __eq__(self, other): if not isinstance(other, FlApp): @@ -250,7 +318,8 @@ def __eq__(self, other): self.completed_at == other.completed_at @classmethod - def from_json(cls, flapp: dict) -> 'FlApp': + def from_json(cls, app_detail: dict) -> 'FlApp': + flapp = app_detail.get('app', None) if flapp is None \ or 'status' not in flapp \ or not isinstance(flapp['status'], dict): @@ -261,24 +330,277 @@ def from_json(cls, flapp: dict) -> 'FlApp': # Parses pod related info replicas = flapp['status'].get('flReplicaStatus', {}) for pod_type in replicas: - for state in ['failed', 'succeeded']: + for state in ['active', 'failed', 'succeeded']: for pod_name in replicas[pod_type].get(state, {}): + if state == 'active': + pod_state = PodState.RUNNING if state == 'failed': pod_state = PodState.FAILED_AND_FREED - else: + if state == 'succeeded': pod_state = PodState.SUCCEEDED_AND_FREED - pods.append(Pod( - name=pod_name, - pod_type=PodType.from_value(pod_type), - state=pod_state)) + pods.append(Pod(name=pod_name, pod_type=pod_type.upper(), state=pod_state)) state = flapp['status'].get('appState') if flapp['status'].get('completionTime', None): # Completion time is a iso formatted datetime in UTC timezone - completed_at = int(datetime.strptime( - flapp['status']['completionTime'], - '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc) - .timestamp()) - + completed_at = int( + datetime.strptime(flapp['status']['completionTime'], + '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc).timestamp()) + + name_to_pod = get_pod_dict_from_detail(app_detail.get('pods', {})) + for pod in pods: + # Only master pod and ps pod use state in flapp, + # because they would not immediately exit when flapp is deleted. + if pod.name not in name_to_pod: + name_to_pod[pod.name] = pod + elif pod.pod_type in ['MASTER', 'PS']: + name_to_pod[pod.name].state = pod.state + + pods = list(name_to_pod.values()) return cls(state=FlAppState.from_value(state), pods=pods, - completed_at=completed_at) + completed_at=completed_at, + creation_timestamp=get_creation_timestamp_from_k8s_app(flapp)) + + @property + def is_completed(self) -> bool: + return self._is_completed + + @property + def is_failed(self) -> bool: + return self._is_failed + + @property + def completed_at(self) -> int: + return self._completed_at or 0 + + @property + def pods(self) -> List[Pod]: + return self._pods + + @property + def error_message(self) -> Optional[str]: + return None + + @property + def creation_timestamp(self) -> int: + return self._creation_timestamp or 0 + + +@unique +class SparkAppState(Enum): + # state: https://github.com/GoogleCloudPlatform/spark-on-k8s-operator/ \ + # blob/075e5383e4678ddd70d7f3fdd71904aa3c9113c2 \ + # /pkg/apis/sparkoperator.k8s.io/v1beta2/types.go#L332 + + # core state transition: SUBMITTED -> RUNNING -> COMPLETED/FAILED + NEW = '' + SUBMITTED = 'SUBMITTED' + RUNNING = 'RUNNING' + COMPLETED = 'COMPLETED' + FAILED = 'FAILED' + SUBMISSION_FAILED = 'SUBMISSION_FAILED' + PEDNING_RERUN = 'PENDING_RERUN' + INVALIDATING = 'INVALIDATING' + SUCCEEDING = 'SUCCEEDING' + FAILING = 'FAILING' + UNKNOWN = 'UNKNOWN' + + @staticmethod + def from_value(value: str) -> 'SparkAppState': + try: + return SparkAppState(value) + except ValueError: + logging.error(f'Unexpected value of FlAppState: {value}') + return SparkAppState.UNKNOWN + + +class SparkApp(K8sApp): + + def __init__(self, + pods: List[Pod], + state: SparkAppState = SparkAppState.UNKNOWN, + completed_at: Optional[int] = None, + err_message: Optional[str] = None, + creation_timestamp: Optional[int] = None): + self.state = state + self._completed_at = completed_at + self._is_failed = self.state in [SparkAppState.FAILED] + self._is_completed = self.state in [SparkAppState.COMPLETED] + self._pods = pods + self._error_message = err_message + self._creation_timestamp = creation_timestamp + + def __eq__(self, other): + if not isinstance(other, SparkApp): + return False + return self.state == other.state and \ + self.completed_at == other.completed_at + + @classmethod + def from_json(cls, app_detail: dict) -> 'SparkApp': + sparkapp = app_detail.get('app', None) + if sparkapp is None \ + or 'status' not in sparkapp \ + or not isinstance(sparkapp['status'], dict): + return cls(pods=[]) + + status = sparkapp['status'] + application_state = status.get('applicationState', {}) + state = application_state.get('state', SparkAppState.UNKNOWN) + completed_at: Optional[int] = None + termination_time = status.get('terminationTime', None) + if termination_time: + # Completion time is a iso formatted datetime in UTC timezone + completed_at = int( + datetime.strptime(termination_time, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc).timestamp()) + pods = list(get_pod_dict_from_detail(app_detail.get('pods', {})).values()) + err_message = application_state.get('errorMessage', None) + return cls(state=SparkAppState.from_value(state), + completed_at=completed_at, + pods=pods, + err_message=err_message, + creation_timestamp=get_creation_timestamp_from_k8s_app(sparkapp)) + + @property + def is_completed(self) -> bool: + return self._is_completed + + @property + def is_failed(self) -> bool: + return self._is_failed + + @property + def completed_at(self) -> int: + return self._completed_at or 0 + + @property + def pods(self) -> List[Pod]: + return self._pods + + @property + def error_message(self) -> Optional[str]: + return self._error_message + + @property + def creation_timestamp(self) -> int: + return self._creation_timestamp or 0 + + +class FedApp(K8sApp): + + def __init__(self, pods: List[Pod], success_condition: Condition, creation_timestamp: Optional[int] = None): + self.success_condition = success_condition + self._pods = pods + self._completed_at = self.success_condition.last_transition_time and to_timestamp( + self.success_condition.last_transition_time) + self._is_failed = self.success_condition.status == Condition.FALSE + self._is_completed = self.success_condition.status == Condition.TRUE + self._creation_timestamp = creation_timestamp + + @classmethod + def from_json(cls, app_detail: dict) -> 'FedApp': + app = app_detail.get('app', None) + if app is None \ + or 'status' not in app \ + or not isinstance(app['status'], dict): + return cls([], Condition()) + + status = app['status'] + success_condition = Condition() + for c in status.get('conditions', []): + c_proto: Condition = ParseDict(c, Condition()) + if c_proto.type == Condition.SUCCEEDED: + success_condition = c_proto + pods = list(get_pod_dict_from_detail(app_detail.get('pods', {})).values()) + return cls(success_condition=success_condition, + pods=pods, + creation_timestamp=get_creation_timestamp_from_k8s_app(app)) + + @property + def is_completed(self) -> bool: + return self._is_completed + + @property + def is_failed(self) -> bool: + return self._is_failed + + @property + def completed_at(self) -> int: + return self._completed_at or 0 + + @property + def pods(self) -> List[Pod]: + return self._pods + + @property + def error_message(self) -> str: + return f'{self.success_condition.reason}: {self.success_condition.message}' + + @property + def creation_timestamp(self) -> int: + return self._creation_timestamp or 0 + + +class UnknownCrd(K8sApp): + + @classmethod + def from_json(cls, app_detail: dict) -> 'UnknownCrd': + return UnknownCrd() + + @property + def is_completed(self) -> bool: + return False + + @property + def is_failed(self) -> bool: + return False + + @property + def completed_at(self) -> int: + return 0 + + @property + def pods(self) -> List[Pod]: + return [] + + @property + def error_message(self) -> Optional[str]: + return None + + @property + def creation_timestamp(self) -> Optional[str]: + return None + + +def get_pod_dict_from_detail(pod_detail: dict) -> Dict[str, Pod]: + """ + Generate name to Pod dict from pod json detail which got from pod cache. + """ + name_to_pod = {} + pods_json = pod_detail.get('items', []) + for p in pods_json: + pod = Pod.from_json(p) + name_to_pod[pod.name] = pod + return name_to_pod + + +def get_app_name_from_metadata(metadata: V1ObjectMeta) -> Optional[str]: + """Extracts the CR app name from the metadata. + + Basically the metadata is from k8s watch event, we only care about the events + related with CRs, so we will check owner references.""" + owner_refs = metadata.owner_references or [] + if not owner_refs: + return None + + # Spark app uses labels to get app name instead of owner references, + # because executors' owner reference will be driver, not the spark app. + labels = metadata.labels or {} + sparkapp_name = labels.get('sparkoperator.k8s.io/app-name', None) + if sparkapp_name: + return sparkapp_name + + owner = owner_refs[0] + if CrdKind.from_value(owner.kind) == CrdKind.UNKNOWN: + return None + return owner.name diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/apis.py b/web_console_v2/api/fedlearner_webconsole/mmgr/apis.py deleted file mode 100644 index cbae04a07..000000000 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/apis.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -from http import HTTPStatus -from flask import request -from flask_restful import Resource -from fedlearner_webconsole.db import db_handler -from fedlearner_webconsole.exceptions import NotFoundException -from fedlearner_webconsole.mmgr.models import Model, ModelType, ModelGroup -from fedlearner_webconsole.mmgr.service import ModelService -from fedlearner_webconsole.utils.decorators import jwt_required - - -class ModelApi(Resource): - @jwt_required() - def get(self, model_id): - detail_level = request.args.get('detail_level', '') - with db_handler.session_scope() as session: - model_json = ModelService(session).query(model_id, detail_level) - if not model_json: - raise NotFoundException( - f'Failed to find model: {model_id}') - return {'data': model_json}, HTTPStatus.OK - - @jwt_required() - def put(self, model_id): - with db_handler.session_scope() as session: - model = session.query(Model).filter_by(id=model_id).one_or_none() - if not model: - raise NotFoundException( - f'Failed to find model: {model_id}') - model.extra = request.args.get('extra', model.extra) - session.commit() - return {'data': model.to_dict()}, HTTPStatus.OK - - @jwt_required() - def delete(self, model_id): - with db_handler.session_scope() as session: - model = ModelService(session).drop(model_id) - if not model: - raise NotFoundException( - f'Failed to find model: {model_id}') - return {'data': model.to_dict()}, HTTPStatus.OK - - -class ModelListApi(Resource): - @jwt_required() - def get(self): - detail_level = request.args.get('detail_level', '') - # TODO serialized query may incur performance penalty - with db_handler.session_scope() as session: - model_list = [ - ModelService(session).query(m.id, detail_level) - for m in Model.query.filter( - Model.type.in_([ - ModelType.NN_MODEL.value, ModelType.TREE_MODEL.value - ])).all() - ] - return {'data': model_list}, HTTPStatus.OK - - -class GroupListApi(Resource): - @jwt_required() - def get(self): - group_list = [o.to_dict() for o in ModelGroup.query.all()] - return {'data': group_list}, HTTPStatus.OK - - @jwt_required() - def post(self): - group = ModelGroup() - - group.name = request.args.get('name', group.name) - group.extra = request.args.get('extra', group.extra) - with db_handler.session_scope() as session: - session.add(group) - session.commit() - - return {'data': group.to_dict()}, HTTPStatus.OK - - -class GroupApi(Resource): - @jwt_required() - def patch(self, group_id): - group = ModelGroup.query.filter_by(id=group_id).one_or_none() - if not group: - raise NotFoundException( - f'Failed to find group: {group_id}') - - group.name = request.args.get('name', group.name) - group.extra = request.args.get('extra', group.extra) - with db_handler.session_scope() as session: - session.add(group) - session.commit() - - return {'data': group.to_dict()}, HTTPStatus.OK - - -def initialize_mmgr_apis(api): - api.add_resource(ModelListApi, '/models') - api.add_resource(ModelApi, '/models/') - - api.add_resource(GroupListApi, '/model_groups') - api.add_resource(GroupApi, '/model_groups/') diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/models.py b/web_console_v2/api/fedlearner_webconsole/mmgr/models.py index 6db0ea885..4eda2e302 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/models.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/models.py @@ -1,108 +1,648 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # -# Licensed under the Apache License, Version 2.0 (the 'License'); +# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, +# distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - -# coding: utf-8 +# import enum +import logging +from typing import Optional +from google.protobuf import text_format from sqlalchemy.sql import func -from sqlalchemy.orm import remote, foreign from sqlalchemy.sql.schema import Index, UniqueConstraint -from fedlearner_webconsole.utils.mixins import to_dict_mixin from fedlearner_webconsole.db import db, default_table_args -from fedlearner_webconsole.job.models import Job +from fedlearner_webconsole.algorithm.models import Algorithm, AlgorithmType +from fedlearner_webconsole.dataset.models import Dataset +from fedlearner_webconsole.mmgr.utils import get_job_path, get_exported_model_path, get_checkpoint_path, \ + get_output_path +from fedlearner_webconsole.project.models import Project +from fedlearner_webconsole.utils.base_model import auth_model +from fedlearner_webconsole.utils.base_model.softdelete_model import SoftDeleteModel +from fedlearner_webconsole.utils.base_model.review_ticket_and_auth_model import ReviewTicketAndAuthModel +from fedlearner_webconsole.utils.base_model.review_ticket_model import TicketStatus +from fedlearner_webconsole.utils.pp_datetime import to_timestamp +from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition +from fedlearner_webconsole.workflow.models import Workflow, WorkflowExternalState +from fedlearner_webconsole.proto.mmgr_pb2 import ModelJobPb, ModelJobGroupPb, ModelJobRef, ModelJobGroupRef, ModelPb, \ + ModelJobGlobalConfig, AlgorithmProjectList class ModelType(enum.Enum): - NN_MODEL = 0 - NN_EVALUATION = 1 + UNSPECIFIED = 0 + NN_MODEL = 1 TREE_MODEL = 2 - TREE_EVALUATION = 3 -class ModelState(enum.Enum): - NEW = -1 # before workflow has synced both party - COMMITTING = 0 # (transient) after workflow has synced both party, before committing to k8s - COMMITTED = 1 # after committed to k8s but before running - WAITING = 2 # k8s is queueing the related job(s) - RUNNING = 3 # k8s is running the related job(s) - PAUSED = 4 # related workflow has been paused by end-user - SUCCEEDED = 5 - FAILED = 6 - # DROPPING = 7 # (transient) removing model and its related resources - DROPPED = 8 # model has been removed +class ModelJobType(enum.Enum): + UNSPECIFIED = 0 + NN_TRAINING = 1 + NN_EVALUATION = 2 + NN_PREDICTION = 3 + TREE_TRAINING = 4 + TREE_EVALUATION = 5 + TREE_PREDICTION = 6 + TRAINING = 7 + EVALUATION = 8 + PREDICTION = 9 -# TODO transaction -@to_dict_mixin() -class Model(db.Model): - __tablename__ = 'models_v2' - __table_args__ = (Index('idx_job_name', 'job_name'), - UniqueConstraint('job_name', name='uniq_job_name'), - default_table_args('model')) - - id = db.Column(db.Integer, primary_key=True, comment='id') - name = db.Column(db.String(255), - comment='name') # can be modified by end-user - version = db.Column(db.Integer, default=0, comment='version') - type = db.Column(db.Integer, comment='type') - state = db.Column(db.Integer, comment='state') +class ModelJobRole(enum.Enum): + PARTICIPANT = 0 + COORDINATOR = 1 + + +class ModelJobStatus(enum.Enum): + PENDING = 'PENDING' # all model jobs are created, the local algorithm files and the local workflow are pending + CONFIGURED = 'CONFIGURED' # the local algorithm files are available and the local workflow is created + ERROR = 'ERROR' # error during creating model job + RUNNING = 'RUNNING' + STOPPED = 'STOPPED' + SUCCEEDED = 'SUCCEEDED' + FAILED = 'FAILED' # job failed during running + + +class AuthStatus(enum.Enum): + PENDING = 'PENDING' + AUTHORIZED = 'AUTHORIZED' + + +class GroupCreateStatus(enum.Enum): + PENDING = 'PENDING' + FAILED = 'FAILED' + SUCCEEDED = 'SUCCEEDED' + + +class GroupAuthFrontendStatus(enum.Enum): + TICKET_PENDING = 'TICKET_PENDING' + TICKET_DECLINED = 'TICKET_DECLINED' + CREATE_PENDING = 'CREATE_PENDING' + CREATE_FAILED = 'CREATE_FAILED' + SELF_AUTH_PENDING = 'SELF_AUTH_PENDING' + PART_AUTH_PENDING = 'PART_AUTH_PENDING' + ALL_AUTHORIZED = 'ALL_AUTHORIZED' + + +class GroupAutoUpdateStatus(enum.Enum): + INITIAL = 'INITIAL' + ACTIVE = 'ACTIVE' + STOPPED = 'STOPPED' + + +class ModelJobCreateStatus(enum.Enum): + PENDING = 'PENDING' + FAILED = 'FAILED' + SUCCEEDED = 'SUCCEEDED' + + +class ModelJobAuthFrontendStatus(enum.Enum): + TICKET_PENDING = 'TICKET_PENDING' + TICKET_DECLINED = 'TICKET_DECLINED' + CREATE_PENDING = 'CREATE_PENDING' + CREATE_FAILED = 'CREATE_FAILED' + SELF_AUTH_PENDING = 'SELF_AUTH_PENDING' + PART_AUTH_PENDING = 'PART_AUTH_PENDING' + ALL_AUTHORIZED = 'ALL_AUTHORIZED' + + +class ModelJob(db.Model, SoftDeleteModel, ReviewTicketAndAuthModel): + __tablename__ = 'model_jobs_v2' + __table_args__ = (Index('idx_uuid', + 'uuid'), UniqueConstraint('job_name', + name='uniq_job_name'), default_table_args('model_jobs_v2')) + + id = db.Column(db.Integer, primary_key=True, comment='id', autoincrement=True) + name = db.Column(db.String(255), comment='name') + uuid = db.Column(db.String(64), comment='uuid') + role = db.Column(db.Enum(ModelJobRole, native_enum=False, length=32, create_constraint=False), + default=ModelJobRole.PARTICIPANT, + comment='role') + model_job_type = db.Column(db.Enum(ModelJobType, native_enum=False, length=32, create_constraint=False), + default=ModelJobType.UNSPECIFIED, + comment='type') job_name = db.Column(db.String(255), comment='job_name') - parent_id = db.Column(db.Integer, comment='parent_id') + job_id = db.Column(db.Integer, comment='job id') + # the model id used for prediction or evaluation + model_id = db.Column(db.Integer, comment='model_id') + group_id = db.Column(db.Integer, comment='group_id') + project_id = db.Column(db.Integer, comment='project id') + workflow_id = db.Column(db.Integer, comment='workflow id') + workflow_uuid = db.Column(db.String(64), comment='workflow uuid') + algorithm_type = db.Column(db.Enum(AlgorithmType, native_enum=False, length=32, create_constraint=False), + default=AlgorithmType.UNSPECIFIED, + comment='algorithm type') + algorithm_id = db.Column(db.Integer, comment='algorithm id') + dataset_id = db.Column(db.Integer, comment='dataset id') params = db.Column(db.Text(), comment='params') metrics = db.Column(db.Text(), comment='metrics') - created_at = db.Column(db.DateTime(timezone=True), - comment='created_at', - server_default=func.now()) + extra = db.Column(db.Text(), comment='extra') + favorite = db.Column(db.Boolean, default=False, comment='favorite') + comment = db.Column('cmt', db.Text(), key='comment', comment='comment') + version = db.Column(db.Integer, comment='version') + creator_username = db.Column(db.String(255), comment='creator username') + coordinator_id = db.Column(db.Integer, comment='coordinator participant id') + path = db.Column('fspath', db.String(512), key='path', comment='model job path') + metric_is_public = db.Column(db.Boolean(), default=False, comment='is metric public') + global_config = db.Column(db.Text(16777215), comment='global_config') + status = db.Column(db.Enum(ModelJobStatus, native_enum=False, length=32, create_constraint=False), + default=ModelJobStatus.PENDING, + comment='model job status') + create_status = db.Column(db.Enum(ModelJobCreateStatus, native_enum=False, length=32, create_constraint=False), + default=ModelJobCreateStatus.PENDING, + comment='create status') + auth_status = db.Column(db.Enum(AuthStatus, native_enum=False, length=32, create_constraint=False), + default=AuthStatus.PENDING, + comment='authorization status') + auto_update = db.Column(db.Boolean(), server_default=db.text('0'), comment='is auto update') + data_batch_id = db.Column(db.Integer, comment='data_batches id for auto update job') + error_message = db.Column(db.Text(), comment='error message') + created_at = db.Column(db.DateTime(timezone=True), comment='created_at', server_default=func.now()) updated_at = db.Column(db.DateTime(timezone=True), comment='updated_at', server_default=func.now(), onupdate=func.now()) deleted_at = db.Column(db.DateTime(timezone=True), comment='deleted_at') + # the model id used for prediction or evaluation + model = db.relationship('Model', primaryjoin='Model.id == foreign(ModelJob.model_id)') + group = db.relationship('ModelJobGroup', primaryjoin='ModelJobGroup.id == foreign(ModelJob.group_id)') + project = db.relationship(Project.__name__, primaryjoin='Project.id == foreign(ModelJob.project_id)') + # job_name is the foreign key, job_id is unknown when creating + job = db.relationship('Job', primaryjoin='Job.name == foreign(ModelJob.job_name)') + # workflow_uuid is the foreign key, workflow_id is unknown when creating + workflow = db.relationship(Workflow.__name__, primaryjoin='Workflow.uuid == foreign(ModelJob.workflow_uuid)') + algorithm = db.relationship(Algorithm.__name__, primaryjoin='Algorithm.id == foreign(ModelJob.algorithm_id)') + dataset = db.relationship(Dataset.__name__, primaryjoin='Dataset.id == foreign(ModelJob.dataset_id)') + data_batch = db.relationship('DataBatch', primaryjoin='DataBatch.id == foreign(ModelJob.data_batch_id)') - group_id = db.Column(db.Integer, default=0, comment='group_id') - # TODO https://code.byted.org/data/fedlearner_web_console_v2/issues/289 - extra = db.Column(db.Text(), comment='extra') # json string + output_model = db.relationship( + 'Model', + uselist=False, + primaryjoin='ModelJob.id == foreign(Model.model_job_id)', + # To disable the warning of back_populates + overlaps='model_job') + + def to_proto(self) -> ModelJobPb: + config = self.config() + model_job = ModelJobPb( + id=self.id, + name=self.name, + uuid=self.uuid, + role=self.role.name, + model_job_type=self.model_job_type.name, + algorithm_type=self.algorithm_type.name if self.algorithm_type else AlgorithmType.UNSPECIFIED.name, + algorithm_id=self.algorithm_id, + group_id=self.group_id, + project_id=self.project_id, + state=self.state.name, + configured=config is not None, + model_id=self.model_id, + model_name=self.model_name(), + job_id=self.job_id, + job_name=self.job_name, + workflow_id=self.workflow_id, + dataset_id=self.dataset_id, + dataset_name=self.dataset_name(), + creator_username=self.creator_username, + coordinator_id=self.coordinator_id, + auth_status=self.auth_status.name if self.auth_status else '', + status=self.status.name if self.status else '', + error_message=self.error_message, + auto_update=self.auto_update, + data_batch_id=self.data_batch_id, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + started_at=self.started_at(), + stopped_at=self.stopped_at(), + version=self.version, + comment=self.comment, + metric_is_public=self.metric_is_public, + global_config=self.get_global_config(), + participants_info=self.get_participants_info(), + auth_frontend_status=self.get_model_job_auth_frontend_status().name) + if config is not None: + model_job.config.MergeFrom(config) + if self.output_model is not None: + model_job.output_model_name = self.output_model.name + model_job.output_models.append(self.output_model.to_proto()) + return model_job + + def to_ref(self) -> ModelJobRef: + return ModelJobRef( + id=self.id, + name=self.name, + uuid=self.uuid, + group_id=self.group_id, + project_id=self.project_id, + role=self.role.name, + model_job_type=self.model_job_type.name, + algorithm_type=self.algorithm_type.name if self.algorithm_type else AlgorithmType.UNSPECIFIED.name, + algorithm_id=self.algorithm_id, + state=self.state.name, + configured=self.config() is not None, + creator_username=self.creator_username, + coordinator_id=self.coordinator_id, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + started_at=self.started_at(), + stopped_at=self.stopped_at(), + version=self.version, + metric_is_public=self.metric_is_public, + status=self.status.name if self.status else '', + auto_update=self.auto_update, + auth_status=self.auth_status.name if self.auth_status else '', + participants_info=self.get_participants_info(), + auth_frontend_status=self.get_model_job_auth_frontend_status().name) + + @property + def state(self) -> WorkflowExternalState: + # TODO(hangweiqiang): design model job state + if self.workflow is None: + return WorkflowExternalState.PENDING_ACCEPT + return self.workflow.get_state_for_frontend() + + def get_model_job_auth_frontend_status(self) -> ModelJobAuthFrontendStatus: + if self.ticket_status == TicketStatus.PENDING: + if self.ticket_uuid is not None: + return ModelJobAuthFrontendStatus.TICKET_PENDING + # Update old data that is set to PENDING by default when ticket is disabled + self.ticket_status = TicketStatus.APPROVED + if self.ticket_status == TicketStatus.DECLINED: + return ModelJobAuthFrontendStatus.TICKET_DECLINED + if self.auth_status not in [AuthStatus.AUTHORIZED]: + return ModelJobAuthFrontendStatus.SELF_AUTH_PENDING + if self.is_all_participants_authorized(): + return ModelJobAuthFrontendStatus.ALL_AUTHORIZED + if self.create_status in [ModelJobCreateStatus.PENDING]: + return ModelJobAuthFrontendStatus.CREATE_PENDING + if self.create_status in [ModelJobCreateStatus.FAILED]: + return ModelJobAuthFrontendStatus.CREATE_FAILED + return ModelJobAuthFrontendStatus.PART_AUTH_PENDING + + def get_job_path(self): + path = self.project.get_storage_root_path(None) + if path is None: + logging.warning('cannot find storage_root_path') + return None + return get_job_path(path, self.job_name) + + def get_exported_model_path(self) -> Optional[str]: + """Get the path of the exported models. + + Returns: + The path of the exported_models is returned. Return None if the + path can not found. There may be multiple checkpoints under the + path. The file structure of nn_model under the path of + exported_model is + - exported_models: + - ${terminated time, e.g. 1619769879} + - _SUCCESS + - saved_model.pb + - variables + - variables.data-00000-of-00001 + - variables.index + """ + job_path = self.get_job_path() + if job_path is None: + return None + return get_exported_model_path(job_path) + + def get_checkpoint_path(self): + job_path = self.get_job_path() + if job_path is None: + return None + return get_checkpoint_path(job_path=job_path) + + def get_output_path(self): + job_path = self.get_job_path() + if job_path is None: + return None + return get_output_path(job_path) + + def model_name(self) -> Optional[str]: + if self.model_id is not None: + return self.model.name + return None + + def dataset_name(self) -> Optional[str]: + # checking through relationship instead of existence of id, since item is possibly deleted + if self.dataset is not None: + return self.dataset.name + return None + + def started_at(self) -> Optional[int]: + if self.workflow: + return self.workflow.start_at + return None - parent = db.relationship('Model', - primaryjoin=remote(id) == foreign(parent_id), - backref='children') - job = db.relationship('Job', primaryjoin=Job.name == foreign(job_name)) + def stopped_at(self) -> Optional[int]: + if self.workflow: + return self.workflow.stop_at + return None - def get_eval_model(self): - return [ - child for child in self.children if child.type in - [ModelType.NN_EVALUATION.value, ModelType.TREE_EVALUATION.value] + def config(self) -> Optional[WorkflowDefinition]: + if self.workflow: + return self.workflow.get_config() + return None + + def is_deletable(self) -> bool: + return self.state in [ + WorkflowExternalState.FAILED, WorkflowExternalState.STOPPED, WorkflowExternalState.COMPLETED ] + def set_global_config(self, proto: ModelJobGlobalConfig): + self.global_config = text_format.MessageToString(proto) -@to_dict_mixin() -class ModelGroup(db.Model): - __tablename__ = 'model_groups_v2' - __table_args__ = (default_table_args('model_groups_v2')) + def get_global_config(self) -> Optional[ModelJobGlobalConfig]: + if self.global_config is not None: + return text_format.Parse(self.global_config, ModelJobGlobalConfig()) + return None - id = db.Column(db.Integer, primary_key=True, comment='id') - name = db.Column(db.String(255), - comment='name') # can be modified by end-user - created_at = db.Column(db.DateTime(timezone=True), - comment='created_at', - server_default=func.now()) +class Model(db.Model, SoftDeleteModel): + __tablename__ = 'models_v2' + __table_args__ = (UniqueConstraint('name', name='uniq_name'), UniqueConstraint('uuid', name='uniq_uuid'), + default_table_args('models_v2')) + + id = db.Column(db.Integer, primary_key=True, comment='id', autoincrement=True) + name = db.Column(db.String(255), comment='name') + uuid = db.Column(db.String(64), comment='uuid') + algorithm_type = db.Column(db.Enum(AlgorithmType, native_enum=False, length=32, create_constraint=False), + default=AlgorithmType.UNSPECIFIED, + comment='algorithm type') + # TODO(hangweiqiang): remove model_type coloumn + model_type = db.Column(db.Enum(ModelType, native_enum=False, length=32, create_constraint=False), + default=ModelType.UNSPECIFIED, + comment='type') + model_path = db.Column(db.String(512), comment='model path') + favorite = db.Column(db.Boolean, default=False, comment='favorite model') + comment = db.Column('cmt', db.Text(), key='comment', comment='comment') + group_id = db.Column(db.Integer, comment='group_id') + project_id = db.Column(db.Integer, comment='project_id') + job_id = db.Column(db.Integer, comment='job id') + model_job_id = db.Column(db.Integer, comment='model job id') + version = db.Column(db.Integer, comment='version') + created_at = db.Column(db.DateTime(timezone=True), comment='created_at', server_default=func.now()) updated_at = db.Column(db.DateTime(timezone=True), comment='updated_at', server_default=func.now(), onupdate=func.now()) deleted_at = db.Column(db.DateTime(timezone=True), comment='deleted_at') + group = db.relationship('ModelJobGroup', primaryjoin='ModelJobGroup.id == foreign(Model.group_id)') + project = db.relationship('Project', primaryjoin='Project.id == foreign(Model.project_id)') + job = db.relationship('Job', primaryjoin='Job.id == foreign(Model.job_id)') + # the model_job generating this model + model_job = db.relationship('ModelJob', primaryjoin='ModelJob.id == foreign(Model.model_job_id)') + # the model_jobs inheriting this model + derived_model_jobs = db.relationship( + 'ModelJob', + primaryjoin='foreign(ModelJob.model_id) == Model.id', + # To disable the warning of back_populates + overlaps='model') + + def to_proto(self) -> ModelPb: + return ModelPb( + id=self.id, + name=self.name, + uuid=self.uuid, + algorithm_type=self.algorithm_type.name if self.algorithm_type else AlgorithmType.UNSPECIFIED.name, + group_id=self.group_id, + project_id=self.project_id, + model_job_id=self.model_job_id, + model_job_name=self.model_job_name(), + job_id=self.job_id, + job_name=self.job_name(), + workflow_id=self.workflow_id(), + workflow_name=self.workflow_name(), + version=self.version, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + comment=self.comment, + model_path=self.model_path) + + def workflow_id(self): + if self.job is not None: + return self.job.workflow_id + return None + + def job_name(self): + if self.job is not None: + return self.job.name + return None + + def workflow_name(self): + if self.job is not None: + return self.job.workflow.name + return None + + def model_job_name(self): + if self.model_job is not None: + return self.model_job.name + return None - # TODO https://code.byted.org/data/fedlearner_web_console_v2/issues/289 + def get_exported_model_path(self): + """Get the path of the exported models + same with get_exported_path function in ModelJob class + """ + return get_exported_model_path(self.model_path) + + def get_checkpoint_path(self): + return get_checkpoint_path(self.model_path) + + +class ModelJobGroup(db.Model, SoftDeleteModel, ReviewTicketAndAuthModel): + # inconsistency between table name and class name due to historical issues + __tablename__ = 'model_groups_v2' + __table_args__ = (UniqueConstraint('name', name='uniq_name'), default_table_args('model_groups_v2')) + + id = db.Column(db.Integer, primary_key=True, comment='id', autoincrement=True) + uuid = db.Column(db.String(64), comment='uuid') + name = db.Column(db.String(255), comment='name') + project_id = db.Column(db.Integer, comment='project_id') + role = db.Column(db.Enum(ModelJobRole, native_enum=False, length=32, create_constraint=False), + default=ModelJobRole.PARTICIPANT, + comment='role') + authorized = db.Column(db.Boolean, default=False, comment='authorized to participants in project') + dataset_id = db.Column(db.Integer, comment='dataset id') + algorithm_type = db.Column(db.Enum(AlgorithmType, native_enum=False, length=32, create_constraint=False), + default=AlgorithmType.UNSPECIFIED, + comment='algorithm type') + algorithm_project_id = db.Column(db.Integer, comment='algorithm project id') + algorithm_id = db.Column(db.Integer, comment='algorithm id') + config = db.Column(db.Text(16777215), comment='config') + cron_job_global_config = db.Column(db.Text(16777215), comment='global config for cron job') + # use proto.AlgorithmProjectList to store the algorithm project uuid of each participant + algorithm_project_uuid_list = db.Column('algorithm_uuid_list', + db.Text(16777215), + key='algorithm_uuid_list', + comment='algorithm project uuid for all participants') + comment = db.Column('cmt', db.Text(), key='comment', comment='comment') + creator_username = db.Column(db.String(255), comment='creator username') + coordinator_id = db.Column(db.Integer, comment='coordinator participant id') + cron_config = db.Column(db.String(255), comment='cron expression in UTC timezone') + path = db.Column('fspath', db.String(512), key='path', comment='model job group path') + _auth_status = db.Column('auth_status', + db.Enum(auth_model.AuthStatus, native_enum=False, length=32, create_constraint=False), + default=auth_model.AuthStatus.PENDING, + comment='auth status') + auto_update_status = db.Column(db.Enum(GroupAutoUpdateStatus, native_enum=False, length=32, + create_constraint=False), + default=GroupAutoUpdateStatus.INITIAL, + comment='auto update status') + start_data_batch_id = db.Column(db.Integer, comment='start data_batches id for auto update job') + created_at = db.Column(db.DateTime(timezone=True), comment='created_at', server_default=func.now()) + updated_at = db.Column(db.DateTime(timezone=True), + comment='updated_at', + server_default=func.now(), + onupdate=func.now()) + deleted_at = db.Column(db.DateTime(timezone=True), comment='deleted_at') extra = db.Column(db.Text(), comment='extra') # json string + latest_version = db.Column(db.Integer, default=0, comment='latest version') + status = db.Column(db.Enum(GroupCreateStatus, native_enum=False, length=32, create_constraint=False), + default=GroupCreateStatus.PENDING, + comment='create status') + project = db.relationship('Project', primaryjoin='Project.id == foreign(ModelJobGroup.project_id)') + algorithm = db.relationship('Algorithm', primaryjoin='Algorithm.id == foreign(ModelJobGroup.algorithm_id)') + algorithm_project = db.relationship( + 'AlgorithmProject', primaryjoin='AlgorithmProject.id == foreign(ModelJobGroup.algorithm_project_id)') + dataset = db.relationship('Dataset', primaryjoin='Dataset.id == foreign(ModelJobGroup.dataset_id)') + model_jobs = db.relationship( + 'ModelJob', + order_by='desc(ModelJob.version)', + primaryjoin='ModelJobGroup.id == foreign(ModelJob.group_id)', + # To disable the warning of back_populates + overlaps='group') + start_data_batch = db.relationship('DataBatch', + primaryjoin='DataBatch.id == foreign(ModelJobGroup.start_data_batch_id)') + + @property + def auth_status(self): + if self._auth_status is not None: + return self._auth_status + if self.authorized: + return auth_model.AuthStatus.AUTHORIZED + return auth_model.AuthStatus.PENDING + + @auth_status.setter + def auth_status(self, auth_status: auth_model.AuthStatus): + self._auth_status = auth_status + + def to_ref(self) -> ModelJobGroupRef: + group = ModelJobGroupRef(id=self.id, + name=self.name, + uuid=self.uuid, + role=self.role.name, + project_id=self.project_id, + authorized=self.authorized, + algorithm_type=self.algorithm_type.name, + configured=self.config is not None, + creator_username=self.creator_username, + coordinator_id=self.coordinator_id, + latest_version=self.latest_version, + participants_info=self.get_participants_info(), + auth_status=self.auth_status.name, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at)) + latest_job_state = self.latest_job_state() + if latest_job_state is not None: + group.latest_job_state = latest_job_state.name + group.auth_frontend_status = self.get_group_auth_frontend_status().name + return group + + def to_proto(self) -> ModelJobGroupPb: + group = ModelJobGroupPb(id=self.id, + name=self.name, + uuid=self.uuid, + role=self.role.name, + project_id=self.project_id, + authorized=self.authorized, + dataset_id=self.dataset_id, + algorithm_type=self.algorithm_type.name, + algorithm_project_id=self.algorithm_project_id, + algorithm_id=self.algorithm_id, + configured=self.config is not None, + creator_username=self.creator_username, + coordinator_id=self.coordinator_id, + cron_config=self.cron_config, + latest_version=self.latest_version, + participants_info=self.get_participants_info(), + algorithm_project_uuid_list=self.get_algorithm_project_uuid_list(), + auth_status=self.auth_status.name, + auto_update_status=self.auto_update_status.name if self.auto_update_status else '', + start_data_batch_id=self.start_data_batch_id, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + comment=self.comment) + latest_job_state = self.latest_job_state() + if latest_job_state is not None: + group.latest_job_state = latest_job_state.name + group.auth_frontend_status = self.get_group_auth_frontend_status().name + if self.config is not None: + group.config.MergeFrom(self.get_config()) + group.model_jobs.extend([mj.to_ref() for mj in self.model_jobs]) + return group + + def latest_job_state(self) -> Optional[ModelJobStatus]: + if len(self.model_jobs) == 0: + return None + return self.model_jobs[0].status + + def get_group_auth_frontend_status(self) -> GroupAuthFrontendStatus: + if self.ticket_status == TicketStatus.PENDING: + if self.ticket_uuid is not None: + return GroupAuthFrontendStatus.TICKET_PENDING + # Update old data that is set to PENDING by default when ticket is disabled + self.ticket_status = TicketStatus.APPROVED + if self.ticket_status == TicketStatus.DECLINED: + return GroupAuthFrontendStatus.TICKET_DECLINED + if not self.authorized: + return GroupAuthFrontendStatus.SELF_AUTH_PENDING + if self.is_all_participants_authorized(): + return GroupAuthFrontendStatus.ALL_AUTHORIZED + if self.status == GroupCreateStatus.PENDING: + return GroupAuthFrontendStatus.CREATE_PENDING + if self.status == GroupCreateStatus.FAILED: + return GroupAuthFrontendStatus.CREATE_FAILED + return GroupAuthFrontendStatus.PART_AUTH_PENDING + + def get_config(self) -> Optional[WorkflowDefinition]: + if self.config is not None: + return text_format.Parse(self.config, WorkflowDefinition()) + return None + + def set_config(self, config: Optional[WorkflowDefinition] = None): + if config is None: + config = WorkflowDefinition() + self.config = text_format.MessageToString(config) + + def is_deletable(self) -> bool: + for model_job in self.model_jobs: + if not model_job.is_deletable(): + return False + return True + + def latest_completed_job(self) -> Optional[ModelJob]: + for job in self.model_jobs: + if job.state == WorkflowExternalState.COMPLETED: + return job + return None + + def set_algorithm_project_uuid_list(self, proto: AlgorithmProjectList): + self.algorithm_project_uuid_list = text_format.MessageToString(proto) + + def get_algorithm_project_uuid_list(self) -> AlgorithmProjectList: + algorithm_project_uuid_list = AlgorithmProjectList() + if self.algorithm_project_uuid_list is not None: + algorithm_project_uuid_list = text_format.Parse(self.algorithm_project_uuid_list, AlgorithmProjectList()) + return algorithm_project_uuid_list + + +def is_federated(algorithm_type: AlgorithmType, model_job_type: ModelJobType) -> bool: + return algorithm_type != AlgorithmType.NN_HORIZONTAL or model_job_type == ModelJobType.TRAINING diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/service.py b/web_console_v2/api/fedlearner_webconsole/mmgr/service.py index 2f811c169..59fc1ddff 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/service.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/service.py @@ -1,161 +1,467 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# -# coding: utf-8 - -import os -import json import logging -from fedlearner_webconsole.db import make_session_context +from typing import Optional +from sqlalchemy.orm import Session + +from fedlearner_webconsole.composer.interface import ItemType +from fedlearner_webconsole.exceptions import InvalidArgumentException, NotFoundException +from fedlearner_webconsole.mmgr.metrics.metrics_inquirer import tree_metrics_inquirer, nn_metrics_inquirer +from fedlearner_webconsole.participant.models import Participant +from fedlearner_webconsole.participant.services import ParticipantService +from fedlearner_webconsole.project.models import Project +from fedlearner_webconsole.proto.composer_pb2 import RunnerInput, ModelTrainingCronJobInput +from fedlearner_webconsole.proto.metrics_pb2 import ModelJobMetrics from fedlearner_webconsole.job.metrics import JobMetricsBuilder -from fedlearner_webconsole.job.models import Job, JobType, JobState, JobDefinition -from fedlearner_webconsole.job.yaml_formatter import generate_job_run_yaml -from fedlearner_webconsole.mmgr.models import Model, ModelType, ModelState -from fedlearner_webconsole.utils.k8s_cache import Event, EventType, ObjectType +from fedlearner_webconsole.job.models import Job +from fedlearner_webconsole.workflow.models import Workflow, WorkflowState +from fedlearner_webconsole.algorithm.models import AlgorithmType, Algorithm, AlgorithmProject, Source +from fedlearner_webconsole.mmgr.models import Model, ModelJob, ModelType, ModelJobGroup, ModelJobType, ModelJobRole, \ + ModelJobStatus, AuthStatus +from fedlearner_webconsole.mmgr.utils import deleted_name +from fedlearner_webconsole.mmgr.model_job_configer import get_sys_template_id, ModelJobConfiger +from fedlearner_webconsole.mmgr.utils import get_job_path, build_workflow_name, \ + is_model_job +from fedlearner_webconsole.dataset.models import Dataset, DatasetJob, DatasetJobKind, DataBatch +from fedlearner_webconsole.composer.composer_service import CronJobService +from fedlearner_webconsole.workflow.workflow_controller import create_ready_workflow +from fedlearner_webconsole.workflow.service import CreateNewWorkflowParams, WorkflowService +from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition +from fedlearner_webconsole.proto.project_pb2 import ParticipantsInfo, ParticipantInfo +from fedlearner_webconsole.utils.pp_datetime import now +from fedlearner_webconsole.utils.const import SYSTEM_WORKFLOW_CREATOR_USERNAME +from fedlearner_webconsole.utils.base_model import auth_model +from fedlearner_webconsole.proto.mmgr_pb2 import ModelJobGlobalConfig, ModelJobConfig, AlgorithmProjectList +from fedlearner_webconsole.setting.service import SettingService +from fedlearner_webconsole.rpc.v2.job_service_client import JobServiceClient -class ModelService: +def get_project(project_id: int, session: Session) -> Project: + project = session.query(Project).get(project_id) + if project is None: + raise NotFoundException(f'project {project_id} is not found') + return project + + +def get_dataset(dataset_id: int, session: Session) -> Dataset: + dataset = session.query(Dataset).get(dataset_id) + if dataset is None: + raise InvalidArgumentException(f'dataset {dataset_id} is not found') + return dataset + + +def get_model_job(project_id: int, model_job_id: int, session: Session) -> ModelJob: + model_job = session.query(ModelJob).filter_by(id=model_job_id, project_id=project_id).first() + if model_job is None: + raise NotFoundException(f'[Model]model job {model_job_id} is not found') + return model_job + + +def get_model_job_group(project_id: int, group_id: int, session: Session) -> ModelJobGroup: + query = session.query(ModelJobGroup).filter_by(id=group_id, project_id=project_id) + group = query.first() + if group is None: + raise NotFoundException(f'[Model]model group {group_id} is not found') + return group + + +def check_model_job_group(project_id: int, group_id: int, sesseion: Session): + group = sesseion.query(ModelJobGroup).filter_by(id=group_id, project_id=project_id).first() + if group is None: + raise NotFoundException(f'[Model]model group {group_id} is not found') + + +def get_participant(participant_id: int, project: Project) -> Participant: + for participant in project.participants: + if participant.id == participant_id: + return participant + raise NotFoundException(f'participant {participant_id} is not found') + + +def get_model(project_id: int, model_id: int, session: Session) -> Model: + model = session.query(Model).filter_by(project_id=project_id, id=model_id).first() + if model is None: + raise NotFoundException(f'[Model]model {model_id} is not found') + return model + + +def get_algorithm(project_id: int, algorithm_id: int, session: Session) -> Optional[Algorithm]: + query = session.query(Algorithm) + if project_id: + # query under project and preset algorithms with project_id as null + query = query.filter((Algorithm.project_id == project_id) | (Algorithm.source == Source.PRESET)) + algo = query.filter_by(id=algorithm_id).first() + return algo + + +class ModelJobService: def __init__(self, session): self._session = session - job_type_map = { - JobType.NN_MODEL_TRANINING: ModelType.NN_MODEL.value, - JobType.NN_MODEL_EVALUATION: ModelType.NN_EVALUATION.value, - JobType.TREE_MODEL_TRAINING: ModelType.TREE_MODEL.value, - JobType.TREE_MODEL_EVALUATION: ModelType.TREE_EVALUATION.value - } - - job_state_map = { - JobState.STARTED: ModelState.RUNNING.value, - JobState.COMPLETED: ModelState.SUCCEEDED.value, - JobState.FAILED: ModelState.FAILED.value, - JobState.STOPPED: ModelState.PAUSED.value, - JobState.WAITING: ModelState.WAITING.value - } + @staticmethod + def query_metrics(model_job: ModelJob, job: Optional[Job] = None) -> ModelJobMetrics: + job = job or model_job.job + builder = JobMetricsBuilder(job) + if model_job.algorithm_type == AlgorithmType.TREE_VERTICAL: + model_job_metrics = tree_metrics_inquirer.query(job, need_feature_importance=True) + if len(model_job_metrics.train) == 0 and len(model_job_metrics.eval) == 0: + # legacy metrics support + logging.info(f'use legacy tree model metrics, job name = {job.name}') + return builder.query_tree_metrics(need_feature_importance=True) + return model_job_metrics + if model_job.algorithm_type == AlgorithmType.NN_VERTICAL: + model_job_metrics = nn_metrics_inquirer.query(job) + if len(model_job_metrics.train) == 0 and len(model_job_metrics.eval) == 0: + # legacy metrics support + logging.info(f'use legacy nn model metrics, job name = {job.name}') + return builder.query_nn_metrics() + return model_job_metrics + if model_job.algorithm_type == AlgorithmType.NN_HORIZONTAL: + return builder.query_nn_metrics() + raise ValueError(f'invalid algorithm type {model_job.algorithm_type}') @staticmethod - def is_model_related_job(job): - job_type = job.job_type - if isinstance(job_type, int): - job_type = JobType(job.job_type) - return job_type in [ - JobType.NN_MODEL_TRANINING, JobType.NN_MODEL_EVALUATION, - JobType.TREE_MODEL_TRAINING, JobType.TREE_MODEL_EVALUATION - ] - - def k8s_watcher_hook(self, event: Event): - logging.info('[ModelService][k8s_watcher_hook] %s %s: %s', event.obj_type, event.event_type, event.flapp_name) - if event.obj_type == ObjectType.FLAPP and event.event_type in [ - EventType.MODIFIED, EventType.DELETED - ]: - job = self._session.query(Job).filter_by( - name=event.flapp_name).one_or_none() - if not job: - return logging.warning('[ModelService][k8s_watcher_hook] job not found: %s', event.flapp_name) - if self.is_model_related_job(job): - self.on_job_update(job) - - def workflow_hook(self, job: Job): - if self.is_model_related_job(job): - self.create(job) - - def plot_metrics(self, model, job=None): - try: - return JobMetricsBuilder(job or model.job).plot_metrics() - except Exception as e: - return repr(e) - - def is_model_quiescence(self, state): - return state in [ - ModelState.SUCCEEDED.value, ModelState.FAILED.value, - ModelState.PAUSED.value - ] - - def on_job_update(self, job: Job): - logging.info('[ModelService][on_job_update] job name: %s', job.name) - model = self._session.query(Model).filter_by(job_name=job.name).one() - # see also `fedlearner_webconsole.job.models.Job.stop` - if job.state in self.job_state_map: - state = self.job_state_map[job.state] + def _get_job(workflow: Workflow) -> Optional[Job]: + for job in workflow.owned_jobs: + if is_model_job(job.job_type): + return job + return None + + def _create_model_job_for_participants(self, model_job: ModelJob): + project = self._session.query(Project).get(model_job.project_id) + group = self._session.query(ModelJobGroup).get(model_job.group_id) + global_config = model_job.get_global_config() + for participant in project.participants: + client = JobServiceClient.from_project_and_participant(participant.domain_name, project.name) + try: + client.create_model_job(name=model_job.name, + uuid=model_job.uuid, + group_uuid=group.uuid, + model_job_type=model_job.model_job_type, + algorithm_type=model_job.algorithm_type, + global_config=global_config, + version=model_job.version) + logging.info(f'[ModelJob] model job {model_job.id} is ready') + except Exception as e: # pylint: disable=broad-except + logging.exception('[ModelJob] creating model job for participants failed') + raise Exception(f'[ModelJob] creating model job for participants failed with detail {str(e)}') from e + + # TODO(hangweiqiang): ensure version is unique for training job under model job group + def create_model_job(self, + name: str, + uuid: str, + project_id: int, + role: ModelJobRole, + model_job_type: ModelJobType, + algorithm_type: AlgorithmType, + global_config: ModelJobGlobalConfig, + group_id: Optional[int] = None, + coordinator_id: Optional[int] = 0, + data_batch_id: Optional[int] = None, + version: Optional[int] = None, + comment: Optional[str] = None) -> ModelJob: + model_job = ModelJob(name=name, + uuid=uuid, + group_id=group_id, + project_id=project_id, + role=role, + model_job_type=model_job_type, + algorithm_type=algorithm_type, + coordinator_id=coordinator_id, + version=version, + comment=comment) + assert global_config.dataset_uuid != '', 'dataset uuid must not be empty' + dataset = self._session.query(Dataset).filter_by(uuid=global_config.dataset_uuid).first() + assert dataset is not None, f'dataset with uuid {global_config.dataset_uuid} is not found' + model_job.dataset_id = dataset.id + if data_batch_id is not None: # for auto update jobs + assert algorithm_type in [AlgorithmType.NN_VERTICAL],\ + 'auto update is only supported for nn vertical train' + dataset_job: DatasetJob = self._session.query(DatasetJob).filter_by(output_dataset_id=dataset.id).first() + assert dataset_job.kind != DatasetJobKind.RSA_PSI_DATA_JOIN,\ + 'auto update is not supported for RSA-PSI dataset' + data_batch: DataBatch = self._session.query(DataBatch).get(data_batch_id) + assert data_batch is not None, f'data batch {data_batch_id} is not found' + assert data_batch.is_available(), f'data batch {data_batch_id} is not available' + assert data_batch.latest_parent_dataset_job_stage is not None, 'dataset job stage with id is not found' + model_job.data_batch_id = data_batch_id + model_job.auto_update = True + if role in [ModelJobRole.COORDINATOR]: + global_config.dataset_job_stage_uuid = data_batch.latest_parent_dataset_job_stage.uuid + model_job.set_global_config(global_config) + self.initialize_auth_status(model_job) + # when model job type is eval or predict + if global_config.model_uuid != '': + model = self._session.query(Model).filter_by(uuid=global_config.model_uuid).first() + assert model is not None, f'model with uuid {global_config.model_uuid} is not found' + model_job.model_id = model.id + # add model's group id to model_job when eval and predict + model_job.group_id = model.group_id + pure_domain_name = SettingService(session=self._session).get_system_info().pure_domain_name + model_job_config: ModelJobConfig = global_config.global_config.get(pure_domain_name) + assert model_job_config is not None, f'model_job_config of self domain name {pure_domain_name} must not be None' + if model_job_config.algorithm_uuid != '': + algorithm = self._session.query(Algorithm).filter_by(uuid=model_job_config.algorithm_uuid).first() + # algorithm is none if algorithm_uuid points to a published algorithm at the peer platform + if algorithm is not None: + model_job.algorithm_id = algorithm.id + # no need create model job at participants when eval or predict horizontal model + if model_job_type in [ModelJobType.TRAINING] and role in [ModelJobRole.COORDINATOR]: + self._create_model_job_for_participants(model_job) + if model_job_type in [ModelJobType.EVALUATION, ModelJobType.PREDICTION] and algorithm_type not in [ + AlgorithmType.NN_HORIZONTAL + ] and role in [ModelJobRole.COORDINATOR]: + self._create_model_job_for_participants(model_job) + self._session.add(model_job) + return model_job + + def config_model_job(self, + model_job: ModelJob, + config: WorkflowDefinition, + create_workflow: bool, + need_to_create_ready_workflow: Optional[bool] = False, + workflow_uuid: Optional[str] = None): + workflow_name = build_workflow_name(model_job_type=model_job.model_job_type.name, + algorithm_type=model_job.algorithm_type.name, + model_job_name=model_job.name) + template_id = get_sys_template_id(self._session, model_job.algorithm_type, model_job.model_job_type) + if template_id is None: + raise ValueError(f'workflow template for {model_job.algorithm_type.name} not found') + workflow_comment = f'created by model_job {model_job.name}' + configer = ModelJobConfiger(session=self._session, + model_job_type=model_job.model_job_type, + algorithm_type=model_job.algorithm_type, + project_id=model_job.project_id) + configer.set_dataset(config=config, dataset_id=model_job.dataset_id, data_batch_id=model_job.data_batch_id) + if need_to_create_ready_workflow: + workflow = create_ready_workflow( + session=self._session, + name=workflow_name, + config=config, + project_id=model_job.project_id, + template_id=template_id, + uuid=workflow_uuid, + comment=workflow_comment, + ) + elif create_workflow: + params = CreateNewWorkflowParams(project_id=model_job.project_id, template_id=template_id) + workflow = WorkflowService(self._session).create_workflow(name=workflow_name, + config=config, + params=params, + comment=workflow_comment, + uuid=workflow_uuid, + creator_username=SYSTEM_WORKFLOW_CREATOR_USERNAME) else: - return logging.warning( - '[ModelService][on_job_update] job state is %s', job.state) - if model.state != ModelState.RUNNING.value and state == ModelState.RUNNING.value: - logging.info( - '[ModelService][on_job_update] updating model(%d).version from %s to %s', - model.id, model.version, model.version + 1) - model.version += 1 - logging.info( - '[ModelService][on_job_update] updating model(%d).state from %s to %s', - model.id, model.state, state) - if self.is_model_quiescence(state): - model.metrics = json.dumps(self.plot_metrics(model, job)) - model.state = state - self._session.add(model) + workflow = self._session.query(Workflow).filter_by(uuid=model_job.workflow_uuid).first() + if workflow is None: + raise ValueError(f'workflow with uuid {model_job.workflow_uuid} not found') + workflow = WorkflowService(self._session).config_workflow(workflow=workflow, + template_id=template_id, + config=config, + comment=workflow_comment, + creator_username=SYSTEM_WORKFLOW_CREATOR_USERNAME) + self._session.flush() + model_job.workflow_id = workflow.id + model_job.workflow_uuid = workflow.uuid + job = self._get_job(workflow) + assert job is not None, 'model job not found in workflow' + model_job.job_name = job.name + model_job.job_id = job.id + model_job.status = ModelJobStatus.CONFIGURED + self._session.flush() - def create(self, job: Job, parent_job_name=None, group_id=0): - logging.info('[ModelService][create] create model %s', job.name) - model = Model() - model.name = job.name # TODO allow rename by end-user - model.type = self.job_type_map[job.job_type] - model.state = ModelState.COMMITTING.value - model.job_name = job.name - if parent_job_name: - parent = self._session.query(Model).filter_by( - job_name=parent_job_name).one_or_none() - if not parent: - return parent - model.version = parent.version - model.parent_id = parent.id - model.params = json.dumps({}) - model.group_id = group_id - model.state = ModelState.COMMITTED.value - self._session.add(model) + def update_model_job_status(self, model_job: ModelJob): + workflow = self._session.query(Workflow).filter_by(uuid=model_job.workflow_uuid).first() + if workflow: + if workflow.state in [WorkflowState.RUNNING]: + model_job.status = ModelJobStatus.RUNNING + if workflow.state in [WorkflowState.STOPPED]: + model_job.status = ModelJobStatus.STOPPED + if workflow.state in [WorkflowState.COMPLETED]: + model_job.status = ModelJobStatus.SUCCEEDED + if workflow.state in [WorkflowState.FAILED]: + model_job.status = ModelJobStatus.FAILED self._session.commit() - return model - # `detail_level` is a comma separated string list - # contains `metrics` if `plot_metrics` result is - def query(self, model_id, detail_level=''): - model = self._session.query(Model).filter_by(id=model_id).one_or_none() - if not model: - return model - detail_level = detail_level.split(',') - model_json = model.to_dict() - model_json['detail_level'] = detail_level - if 'metrics' in detail_level: - if self.is_model_quiescence(model) and model.metrics: - model_json['metrics'] = json.loads(model.metrics) - else: model_json['metrics'] = self.plot_metrics(model) - return model_json - - def drop(self, model_id): - model = self._session.query(Model).filter_by(id=model_id).one_or_none() - if not model: - return model - if model.state not in [ - ModelState.SUCCEEDED.value, ModelState.FAILED.value - ]: # FIXME atomicity - raise Exception( - f'cannot delete model when model.state is {model.state}') - # model.state = ModelState.DROPPING.value - # TODO remove model files from NFS et al. - model.state = ModelState.DROPPED.value + def initialize_auth_status(self, model_job: ModelJob): + pure_domain_name = SettingService(self._session).get_system_info().pure_domain_name + participants = ParticipantService(self._session).get_participants_by_project(model_job.project_id) + # 1. default all authorized when model job type is training + # 2. default all authorized when algorithm type is nn_horizontal and model job type is evaluation or prediction + # 3. set coordinator authorized when algorithm type is not nn_horizontal and model job type is evaluation or + # prediction + participants_info = ParticipantsInfo(participants_map={ + p.pure_domain_name(): ParticipantInfo(auth_status=AuthStatus.PENDING.name) for p in participants + }) + participants_info.participants_map[pure_domain_name].auth_status = AuthStatus.PENDING.name + if model_job.model_job_type in [ModelJobType.TRAINING + ] or model_job.algorithm_type in [AlgorithmType.NN_HORIZONTAL]: + participants_info = ParticipantsInfo(participants_map={ + p.pure_domain_name(): ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name) for p in participants + }) + participants_info.participants_map[pure_domain_name].auth_status = AuthStatus.AUTHORIZED.name + model_job.auth_status = AuthStatus.AUTHORIZED + elif model_job.role in [ModelJobRole.COORDINATOR]: + participants_info.participants_map[pure_domain_name].auth_status = AuthStatus.AUTHORIZED.name + model_job.auth_status = AuthStatus.AUTHORIZED + model_job.set_participants_info(participants_info) + + def delete(self, job_id: int): + model_job: ModelJob = self._session.query(ModelJob).get(job_id) + model_job.deleted_at = now() + model_job.name = deleted_name(model_job.name) + if model_job.output_model is not None: + ModelService(self._session).delete(model_job.output_model.id) + + +class ModelService: + + def __init__(self, session: Session): + self._session = session + + def create_model_from_model_job(self, model_job: ModelJob): + name = f'{model_job.group.name}-v{model_job.version}' + model_type = ModelType.NN_MODEL + if model_job.algorithm_type == AlgorithmType.TREE_VERTICAL: + model_type = ModelType.TREE_MODEL + model = Model(name=name, + uuid=model_job.uuid, + version=model_job.version, + model_type=model_type, + algorithm_type=model_job.algorithm_type, + project_id=model_job.project_id, + job_id=model_job.job_id, + group_id=model_job.group_id, + model_job_id=model_job.id) + storage_root_dir = model_job.project.get_storage_root_path(None) + if storage_root_dir is None: + logging.warning(f'[ModelService] storage root of project {model_job.project.name} is None') + raise RuntimeError(f'storage root of project {model_job.project.name} is None') + model.model_path = get_job_path(storage_root_dir, model_job.job.name) self._session.add(model) - self._session.commit() + + def delete(self, model_id: int): + model: Model = self._session.query(Model).get(model_id) + model.deleted_at = now() + model.name = deleted_name(model.name) + + +class ModelJobGroupService: + + def __init__(self, session: Session): + self._session = session + + def launch_model_job(self, group: ModelJobGroup, name: str, uuid: str, version: int) -> ModelJob: + model_job = ModelJob( + name=name, + uuid=uuid, + group_id=group.id, + project_id=group.project_id, + model_job_type=ModelJobType.TRAINING, + algorithm_type=group.algorithm_type, + algorithm_id=group.algorithm_id, + dataset_id=group.dataset_id, + version=version, + ) + self._session.add(model_job) + self._session.flush() + ModelJobService(self._session).config_model_job(model_job, + group.get_config(), + create_workflow=False, + need_to_create_ready_workflow=True, + workflow_uuid=model_job.uuid) + group.latest_version = version + self._session.flush() + return model_job + + def delete(self, group_id: int): + group: ModelJobGroup = self._session.query(ModelJobGroup).get(group_id) + group.name = deleted_name(group.name) + group.deleted_at = now() + job_service = ModelJobService(self._session) + for job in group.model_jobs: + job_service.delete(job.id) + + def lock_and_update_version(self, group_id: int) -> ModelJobGroup: + group: ModelJobGroup = self._session.query(ModelJobGroup).populate_existing().with_for_update().get(group_id) + # use exclusive lock to ensure version is unique and increasing. + # since 2PC has its own db transaction, and the latest_version of group should be updated in service, + # to avoid lock conflict, the latest_version is updated and lock is released, + # and the version is passed to 2PC transaction. + group.latest_version = group.latest_version + 1 + return group + + def update_cronjob_config(self, group: ModelJobGroup, cron_config: str): + """Update model training cron job config + + Args: + group: group for updating cron config + cron_config: cancel cron job if cron config is empty string; create + or update cron job if cron config is valid + """ + item_name = f'model_training_cron_job_{group.id}' + group.cron_config = cron_config + if cron_config: + runner_input = RunnerInput(model_training_cron_job_input=ModelTrainingCronJobInput(group_id=group.id)) + items = [(ItemType.MODEL_TRAINING_CRON_JOB, runner_input)] + CronJobService(self._session).start_cronjob(item_name=item_name, items=items, cron_config=cron_config) + else: + CronJobService(self._session).stop_cronjob(item_name=item_name) + + def create_group(self, name: str, uuid: str, project_id: int, role: ModelJobRole, dataset_id: int, + algorithm_type: AlgorithmType, algorithm_project_list: AlgorithmProjectList, + coordinator_id: int) -> ModelJobGroup: + dataset = self._session.query(Dataset).get(dataset_id) + assert dataset is not None, f'dataset with id {dataset_id} is not found' + group = ModelJobGroup(name=name, + uuid=uuid, + role=role, + project_id=project_id, + dataset_id=dataset_id, + algorithm_type=algorithm_type, + coordinator_id=coordinator_id) + group.set_algorithm_project_uuid_list(algorithm_project_list) + pure_domain_name = SettingService(session=self._session).get_system_info().pure_domain_name + algorithm_project_uuid = algorithm_project_list.algorithm_projects.get(pure_domain_name) + if algorithm_project_uuid is None and algorithm_type != AlgorithmType.TREE_VERTICAL: + raise Exception(f'algorithm project uuid must be given if algorithm type is {algorithm_type.name}') + if algorithm_project_uuid is not None: + algorithm_project = self._session.query(AlgorithmProject).filter_by(uuid=algorithm_project_uuid).first() + # algorithm project is none if uuid points to a published algorithm at the peer platform + if algorithm_project is not None: + group.algorithm_project_id = algorithm_project.id + self._session.add(group) + return group + + def get_latest_model_from_model_group(self, model_group_id: int) -> Model: + model = self._session.query(Model).filter_by(group_id=model_group_id).order_by(Model.version.desc()).first() + if model is None: + raise InvalidArgumentException(f'model in group {model_group_id} is not found') return model - def get_checkpoint_path(self, job): - return None + def initialize_auth_status(self, group: ModelJobGroup): + # set auth status map + pure_domain_name = SettingService(self._session).get_system_info().pure_domain_name + participants = ParticipantService(self._session).get_participants_by_project(group.project_id) + participants_info = ParticipantsInfo(participants_map={ + p.pure_domain_name(): ParticipantInfo(auth_status=AuthStatus.PENDING.name) for p in participants + }) + participants_info.participants_map[pure_domain_name].auth_status = AuthStatus.AUTHORIZED.name + group.set_participants_info(participants_info) + # compatible with older versions of auth status + group.authorized = True + group.auth_status = auth_model.AuthStatus.AUTHORIZED diff --git a/web_console_v2/api/fedlearner_webconsole/project/add_on.py b/web_console_v2/api/fedlearner_webconsole/project/add_on.py deleted file mode 100644 index c20453528..000000000 --- a/web_console_v2/api/fedlearner_webconsole/project/add_on.py +++ /dev/null @@ -1,342 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import tarfile -import io -import os -from base64 import b64encode, b64decode -from typing import Type, Dict -from OpenSSL import crypto, SSL -from fedlearner_webconsole.utils.k8s_client import K8sClient - -CA_SECRET_NAME = 'ca-secret' -OPERATOR_NAME = 'fedlearner-operator' -SERVER_SECRET_NAME = 'fedlearner-proxy-server' -INGRESS_NGINX_CONTROLLER_NAME = 'fedlearner-stack-ingress-nginx-controller' - - -def parse_certificates(encoded_gz): - """ - Parse certificates from base64-encoded string to a dict - Args: - encoded_gz: A base64-encoded string from a `.gz` file. - Returns: - dict: key is the file name, value is the content - """ - binary_gz = io.BytesIO(b64decode(encoded_gz)) - with tarfile.open(fileobj=binary_gz) as gz: - certificates = {} - for file in gz.getmembers(): - if file.isfile(): - # raw file name is like `fl-test.com/client/client.pem` - certificates[file.name.split('/', 1)[-1]] = \ - str(b64encode(gz.extractfile(file).read()), - encoding='utf-8') - return certificates - - -def verify_certificates(certificates: Dict[str, str]) -> (bool, str): - """ - Verify certificates from 4 aspects: - 1. The CN of all public keys are equal. - 2. All the CN are generic domain names. - 3. Public key match private key. - 4. Private key is signed by CA. - Args: - certificates: - Returns: - """ - try: - client_public_key = crypto.load_certificate( - crypto.FILETYPE_PEM, - b64decode(certificates.get('client/client.pem'))) - server_public_key = crypto.load_certificate( - crypto.FILETYPE_PEM, - b64decode(certificates.get('server/server.pem'))) - client_private_key = crypto.load_privatekey( - crypto.FILETYPE_PEM, - b64decode(certificates.get('client/client.key'))) - server_private_key = crypto.load_privatekey( - crypto.FILETYPE_PEM, - b64decode(certificates.get('server/server.key'))) - client_intermediate_ca = crypto.load_certificate( - crypto.FILETYPE_PEM, - b64decode(certificates.get('client/intermediate.pem'))) - server_intermediate_ca = crypto.load_certificate( - crypto.FILETYPE_PEM, - b64decode(certificates.get('server/intermediate.pem'))) - client_root_ca = crypto.load_certificate( - crypto.FILETYPE_PEM, - b64decode(certificates.get('client/root.pem'))) - server_root_ca = crypto.load_certificate( - crypto.FILETYPE_PEM, - b64decode(certificates.get('server/root.pem'))) - except crypto.Error as err: - return False, 'Format of key or CA is invalid: {}'.format(err) - - if client_public_key.get_subject().CN != server_public_key.get_subject().CN: - return False, 'Client and server public key CN mismatch' - if not client_public_key.get_subject().CN.startswith('*.'): - return False, 'CN of public key should be a generic domain name' - - try: - client_context = SSL.Context(SSL.TLSv1_METHOD) - client_context.use_certificate(client_public_key) - client_context.use_privatekey(client_private_key) - client_context.check_privatekey() - - server_context = SSL.Context(SSL.TLSv1_METHOD) - server_context.use_certificate(server_public_key) - server_context.use_privatekey(server_private_key) - server_context.check_privatekey() - except SSL.Error as err: - return False, 'Key pair mismatch: {}'.format(err) - - try: - client_store = crypto.X509Store() - client_store.add_cert(client_root_ca) - client_store.add_cert(client_intermediate_ca) - crypto.X509StoreContext(client_store, client_public_key)\ - .verify_certificate() - except crypto.X509StoreContextError as err: - return False, 'Client key and CA mismatch: {}'.format(err) - try: - server_store = crypto.X509Store() - server_store.add_cert(server_root_ca) - server_store.add_cert(server_intermediate_ca) - crypto.X509StoreContext(server_store, server_public_key)\ - .verify_certificate() - except crypto.X509StoreContextError as err: - return False, 'Server key and CA mismatch: {}'.format(err) - - return True, '' - - -def create_add_on(client: Type[K8sClient], domain_name: str, url: str, - certificates: Dict[str, str], custom_host: str = None): - """ - Idempotent - Create add on and upgrade nginx-ingress and operator. - If add on of domain_name exists, replace it. - - Args: - client: K8s client instance - domain_name: participant's domain name, used to create Ingress - url: participant's external ip, used to create ExternalName - Service - certificates: used for two-way tls authentication and to create one - server Secret, one client Secret and one CA - custom_host: used for case where participant is using an external - authentication gateway - """ - # url: xxx.xxx.xxx.xxx:xxxxx - ip = url.split(':')[0] - port = int(url.split(':')[1]) - client_all_pem = str(b64encode('{}\n{}'.format( - str(b64decode(certificates.get('client/intermediate.pem')), - encoding='utf-8').strip(), - str(b64decode(certificates.get('client/root.pem')), - encoding='utf-8').strip()).encode()), encoding='utf-8') - server_all_pem = str(b64encode('{}\n{}'.format( - str(b64decode(certificates.get('server/intermediate.pem')), - encoding='utf-8').strip(), - str(b64decode(certificates.get('server/root.pem')), - encoding='utf-8').strip()).encode()), encoding='utf-8') - name = domain_name.split('.')[0] - client_secret_name = '{}-client'.format(name) - client_auth_ingress_name = '-client-auth.'.join(domain_name.split('.')) - - # Create server certificate secret - # If users verify gRpc in external gateway, - # `AUTHORIZATION_MODE` should be set to `EXTERNAL`. - if os.environ.get('AUTHORIZATION_MODE') != 'EXTERNAL': - client.create_or_update_secret( - data={ - 'ca.crt': certificates.get('server/intermediate.pem'), - 'tls.crt': certificates.get('server/server.pem'), - 'tls.key': certificates.get('server/server.key') - }, - metadata={ - 'name': SERVER_SECRET_NAME, - 'namespace': 'default' - }, - secret_type='Opaque', - name=SERVER_SECRET_NAME - ) - client.create_or_update_secret( - data={ - 'ca.crt': server_all_pem - }, - metadata={ - 'name': CA_SECRET_NAME, - 'namespace': 'default' - }, - secret_type='Opaque', - name=CA_SECRET_NAME - ) - # TODO: Support multiple participants - operator = client.get_deployment(OPERATOR_NAME) - new_args = list(filter(lambda arg: not arg.startswith('--ingress'), - operator.spec.template.spec.containers[0].args)) - new_args.extend([ - '--ingress-extra-host-suffix=".{}"'.format(domain_name), - '--ingress-client-auth-secret-name="default/ca-secret"', - '--ingress-enabled-client-auth=true', - '--ingress-secret-name={}'.format(SERVER_SECRET_NAME)]) - operator.spec.template.spec.containers[0].args = new_args - client.create_or_update_deployment(metadata=operator.metadata, - spec=operator.spec, - name=OPERATOR_NAME) - - # Create client certificate secret - client.create_or_update_secret( - data={ - 'client.pem': certificates.get('client/intermediate.pem'), - 'client.key': certificates.get('client/client.key'), - 'all.pem': client_all_pem - }, - metadata={ - 'name': client_secret_name - }, - secret_type='Opaque', - name=client_secret_name - ) - - # Update ingress-nginx-controller to load client secret - ingress_nginx_controller = client.get_deployment( - INGRESS_NGINX_CONTROLLER_NAME - ) - volumes = ingress_nginx_controller.spec.template.spec.volumes or [] - volumes = list(filter(lambda volume: volume.name != client_secret_name, - volumes)) - volumes.append({ - 'name': client_secret_name, - 'secret': { - 'secretName': client_secret_name - } - }) - volume_mounts = ingress_nginx_controller.spec.template\ - .spec.containers[0].volume_mounts or [] - volume_mounts = list(filter( - lambda mount: mount.name != client_secret_name, volume_mounts)) - volume_mounts.append( - { - 'mountPath': '/etc/{}/client/'.format(name), - 'name': client_secret_name - }) - ingress_nginx_controller.spec.template.spec.volumes = volumes - ingress_nginx_controller.spec.template\ - .spec.containers[0].volume_mounts = volume_mounts - client.create_or_update_deployment( - metadata=ingress_nginx_controller.metadata, - spec=ingress_nginx_controller.spec, - name=INGRESS_NGINX_CONTROLLER_NAME - ) - # TODO: check ingress-nginx-controller's health - - # Create ingress to forward request to peer - client.create_or_update_service( - metadata={ - 'name': name, - 'namespace': 'default' - }, - spec={ - 'externalName': ip, - 'type': 'ExternalName' - }, - name=name - ) - configuration_snippet_template = 'grpc_next_upstream_tries 5;\n'\ - 'grpc_set_header Host {0};\n'\ - 'grpc_set_header Authority {0};' - configuration_snippet = \ - configuration_snippet_template.format(custom_host or '$http_x_host') - client.create_or_update_ingress( - metadata={ - 'name': domain_name, - 'namespace': 'default', - 'annotations': { - 'kubernetes.io/ingress.class': 'nginx', - 'nginx.ingress.kubernetes.io/backend-protocol': 'GRPCS', - 'nginx.ingress.kubernetes.io/http2-insecure-port': 't', - 'nginx.ingress.kubernetes.io/configuration-snippet': - configuration_snippet - } - }, - spec={ - 'rules': [{ - 'host': domain_name, - 'http': { - 'paths': [ - { - 'path': '/', - 'backend': { - 'serviceName': name, - 'servicePort': port - } - } - ] - } - }] - }, - name=domain_name - ) - # In most case with external authorization mode, - # secrets are created by helm charts (deploy/charts/fedlearner-add-on). - # So use `ingress-nginx` as default. - # FIXME: change when supporting multi-peer - secret_path = name if os.environ.get('AUTHORIZATION_MODE') != 'EXTERNAL' \ - else 'ingress-nginx' - server_snippet_template = \ - 'grpc_ssl_verify on;\n'\ - 'grpc_ssl_server_name on;\n'\ - 'grpc_ssl_name {0};\n'\ - 'grpc_ssl_trusted_certificate /etc/{1}/client/all.pem;\n'\ - 'grpc_ssl_certificate /etc/{1}/client/client.pem;\n'\ - 'grpc_ssl_certificate_key /etc/{1}/client/client.key;' - server_snippet = server_snippet_template.format( - custom_host or '$http_x_host', secret_path) - client.create_or_update_ingress( - metadata={ - 'name': client_auth_ingress_name, - 'namespace': 'default', - 'annotations': { - 'kubernetes.io/ingress.class': 'nginx', - 'nginx.ingress.kubernetes.io/backend-protocol': 'GRPCS', - 'nginx.ingress.kubernetes.io/http2-insecure-port': 't', - 'nginx.ingress.kubernetes.io/configuration-snippet': - configuration_snippet, - 'nginx.ingress.kubernetes.io/server-snippet': server_snippet - } - }, - spec={ - 'rules': [{ - 'host': client_auth_ingress_name, - 'http': { - 'paths': [ - { - 'path': '/', - 'backend': { - 'serviceName': name, - 'servicePort': port - } - } - ] - } - }] - }, - name=client_auth_ingress_name - ) diff --git a/web_console_v2/api/fedlearner_webconsole/project/apis.py b/web_console_v2/api/fedlearner_webconsole/project/apis.py index c13a6a3ea..97e00c83b 100644 --- a/web_console_v2/api/fedlearner_webconsole/project/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/project/apis.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. @@ -13,282 +13,498 @@ # limitations under the License. # coding: utf-8 -# pylint: disable=raise-missing-from - -import re from enum import Enum -from uuid import uuid4 +from functools import partial +from http import HTTPStatus +from typing import Optional, Dict, Any, List -from sqlalchemy.sql import func -from flask import request -from flask_restful import Resource, Api, reqparse from google.protobuf.json_format import ParseDict +from flask_restful import Resource, Api +from marshmallow import Schema, fields, validate, post_load +from marshmallow.validate import Length +from envs import Envs +from fedlearner_webconsole.audit.decorators import emits_event from fedlearner_webconsole.db import db -from fedlearner_webconsole.project.models import Project -from fedlearner_webconsole.proto.common_pb2 import Variable, StatusCode -from fedlearner_webconsole.proto.project_pb2 \ - import Project as ProjectProto, CertificateStorage, \ - Participant as ParticipantProto -from fedlearner_webconsole.project.add_on \ - import parse_certificates, verify_certificates, create_add_on +from fedlearner_webconsole.iam.client import create_iams_for_resource +from fedlearner_webconsole.iam.iam_required import iam_required +from fedlearner_webconsole.iam.permission import Permission +from fedlearner_webconsole.participant.services import ParticipantService +from fedlearner_webconsole.participant.models import ProjectParticipant +from fedlearner_webconsole.project.controllers import PendingProjectRpcController +from fedlearner_webconsole.project.models import Project, PendingProjectState, ProjectRole, PendingProject +from fedlearner_webconsole.project.services import ProjectService, PendingProjectService +from fedlearner_webconsole.proto.common_pb2 import StatusCode, Variable from fedlearner_webconsole.exceptions \ - import InvalidArgumentException, NotFoundException + import InvalidArgumentException, NotFoundException, ResourceConflictException, InternalException +from fedlearner_webconsole.proto.project_pb2 import ProjectConfig +from fedlearner_webconsole.proto.review_pb2 import TicketType, TicketDetails +from fedlearner_webconsole.review.ticket_helper import get_ticket_helper from fedlearner_webconsole.rpc.client import RpcClient -from fedlearner_webconsole.utils.decorators import jwt_required -from fedlearner_webconsole.utils.k8s_client import k8s_client -from fedlearner_webconsole.workflow.models import Workflow - -_CERTIFICATE_FILE_NAMES = [ - 'client/client.pem', 'client/client.key', 'client/intermediate.pem', - 'client/root.pem', 'server/server.pem', 'server/server.key', - 'server/intermediate.pem', 'server/root.pem' -] - -_URL_REGEX = r'(?:^((?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])(?:\.' \ - r'(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])){3})(?::+' \ - r'(\d+))?$)|(?:^\[((?:(?:[0-9a-fA-F:]){1,4}(?:(?::(?:[0-9a-fA-F]' \ - r'){1,4}|:)){2,7})+)\](?::+(\d+))?|((?:(?:[0-9a-fA-F:]){1,4}(?:(' \ - r'?::(?:[0-9a-fA-F]){1,4}|:)){2,7})+)$)' +from fedlearner_webconsole.rpc.v2.project_service_client import ProjectServiceClient +from fedlearner_webconsole.swagger.models import schema_manager +from fedlearner_webconsole.utils.decorators.pp_flask import input_validator, use_args, use_kwargs +from fedlearner_webconsole.auth.third_party_sso import credentials_required +from fedlearner_webconsole.utils.flask_utils import get_current_user, make_flask_response, FilterExpField class ErrorMessage(Enum): PARAM_FORMAT_ERROR = 'Format of parameter {} is wrong: {}' - NAME_CONFLICT = 'Project name {} has been used.' + + +def _add_variable(config: Optional[Dict], field: str, value: Any) -> Dict: + config = config or {} + config['variables'] = config.get('variables', []) + for item in config['variables']: + if item['name'] == field: + return config + config['variables'].append({'name': field, 'value': value}) + return config + + +class CreateProjectParameter(Schema): + name = fields.String(required=True) + config = fields.Dict(load_default={}) + # System does not support multiple participants now + participant_ids = fields.List(fields.Integer(), validate=Length(equal=1)) + comment = fields.String(load_default='') + + +class CreatePendingProjectParameter(Schema): + name = fields.String(required=True) + config = fields.Dict(load_default={}) + participant_ids = fields.List(fields.Integer(), validate=Length(min=1)) + comment = fields.String(load_default='') + + @post_load() + def make(self, data, **kwargs): + data['config'] = ParseDict(data['config'], ProjectConfig(), ignore_unknown_fields=True) + return data class ProjectsApi(Resource): - @jwt_required() - def post(self): - parser = reqparse.RequestParser() - parser.add_argument('name', - required=True, - type=str, - help=ErrorMessage.PARAM_FORMAT_ERROR.value.format( - 'name', 'Empty')) - parser.add_argument('config', - required=True, - type=dict, - help=ErrorMessage.PARAM_FORMAT_ERROR.value.format( - 'config', 'Empty')) - parser.add_argument('comment') - data = parser.parse_args() + + @input_validator + @credentials_required + @iam_required(Permission.PROJECTS_POST) + @emits_event(audit_fields=['participant_ids']) + @use_args(CreateProjectParameter()) + def post(self, data: Dict): + """Creates a new project. + --- + tags: + - project + description: Creates a new project + parameters: + - in: body + name: body + schema: + $ref: '#/definitions/CreateProjectParameter' + responses: + 201: + description: Created a project + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Project' + """ name = data['name'] config = data['config'] comment = data['comment'] - - if Project.query.filter_by(name=name).first() is not None: - raise InvalidArgumentException( - details=ErrorMessage.NAME_CONFLICT.value.format(name)) - - if config.get('participants') is None: - raise InvalidArgumentException( - details=ErrorMessage.PARAM_FORMAT_ERROR.value.format( - 'participants', 'Empty')) - if len(config.get('participants')) != 1: - # TODO: remove limit after operator supports multiple participants - raise InvalidArgumentException( - details='Currently not support multiple participants.') - - # exact configuration from variables - # TODO: one custom host for one participant - grpc_ssl_server_host = None - egress_host = None - for variable in config.get('variables', []): - if variable.get('name') == 'GRPC_SSL_SERVER_HOST': - grpc_ssl_server_host = variable.get('value') - if variable.get('name') == 'EGRESS_HOST': - egress_host = variable.get('value') - - # parse participant - certificates = {} - for participant in config.get('participants'): - if 'name' not in participant.keys() or \ - 'domain_name' not in participant.keys(): - raise InvalidArgumentException( - details=ErrorMessage.PARAM_FORMAT_ERROR.value.format( - 'participants', 'Participant must have name and ' - 'domain_name.')) - domain_name = participant.get('domain_name') - # Grpc spec - participant['grpc_spec'] = { - 'authority': - egress_host or '{}-client-auth.com'.format(domain_name[:-4]) - } - - if participant.get('certificates'): - # If users use web console to create add-on, - # peer url must be given - if 'url' not in participant.keys(): + participant_ids = data['participant_ids'] + with db.session_scope() as session: + if session.query(Project).filter_by(name=name).first() is not None: + raise ResourceConflictException(message=f'Project name {name} has been used.') + + with db.session_scope() as session: + try: + user = get_current_user() + # defensive programming, if user is none, wont query user.username + new_project = Project(name=name, comment=comment, creator=user and user.username) + config = _add_variable(config, 'storage_root_path', Envs.STORAGE_ROOT) + try: + new_project.set_config(ParseDict(config, ProjectConfig())) + except Exception as e: raise InvalidArgumentException( - details=ErrorMessage.PARAM_FORMAT_ERROR.value.format( - 'participants', 'Participant must have url.')) - if re.match(_URL_REGEX, participant.get('url')) is None: - raise InvalidArgumentException('URL pattern is wrong') - - current_cert = parse_certificates( - participant.get('certificates')) - success, err = verify_certificates(current_cert) - if not success: - raise InvalidArgumentException(err) - certificates[domain_name] = {'certs': current_cert} - if 'certificates' in participant.keys(): - participant.pop('certificates') - - new_project = Project() - # generate token - # If users send a token, then use it instead. - # If `token` is None, generate a new one by uuid. - config['name'] = name - token = config.get('token', uuid4().hex) - config['token'] = token - - # check format of config - try: - new_project.set_config(ParseDict(config, ProjectProto())) - except Exception as e: - raise InvalidArgumentException( - details=ErrorMessage.PARAM_FORMAT_ERROR.value.format( - 'config', e)) - new_project.set_certificate( - ParseDict({'domain_name_to_cert': certificates}, - CertificateStorage())) - new_project.name = name - new_project.token = token - new_project.comment = comment - - # create add on - for participant in new_project.get_config().participants: - if participant.domain_name in\ - new_project.get_certificate().domain_name_to_cert.keys(): - _create_add_on( - participant, - new_project.get_certificate().domain_name_to_cert[ - participant.domain_name], grpc_ssl_server_host) - try: - new_project = db.session.merge(new_project) - db.session.commit() - except Exception as e: - raise InvalidArgumentException(details=str(e)) - - return {'data': new_project.to_dict()} - - @jwt_required() + details=ErrorMessage.PARAM_FORMAT_ERROR.value.format('config', e)) from e + session.add(new_project) + session.flush() + + for participant_id in participant_ids: + # insert a relationship into the table + new_relationship = ProjectParticipant(project_id=new_project.id, participant_id=participant_id) + session.add(new_relationship) + + create_iams_for_resource(new_project, user) + session.commit() + except Exception as e: + raise InvalidArgumentException(details=str(e)) from e + return make_flask_response(data=new_project.to_proto(), status=HTTPStatus.CREATED) + + @credentials_required def get(self): - # TODO: Not count soft-deleted workflow - projects = db.session.query( - Project, func.count(Workflow.id).label('num_workflow'))\ - .join(Workflow, Workflow.project_id == Project.id, isouter=True)\ - .group_by(Project.id)\ - .all() - result = [] - for project in projects: - project_dict = project.Project.to_dict() - project_dict['num_workflow'] = project.num_workflow - result.append(project_dict) - return {'data': result} + """Gets all projects. + --- + tags: + - project + description: gets all projects. + responses: + 200: + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.ProjectRef' + """ + with db.session_scope() as session: + service = ProjectService(session) + return make_flask_response(data=service.get_projects()) class ProjectApi(Resource): - @jwt_required() - def get(self, project_id): - project = Project.query.filter_by(id=project_id).first() - if project is None: - raise NotFoundException( - f'Failed to find project: {project_id}') - return {'data': project.to_dict()} - - @jwt_required() - def patch(self, project_id): - project = Project.query.filter_by(id=project_id).first() - if project is None: - raise NotFoundException( - f'Failed to find project: {project_id}') - config = project.get_config() - if request.json.get('token') is not None: - new_token = request.json.get('token') - config.token = new_token - project.token = new_token - if request.json.get('variables') is not None: - del config.variables[:] - config.variables.extend([ - ParseDict(variable, Variable()) - for variable in request.json.get('variables') - ]) - - # exact configuration from variables - grpc_ssl_server_host = None - egress_host = None - for variable in config.variables: - if variable.name == 'GRPC_SSL_SERVER_HOST': - grpc_ssl_server_host = variable.value - if variable.name == 'EGRESS_HOST': - egress_host = variable.value - - if request.json.get('participant_name'): - config.participants[0].name = request.json.get('participant_name') - - if request.json.get('comment'): - project.comment = request.json.get('comment') - - for participant in config.participants: - if participant.domain_name in\ - project.get_certificate().domain_name_to_cert.keys(): - _create_add_on( - participant, - project.get_certificate().domain_name_to_cert[ - participant.domain_name], grpc_ssl_server_host) - if egress_host: - participant.grpc_spec.authority = egress_host - project.set_config(config) - try: - db.session.commit() - except Exception as e: - raise InvalidArgumentException(details=e) - return {'data': project.to_dict()} + + @credentials_required + @iam_required(Permission.PROJECT_GET) + def get(self, project_id: int): + """Gets a project. + --- + tags: + - project + description: Gets a project + parameters: + - in: path + name: project_id + schema: + type: integer + responses: + 200: + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Project' + """ + with db.session_scope() as session: + project = session.query(Project).filter_by(id=project_id).first() + if project is None: + raise NotFoundException(f'Failed to find project: {project_id}') + return make_flask_response(data=project.to_proto(), status=HTTPStatus.OK) + + @input_validator + @credentials_required + @iam_required(Permission.PROJECT_PATCH) + @emits_event(audit_fields=['variables']) + @use_kwargs({ + 'comment': fields.String(load_default=None), + 'variables': fields.List(fields.Dict(), load_default=None), + 'config': fields.Dict(load_default=None) + }) + def patch(self, project_id: int, comment: Optional[str], variables: Optional[List[Dict]], config: Optional[Dict]): + """Patch a project. + --- + tags: + - project + description: Update a project. + parameters: + - in: path + name: project_id + schema: + type: integer + - in: body + name: body + schema: + type: object + properties: + comment: + type: string + variables: + description: A list of variables to override existing ones. + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.Variable' + config: + description: Config of project, include variables. + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.ProjectConfig' + responses: + 200: + description: Updated project + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.Project' + """ + with db.session_scope() as session: + project = session.query(Project).filter_by(id=project_id).first() + if project is None: + raise NotFoundException(f'Failed to find project: {project_id}') + + if comment: + project.comment = comment + + if config is not None: + config_proto = ParseDict(config, ProjectConfig(), ignore_unknown_fields=True) + project.set_config(config_proto) + session.flush() + # TODO(xiangyuxuan.prs): remove variables parameter when pending project launch + if variables is not None: + # Overrides all variables + variables = [ParseDict(variable, Variable()) for variable in variables] + project.set_variables(variables) + try: + session.commit() + except Exception as e: + raise InvalidArgumentException(details=e) from e + + return make_flask_response(data=project.to_proto(), status=HTTPStatus.OK) class CheckConnectionApi(Resource): - @jwt_required() - def post(self, project_id): - project = Project.query.filter_by(id=project_id).first() - if project is None: - raise NotFoundException( - f'Failed to find project: {project_id}') - success = True - details = [] - # TODO: Concurrently check - for participant in project.get_config().participants: - result = self.check_connection(project.get_config(), participant) - success = success & (result.code == StatusCode.STATUS_SUCCESS) + + @credentials_required + def get(self, project_id: int): + """Checks the connection for a project. + --- + tags: + - project + description: Checks the connection for a project. + parameters: + - in: path + name: project_id + schema: + type: integer + responses: + 200: + content: + application/json: + schema: + type: object + properties: + success: + description: If the connection is established or not. + type: boolean + message: + type: string + """ + with db.session_scope() as session: + project = session.query(Project).filter_by(id=project_id).first() + if project is None: + raise NotFoundException(f'Failed to find project: {project_id}') + service = ParticipantService(session) + participants = service.get_platform_participants_by_project(project.id) + + error_messages = [] + for participant in participants: + client = RpcClient.from_project_and_participant(project.name, project.token, participant.domain_name) + result = client.check_connection().status if result.code != StatusCode.STATUS_SUCCESS: - details.append(result.msg) - return {'data': {'success': success, 'details': details}} + error_messages.append( + f'failed to validate {participant.domain_name}\'s workspace, result: {result.msg}') - def check_connection(self, project_config: ProjectProto, - participant_proto: ParticipantProto): - client = RpcClient(project_config, participant_proto) - return client.check_connection().status + return { + 'data': { + 'success': len(error_messages) == 0, + 'message': '\n'.join(error_messages) if len(error_messages) > 0 else 'validate project successfully!' + } + }, HTTPStatus.OK + + +class ProjectParticipantsApi(Resource): + + @credentials_required + def get(self, project_id: int): + """Gets participants of a project. + --- + tags: + - project + description: Gets participants of a project. + parameters: + - in: path + name: project_id + schema: + type: integer + responses: + 200: + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.Participant' + """ + with db.session_scope() as session: + project = session.query(Project).filter_by(id=project_id).first() + if project is None: + raise NotFoundException(f'Failed to find project: {project_id}') + service = ParticipantService(session) + participants = service.get_participants_by_project(project_id) + return make_flask_response(data=[participant.to_proto() for participant in participants]) + + +class PendingProjectsApi(Resource): + + @input_validator + @credentials_required + @iam_required(Permission.PROJECTS_POST) + @use_args(CreatePendingProjectParameter()) + def post(self, data: Dict): + """Creates a new pending project. + --- + tags: + - project + description: Creates a new pending project + parameters: + - in: body + name: body + schema: + $ref: '#/definitions/CreatePendingProjectParameter' + responses: + 201: + description: Created a pending project + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.PendingProjectPb' + """ + with db.session_scope() as session: + # TODO(xiangyuxuan.prs): remove after using token instead of name to ensure consistency of project + if PendingProjectService(session).duplicated_name_exists(data['name']): + raise ResourceConflictException(f'{data["name"]} has already existed') + participants_info = PendingProjectService(session).build_participants_info(data['participant_ids']) + pending_project = PendingProjectService(session).create_pending_project(data['name'], + data['config'], + participants_info, + data['comment'], + get_current_user().username, + state=PendingProjectState.ACCEPTED, + role=ProjectRole.COORDINATOR) + session.flush() + ticket_helper = get_ticket_helper(session) + ticket_helper.create_ticket(TicketType.CREATE_PROJECT, TicketDetails(uuid=pending_project.uuid)) + session.commit() + return make_flask_response(data=pending_project.to_proto(), status=HTTPStatus.CREATED) + + @credentials_required + @use_args( + { + 'filter': FilterExpField( + required=False, + load_default=None, + ), + 'page': fields.Integer(required=False, load_default=1), + 'page_size': fields.Integer(required=False, load_default=10) + }, + location='query') + def get(self, params: dict): + """Gets all pending projects. + --- + tags: + - project + description: gets all pending projects. + responses: + 200: + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.PendingProjectPb' + """ + with db.session_scope() as session: + try: + pagination = PendingProjectService(session).list_pending_projects( + filter_exp=params['filter'], + page=params['page'], + page_size=params['page_size'], + ) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid filter: {str(e)}') from e + data = [t.to_proto() for t in pagination.get_items()] + return make_flask_response(data=data, page_meta=pagination.get_metadata()) + + +class PendingProjectApi(Resource): + + @credentials_required + @use_kwargs({ + 'state': + fields.String(required=True, + validate=validate.OneOf([PendingProjectState.ACCEPTED.name, PendingProjectState.CLOSED.name])) + }) + def patch(self, pending_project_id: int, state: str): + """Accept or refuse a pending project. + --- + tags: + - project + description: Accept or refuse a pending project. + parameters: + - in: path + name: pending_project_id + schema: + type: integer + - in: body + name: body + schema: + type: object + properties: + state: + type: string + responses: + 200: + description: a pending project + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.PendingProjectPb' + """ + with db.session_scope() as session: + pending_project = PendingProjectService(session).update_state_as_participant(pending_project_id, state) + resp = PendingProjectRpcController(pending_project).sync_pending_project_state_to_coordinator( + uuid=pending_project.uuid, state=PendingProjectState(state)) + if not resp.succeeded: + raise InternalException(f'connect to coordinator failed: {resp.msg}') + session.commit() + return make_flask_response(data=pending_project.to_proto()) + + def delete(self, pending_project_id: int): + """Delete pending project by id. + --- + tags: + - project + description: Delete pending project. + parameters: + - in: path + name: pending_project_id + required: true + schema: + type: integer + description: The ID of the pending project + responses: + 204: + description: No content. + """ + with db.session_scope() as session: + pending_project = session.query(PendingProject).get(pending_project_id) + if pending_project is None: + return make_flask_response(status=HTTPStatus.NO_CONTENT) + result = PendingProjectRpcController(pending_project).send_to_participants( + partial(ProjectServiceClient.delete_pending_project, uuid=pending_project.uuid)) + if not all(resp.succeeded for resp in result.values()): + raise InternalException(f'delete participants failed: {result}') + with db.session_scope() as session: + session.delete(pending_project) + session.commit() + return make_flask_response(status=HTTPStatus.NO_CONTENT) def initialize_project_apis(api: Api): api.add_resource(ProjectsApi, '/projects') api.add_resource(ProjectApi, '/projects/') - api.add_resource(CheckConnectionApi, - '/projects//connection_checks') - - -def _create_add_on(participant, certificate, grpc_ssl_server_host=None): - if certificate is None: - return - # check validation - for file_name in _CERTIFICATE_FILE_NAMES: - if certificate.certs.get(file_name) is None: - raise InvalidArgumentException( - details=ErrorMessage.PARAM_FORMAT_ERROR.value.format( - 'certificates', '{} not existed'.format(file_name))) - try: - create_add_on(k8s_client, participant.domain_name, participant.url, - certificate.certs, grpc_ssl_server_host) - except RuntimeError as e: - raise InvalidArgumentException(details=str(e)) + api.add_resource(ProjectParticipantsApi, '/projects//participants') + api.add_resource(CheckConnectionApi, '/projects//connection_checks') + + api.add_resource(PendingProjectsApi, '/pending_projects') + api.add_resource(PendingProjectApi, '/pending_project/') + + schema_manager.append(CreateProjectParameter) + schema_manager.append(CreatePendingProjectParameter) diff --git a/web_console_v2/api/fedlearner_webconsole/project/models.py b/web_console_v2/api/fedlearner_webconsole/project/models.py index 464d2877d..3b054d22d 100644 --- a/web_console_v2/api/fedlearner_webconsole/project/models.py +++ b/web_console_v2/api/fedlearner_webconsole/project/models.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,67 +13,217 @@ # limitations under the License. # coding: utf-8 +import enum +from typing import Optional, List, Tuple +from google.protobuf import text_format from sqlalchemy.sql import func from sqlalchemy.sql.schema import Index, UniqueConstraint -from fedlearner_webconsole.utils.mixins import to_dict_mixin -from fedlearner_webconsole.db import db + +from fedlearner_webconsole.proto.project_pb2 import ProjectRef, ParticipantsInfo, ProjectConfig, ParticipantInfo +from fedlearner_webconsole.utils.base_model.review_ticket_model import ReviewTicketModel +from fedlearner_webconsole.utils.base_model.softdelete_model import SoftDeleteModel +from fedlearner_webconsole.utils.pp_datetime import to_timestamp +from fedlearner_webconsole.proto.common_pb2 import Variable +from fedlearner_webconsole.db import db, default_table_args from fedlearner_webconsole.proto import project_pb2 +from fedlearner_webconsole.participant.models import ParticipantType + + +class PendingProjectState(enum.Enum): + PENDING = 'PENDING' + ACCEPTED = 'ACCEPTED' + FAILED = 'FAILED' + CLOSED = 'CLOSED' + + +class ProjectRole(enum.Enum): + COORDINATOR = 'COORDINATOR' + PARTICIPANT = 'PARTICIPANT' + + +class Action(enum.Enum): + ID_ALIGNMENT = 'ID_ALIGNMENT' + DATA_ALIGNMENT = 'DATA_ALIGNMENT' + HORIZONTAL_TRAIN = 'HORIZONTAL_TRAIN' + VERTICAL_TRAIN = 'VERTICAL_TRAIN' + VERTICAL_EVAL = 'VERTICAL_EVAL' + VERTICAL_PRED = 'VERTICAL_PRED' + VERTICAL_SERVING = 'VERTICAL_SERVING' + WORKFLOW = 'WORKFLOW' + TEE_SERVICE = 'TEE_SERVICE' + TEE_RESULT_EXPORT = 'TEE_SERVICE' -@to_dict_mixin(ignores=['certificate'], - extras={'config': (lambda project: project.get_config())}) class Project(db.Model): __tablename__ = 'projects_v2' - __table_args__ = (UniqueConstraint('name', name='idx_name'), - Index('idx_token', 'token'), { - 'comment': 'webconsole projects', - 'mysql_engine': 'innodb', - 'mysql_charset': 'utf8mb4', - }) - id = db.Column(db.Integer, - primary_key=True, - autoincrement=True, - comment='id') + __table_args__ = (UniqueConstraint('name', name='idx_name'), Index('idx_token', 'token'), { + 'comment': 'webconsole projects', + 'mysql_engine': 'innodb', + 'mysql_charset': 'utf8mb4', + }) + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='id') + role = db.Column(db.Enum(ProjectRole, length=32, native_enum=False, create_constraint=False), + default=ProjectRole.PARTICIPANT, + comment='pending project role') + participants_info = db.Column(db.Text(), comment='participants info') + name = db.Column(db.String(255), comment='name') token = db.Column(db.String(64), comment='token') config = db.Column(db.LargeBinary(), comment='config') - certificate = db.Column(db.LargeBinary(), comment='certificate') comment = db.Column('cmt', db.Text(), key='comment', comment='comment') - created_at = db.Column(db.DateTime(timezone=True), - server_default=func.now(), - comment='created at') + creator = db.Column(db.String(255), comment='creator') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created at') updated_at = db.Column(db.DateTime(timezone=True), onupdate=func.now(), server_default=func.now(), comment='updated at') deleted_at = db.Column(db.DateTime(timezone=True), comment='deleted at') + participants = db.relationship('Participant', + secondary='projects_participants_v2', + primaryjoin='Project.id == foreign(ProjectParticipant.project_id)', + secondaryjoin='Participant.id == foreign(ProjectParticipant.participant_id)') - def set_config(self, proto): + def set_config(self, proto: project_pb2.ProjectConfig): self.config = proto.SerializeToString() - def get_config(self): - if self.config is None: - return None - proto = project_pb2.Project() - proto.ParseFromString(self.config) - return proto + def _get_config(self) -> project_pb2.ProjectConfig: + config = project_pb2.ProjectConfig() + if self.config: + config.ParseFromString(self.config) + return config + + def get_variables(self) -> List[Variable]: + return list(self._get_config().variables) + + def set_variables(self, variables: List[Variable]): + config = self._get_config() + del config.variables[:] + config.variables.extend(variables) + self.set_config(config) - def set_certificate(self, proto): - self.certificate = proto.SerializeToString() + def get_storage_root_path(self, dft_value: str) -> str: + variables = self.get_variables() + for variable in variables: + if variable.name == 'storage_root_path': + return variable.value + return dft_value - def get_certificate(self): - if self.certificate is None: + def get_participant_type(self) -> Optional[ParticipantType]: + if len(self.participants) == 0: return None - proto = project_pb2.CertificateStorage() - proto.ParseFromString(self.certificate) + return self.participants[0].get_type() + + def set_participants_info(self, proto: ParticipantsInfo): + self.participants_info = text_format.MessageToString(proto) + + def get_participants_info(self) -> ParticipantsInfo: + if self.participants_info is not None: + return text_format.Parse(self.participants_info, ParticipantsInfo()) + return ParticipantsInfo() + + def to_ref(self) -> ProjectRef: + participant_type = self.get_participant_type() + ref = ProjectRef(id=self.id, + name=self.name, + creator=self.creator, + created_at=to_timestamp(self.created_at), + participant_type=participant_type.name if participant_type else None, + participants_info=self.get_participants_info(), + role=self.role.name if self.role else None) + for participant in self.participants: + ref.participants.append(participant.to_proto()) + return ref + + def to_proto(self) -> project_pb2.Project: + participant_type = self.get_participant_type() + proto = project_pb2.Project(id=self.id, + name=self.name, + creator=self.creator, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + participant_type=participant_type.name if participant_type else None, + token=self.token, + comment=self.comment, + variables=self.get_variables(), + participants_info=self.get_participants_info(), + config=self._get_config(), + role=self.role.name if self.role else None) + for participant in self.participants: + proto.participants.append(participant.to_proto()) return proto - def get_namespace(self): - config = self.get_config() - if config is not None: - variables = self.get_config().variables - for variable in variables: - if variable.name == 'namespace': - return variable.value - return 'default' + +class PendingProject(db.Model, SoftDeleteModel, ReviewTicketModel): + __tablename__ = 'pending_projects_v2' + __table_args__ = (default_table_args('This is webconsole pending_project table')) + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='id') + name = db.Column(db.String(255), comment='name') + uuid = db.Column(db.String(64), comment='uuid') + config = db.Column(db.Text(), comment='config') + state = db.Column(db.Enum(PendingProjectState, length=32, native_enum=False, create_constraint=False), + nullable=False, + default=PendingProjectState.PENDING, + comment='pending project stage state') + participants_info = db.Column(db.Text(), comment='participants info') + role = db.Column(db.Enum(ProjectRole, length=32, native_enum=False, create_constraint=False), + nullable=False, + default=ProjectRole.PARTICIPANT, + comment='pending project role') + + comment = db.Column('cmt', db.Text(), key='comment', comment='comment') + creator_username = db.Column(db.String(255), comment='creator') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created at') + updated_at = db.Column(db.DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + comment='updated at') + + def set_participants_info(self, proto: ParticipantsInfo): + self.participants_info = text_format.MessageToString(proto) + + def get_participants_info(self) -> ParticipantsInfo: + if self.participants_info is not None: + return text_format.Parse(self.participants_info, ParticipantsInfo()) + return ParticipantsInfo() + + def set_config(self, proto: ProjectConfig): + self.config = text_format.MessageToString(proto) + + def get_config(self) -> ProjectConfig: + if self.config is not None: + return text_format.Parse(self.config, ProjectConfig()) + return ProjectConfig() + + def to_proto(self) -> project_pb2.PendingProjectPb: + return project_pb2.PendingProjectPb(id=self.id, + name=self.name, + uuid=self.uuid, + config=self.get_config(), + state=self.state.name, + participants_info=self.get_participants_info(), + role=self.role.name, + comment=self.comment, + creator_username=self.creator_username, + created_at=to_timestamp(self.created_at), + updated_at=to_timestamp(self.updated_at), + ticket_uuid=self.ticket_uuid, + ticket_status=self.ticket_status.name, + participant_type=self.get_participant_type()) + + def get_participant_info(self, pure_domain: str) -> Optional[ParticipantInfo]: + return self.get_participants_info().participants_map.get(pure_domain) + + def get_coordinator_info(self) -> Tuple[str, ParticipantInfo]: + for pure_domain, p_info in self.get_participants_info().participants_map.items(): + if p_info.role == ProjectRole.COORDINATOR.name: + return pure_domain, p_info + raise ValueError(f'not found coordinator in pending project {self.id}') + + def get_participant_type(self) -> str: + # In the short term, the project will only have one type of participants, + # make pending project type hack to be the type of the first participant. + for info in self.get_participants_info().participants_map.values(): + if info.role == ProjectRole.PARTICIPANT.name: + return info.type + return ParticipantType.LIGHT_CLIENT.name diff --git a/web_console_v2/api/fedlearner_webconsole/proto/__init__.py b/web_console_v2/api/fedlearner_webconsole/proto/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/web_console_v2/api/fedlearner_webconsole/rpc/client.py b/web_console_v2/api/fedlearner_webconsole/rpc/client.py index 726568ad6..829778325 100644 --- a/web_console_v2/api/fedlearner_webconsole/rpc/client.py +++ b/web_console_v2/api/fedlearner_webconsole/rpc/client.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,159 +17,212 @@ import logging from functools import wraps +from typing import Optional import grpc +from google.protobuf import empty_pb2 from envs import Envs -from fedlearner_webconsole.exceptions import ( - UnauthorizedException, InvalidArgumentException -) -from fedlearner_webconsole.proto import ( - service_pb2, service_pb2_grpc, common_pb2 -) -from fedlearner_webconsole.utils.decorators import retry_fn - - -def _build_channel(url, authority): - """A helper function to build gRPC channel for easy testing.""" - return grpc.insecure_channel( - target=url, +from fedlearner_webconsole.utils.decorators.lru_cache import lru_cache +from fedlearner_webconsole.utils.decorators.retry import retry_fn +from fedlearner_webconsole.exceptions import (UnauthorizedException, InvalidArgumentException) +from fedlearner_webconsole.proto import (dataset_pb2, service_pb2, service_pb2_grpc, common_pb2) +from fedlearner_webconsole.proto.service_pb2_grpc import WebConsoleV2ServiceStub +from fedlearner_webconsole.proto.serving_pb2 import ServingServiceType +from fedlearner_webconsole.proto.two_pc_pb2 import TwoPcType, TwoPcAction, TransactionData +from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition +from fedlearner_webconsole.rpc.client_interceptor import ClientInterceptor +from fedlearner_webconsole.rpc.v2.client_base import get_nginx_controller_url + + +@lru_cache(timeout=60, maxsize=100) +def _build_grpc_stub(egress_url: str, authority: str) -> WebConsoleV2ServiceStub: + """A helper function to build gRPC stub with cache. + + Notice that as we cache the stub, if nginx controller gets restarted, the channel may break. + This practice is following official best practice: https://grpc.io/docs/guides/performance/ + + Args: + egress_url: nginx controller url in current cluster. + authority: ingress domain in current cluster. + + Returns: + A grpc service stub to call API. + """ + channel = grpc.insecure_channel( + target=egress_url, # options defined at # https://github.com/grpc/grpc/blob/master/include/grpc/impl/codegen/grpc_types.h options=[('grpc.default_authority', authority)]) + channel = grpc.intercept_channel(channel, ClientInterceptor()) + return service_pb2_grpc.WebConsoleV2ServiceStub(channel) +# TODO(linfan.fine): refactor catch_and_fallback def catch_and_fallback(resp_class): + def decorator(f): + @wraps(f) def wrapper(*args, **kwargs): try: return f(*args, **kwargs) except grpc.RpcError as e: - return resp_class(status=common_pb2.Status( - code=common_pb2.STATUS_UNKNOWN_ERROR, msg=repr(e))) + return resp_class(status=common_pb2.Status(code=common_pb2.STATUS_UNKNOWN_ERROR, msg=repr(e))) return wrapper return decorator +def _need_retry_for_get(err: Exception) -> bool: + if not isinstance(err, grpc.RpcError): + return False + # No need to retry for NOT_FOUND + return err.code() != grpc.StatusCode.NOT_FOUND + + +def _default_need_retry(err: Exception) -> bool: + return isinstance(err, grpc.RpcError) + + class RpcClient(object): - def __init__(self, project_config, receiver_config): - self._project = project_config - self._receiver = receiver_config - self._auth_info = service_pb2.ProjAuthInfo( - project_name=self._project.name, - target_domain=self._receiver.domain_name, - auth_token=self._project.token) - - egress_url = 'fedlearner-stack-ingress-nginx-controller.default.svc:80' - for variable in self._project.variables: - if variable.name == 'EGRESS_URL': - egress_url = variable.value - break - self._client = service_pb2_grpc.WebConsoleV2ServiceStub( - _build_channel(egress_url, self._receiver.grpc_spec.authority)) + + def __init__(self, + egress_url: str, + authority: str, + x_host: str, + project_auth_info: Optional[service_pb2.ProjAuthInfo] = None): + """Inits rpc client. + + Args: + egress_url: nginx controller url in current cluster. + authority: ingress domain in current cluster. + x_host: ingress domain in target cluster, nginx will handle the + rewriting. + project_auth_info: info for project level authentication. + """ + self._x_host = x_host + self._project_auth_info = project_auth_info + + self._client = _build_grpc_stub(egress_url, authority) + + @classmethod + def from_project_and_participant(cls, project_name: str, project_token: str, domain_name: str): + # Builds auth info from project and receiver + auth_info = service_pb2.ProjAuthInfo(project_name=project_name, + target_domain=domain_name, + auth_token=project_token) + return cls(egress_url=get_nginx_controller_url(), + authority=gen_egress_authority(domain_name), + x_host=gen_x_host(domain_name), + project_auth_info=auth_info) + + @classmethod + def from_participant(cls, domain_name: str): + return cls(egress_url=get_nginx_controller_url(), + authority=gen_egress_authority(domain_name), + x_host=gen_x_host(domain_name)) def _get_metadata(self): - metadata = [] - x_host_prefix = 'fedlearner-webconsole-v2' - for variable in self._project.variables: - if variable.name == 'X_HOST': - x_host_prefix = variable.value - break - metadata.append(('x-host', '{}.{}'.format(x_host_prefix, - self._receiver.domain_name))) - for key, value in self._receiver.grpc_spec.extra_headers.items(): - metadata.append((key, value)) # metadata is a tuple of tuples - return tuple(metadata) + return tuple([('x-host', self._x_host)]) @catch_and_fallback(resp_class=service_pb2.CheckConnectionResponse) - @retry_fn(retry_times=3, needed_exceptions=[grpc.RpcError]) + @retry_fn(retry_times=3, need_retry=_default_need_retry) def check_connection(self): - msg = service_pb2.CheckConnectionRequest(auth_info=self._auth_info) - response = self._client.CheckConnection( - request=msg, - metadata=self._get_metadata(), - timeout=Envs.GRPC_CLIENT_TIMEOUT) + msg = service_pb2.CheckConnectionRequest(auth_info=self._project_auth_info) + response = self._client.CheckConnection(request=msg, + metadata=self._get_metadata(), + timeout=Envs.GRPC_CLIENT_TIMEOUT) if response.status.code != common_pb2.STATUS_SUCCESS: - logging.debug('check_connection request error: %s', - response.status.msg) + logging.debug('check_connection request error: %s', response.status.msg) + return response + + @catch_and_fallback(resp_class=service_pb2.CheckPeerConnectionResponse) + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def check_peer_connection(self): + # TODO(taoyanting): double check + msg = service_pb2.CheckPeerConnectionRequest() + response = self._client.CheckPeerConnection(request=msg, + metadata=self._get_metadata(), + timeout=Envs.GRPC_CLIENT_TIMEOUT) + if response.status.code != common_pb2.STATUS_SUCCESS: + logging.debug('check_connection request error: %s', response.status.msg) return response @catch_and_fallback(resp_class=service_pb2.UpdateWorkflowStateResponse) - @retry_fn(retry_times=3, needed_exceptions=[grpc.RpcError]) - def update_workflow_state(self, name, state, target_state, - transaction_state, uuid, forked_from_uuid, - extra=''): - msg = service_pb2.UpdateWorkflowStateRequest( - auth_info=self._auth_info, - workflow_name=name, - state=state.value, - target_state=target_state.value, - transaction_state=transaction_state.value, - uuid=uuid, - forked_from_uuid=forked_from_uuid, - extra=extra - ) - response = self._client.UpdateWorkflowState( - request=msg, metadata=self._get_metadata(), - timeout=Envs.GRPC_CLIENT_TIMEOUT) + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def update_workflow_state(self, name, state, target_state, transaction_state, uuid, forked_from_uuid, extra=''): + msg = service_pb2.UpdateWorkflowStateRequest(auth_info=self._project_auth_info, + workflow_name=name, + state=state.value, + target_state=target_state.value, + transaction_state=transaction_state.value, + uuid=uuid, + forked_from_uuid=forked_from_uuid, + extra=extra) + response = self._client.UpdateWorkflowState(request=msg, + metadata=self._get_metadata(), + timeout=Envs.GRPC_CLIENT_TIMEOUT) if response.status.code != common_pb2.STATUS_SUCCESS: - logging.debug('update_workflow_state request error: %s', - response.status.msg) + logging.debug('update_workflow_state request error: %s', response.status.msg) return response @catch_and_fallback(resp_class=service_pb2.GetWorkflowResponse) - @retry_fn(retry_times=3, needed_exceptions=[grpc.RpcError]) - def get_workflow(self, name): - msg = service_pb2.GetWorkflowRequest(auth_info=self._auth_info, - workflow_name=name) + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def get_workflow(self, uuid, name): + msg = service_pb2.GetWorkflowRequest(auth_info=self._project_auth_info, workflow_name=name, workflow_uuid=uuid) response = self._client.GetWorkflow(request=msg, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) if response.status.code != common_pb2.STATUS_SUCCESS: - logging.debug('get_workflow request error: %s', - response.status.msg) + logging.debug('get_workflow request error: %s', response.status.msg) return response @catch_and_fallback(resp_class=service_pb2.UpdateWorkflowResponse) - @retry_fn(retry_times=3, needed_exceptions=[grpc.RpcError]) - def update_workflow(self, name, config): - msg = service_pb2.UpdateWorkflowRequest(auth_info=self._auth_info, + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def update_workflow(self, uuid, name, config): + msg = service_pb2.UpdateWorkflowRequest(auth_info=self._project_auth_info, workflow_name=name, + workflow_uuid=uuid, config=config) response = self._client.UpdateWorkflow(request=msg, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) if response.status.code != common_pb2.STATUS_SUCCESS: - logging.debug('update_workflow request error: %s', - response.status.msg) + logging.debug('update_workflow request error: %s', response.status.msg) + return response + + @catch_and_fallback(resp_class=service_pb2.InvalidateWorkflowResponse) + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def invalidate_workflow(self, uuid: str): + msg = service_pb2.InvalidateWorkflowRequest(auth_info=self._project_auth_info, workflow_uuid=uuid) + response = self._client.InvalidateWorkflow(request=msg, + metadata=self._get_metadata(), + timeout=Envs.GRPC_CLIENT_TIMEOUT) + if response.status.code != common_pb2.STATUS_SUCCESS: + logging.debug('invalidate_workflow request error: %s', response.status.msg) return response @catch_and_fallback(resp_class=service_pb2.GetJobMetricsResponse) - @retry_fn(retry_times=3, needed_exceptions=[grpc.RpcError]) + @retry_fn(retry_times=3, need_retry=_default_need_retry) def get_job_metrics(self, job_name): - msg = service_pb2.GetJobMetricsRequest(auth_info=self._auth_info, - job_name=job_name) + msg = service_pb2.GetJobMetricsRequest(auth_info=self._project_auth_info, job_name=job_name) response = self._client.GetJobMetrics(request=msg, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) if response.status.code != common_pb2.STATUS_SUCCESS: - logging.debug('get_job_metrics request error: %s', - response.status.msg) + logging.debug('get_job_metrics request error: %s', response.status.msg) return response @catch_and_fallback(resp_class=service_pb2.GetJobMetricsResponse) - @retry_fn(retry_times=3, needed_exceptions=[grpc.RpcError]) + @retry_fn(retry_times=3, need_retry=_default_need_retry) def get_job_kibana(self, job_name, json_args): - msg = service_pb2.GetJobKibanaRequest(auth_info=self._auth_info, - job_name=job_name, - json_args=json_args) + msg = service_pb2.GetJobKibanaRequest(auth_info=self._project_auth_info, job_name=job_name, json_args=json_args) response = self._client.GetJobKibana(request=msg, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) @@ -179,14 +232,13 @@ def get_job_kibana(self, job_name, json_args): raise UnauthorizedException(status.msg) if status.code == common_pb2.STATUS_INVALID_ARGUMENT: raise InvalidArgumentException(status.msg) - logging.debug('get_job_kibana request error: %s', - response.status.msg) + logging.debug('get_job_kibana request error: %s', response.status.msg) return response @catch_and_fallback(resp_class=service_pb2.GetJobEventsResponse) - @retry_fn(retry_times=3, needed_exceptions=[grpc.RpcError]) + @retry_fn(retry_times=3, need_retry=_default_need_retry) def get_job_events(self, job_name, start_time, max_lines): - msg = service_pb2.GetJobEventsRequest(auth_info=self._auth_info, + msg = service_pb2.GetJobEventsRequest(auth_info=self._project_auth_info, job_name=job_name, start_time=start_time, max_lines=max_lines) @@ -195,21 +247,135 @@ def get_job_events(self, job_name, start_time, max_lines): timeout=Envs.GRPC_CLIENT_TIMEOUT) if response.status.code != common_pb2.STATUS_SUCCESS: - logging.debug('get_job_events request error: %s', - response.status.msg) + logging.debug('get_job_events request error: %s', response.status.msg) return response @catch_and_fallback(resp_class=service_pb2.CheckJobReadyResponse) - @retry_fn(retry_times=3, needed_exceptions=[grpc.RpcError]) + @retry_fn(retry_times=3, need_retry=_default_need_retry) def check_job_ready(self, job_name: str) \ -> service_pb2.CheckJobReadyResponse: - msg = service_pb2.CheckJobReadyRequest(auth_info=self._auth_info, - job_name=job_name) + msg = service_pb2.CheckJobReadyRequest(auth_info=self._project_auth_info, job_name=job_name) response = self._client.CheckJobReady(request=msg, timeout=Envs.GRPC_CLIENT_TIMEOUT, metadata=self._get_metadata()) if response.status.code != common_pb2.STATUS_SUCCESS: - logging.debug('check_job_ready request error: %s', - response.status.msg) + logging.debug('check_job_ready request error: %s', response.status.msg) + return response + + @catch_and_fallback(resp_class=service_pb2.TwoPcResponse) + @retry_fn(retry_times=3, need_retry=_default_need_retry, delay=200, backoff=2) + def run_two_pc(self, transaction_uuid: str, two_pc_type: TwoPcType, action: TwoPcAction, + data: TransactionData) -> service_pb2.TwoPcResponse: + msg = service_pb2.TwoPcRequest(auth_info=self._project_auth_info, + transaction_uuid=transaction_uuid, + type=two_pc_type, + action=action, + data=data) + response = self._client.Run2Pc(request=msg, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) + return response + + @catch_and_fallback(resp_class=service_pb2.ServingServiceResponse) + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def operate_serving_service(self, operation_type: ServingServiceType, serving_model_uuid: str, model_uuid: str, + name: str): + msg = service_pb2.ServingServiceRequest(auth_info=self._project_auth_info, + operation_type=operation_type, + serving_model_uuid=serving_model_uuid, + model_uuid=model_uuid, + serving_model_name=name) + response = self._client.ServingServiceManagement(request=msg, + metadata=self._get_metadata(), + timeout=Envs.GRPC_CLIENT_TIMEOUT) + + if response.status.code != common_pb2.STATUS_SUCCESS: + logging.debug('serving_service request error: %s', response.status.msg) + return response + + @catch_and_fallback(resp_class=service_pb2.ServingServiceInferenceResponse) + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def inference_serving_service(self, serving_model_uuid: str, example_id: str): + msg = service_pb2.ServingServiceInferenceRequest(auth_info=self._project_auth_info, + serving_model_uuid=serving_model_uuid, + example_id=example_id) + response = self._client.ServingServiceInference(request=msg, + metadata=self._get_metadata(), + timeout=Envs.GRPC_CLIENT_TIMEOUT) + + if response.status.code != common_pb2.STATUS_SUCCESS: + logging.debug('serving_service request error: %s', response.status.msg) return response + + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def get_model_job(self, model_job_uuid: str, need_metrics: bool = False) -> service_pb2.GetModelJobResponse: + request = service_pb2.GetModelJobRequest(auth_info=self._project_auth_info, + uuid=model_job_uuid, + need_metrics=need_metrics) + return self._client.GetModelJob(request, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) + + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def get_model_job_group(self, model_job_group_uuid: str) -> service_pb2.GetModelJobGroupResponse: + request = service_pb2.GetModelJobGroupRequest(auth_info=self._project_auth_info, uuid=model_job_group_uuid) + return self._client.GetModelJobGroup(request, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) + + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def update_model_job_group(self, model_job_group_uuid: str, + config: WorkflowDefinition) -> service_pb2.UpdateModelJobGroupResponse: + request = service_pb2.UpdateModelJobGroupRequest(auth_info=self._project_auth_info, + uuid=model_job_group_uuid, + config=config) + return self._client.UpdateModelJobGroup(request, + metadata=self._get_metadata(), + timeout=Envs.GRPC_CLIENT_TIMEOUT) + + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def list_participant_datasets(self, + kind: Optional[str] = None, + uuid: Optional[str] = None) -> service_pb2.ListParticipantDatasetsResponse: + request = service_pb2.ListParticipantDatasetsRequest(auth_info=self._project_auth_info) + if kind is not None: + request.kind = kind + if uuid is not None: + request.uuid = uuid + return self._client.ListParticipantDatasets(request, + metadata=self._get_metadata(), + timeout=Envs.GRPC_CLIENT_TIMEOUT) + + @retry_fn(retry_times=3, need_retry=_need_retry_for_get) + def get_dataset_job(self, uuid: str) -> service_pb2.GetDatasetJobResponse: + request = service_pb2.GetDatasetJobRequest(auth_info=self._project_auth_info, uuid=uuid) + return self._client.GetDatasetJob(request, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) + + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def create_dataset_job(self, dataset_job: dataset_pb2.DatasetJob, ticket_uuid: str, + dataset: dataset_pb2.Dataset) -> empty_pb2.Empty: + request = service_pb2.CreateDatasetJobRequest(auth_info=self._project_auth_info, + dataset_job=dataset_job, + ticket_uuid=ticket_uuid, + dataset=dataset) + return self._client.CreateDatasetJob(request, metadata=self._get_metadata(), timeout=Envs.GRPC_CLIENT_TIMEOUT) + + +def gen_egress_authority(domain_name: str) -> str: + """generate egress host + Args: + domain_name: + ex: 'test-1.com' + Returns: + authority: + ex:'test-1-client-auth.com' + """ + domain_name_prefix = domain_name.rpartition('.')[0] + return f'{domain_name_prefix}-client-auth.com' + + +def gen_x_host(domain_name: str) -> str: + """generate x host + Args: + domain_name: + ex: 'test-1.com' + Returns: + x-host: + ex:'fedlearner-webconsole-v2.test-1.com' + """ + return f'fedlearner-webconsole-v2.{domain_name}' diff --git a/web_console_v2/api/fedlearner_webconsole/rpc/server.py b/web_console_v2/api/fedlearner_webconsole/rpc/server.py index 19b9ac285..99d154b75 100644 --- a/web_console_v2/api/fedlearner_webconsole/rpc/server.py +++ b/web_console_v2/api/fedlearner_webconsole/rpc/server.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,47 +13,115 @@ # limitations under the License. # coding: utf-8 -# pylint: disable=broad-except, cyclic-import +# pylint: disable=broad-except +from datetime import timedelta +import inspect import time import logging import json -import os import sys import threading import traceback from concurrent import futures +from functools import wraps +from envs import Envs + import grpc from grpc_reflection.v1alpha import reflection -from fedlearner_webconsole.proto import ( - service_pb2, service_pb2_grpc, - common_pb2, workflow_definition_pb2 -) +from google.protobuf import empty_pb2 +from google.protobuf.wrappers_pb2 import BoolValue +from fedlearner_webconsole.middleware.request_id import GrpcRequestIdMiddleware +from fedlearner_webconsole.participant.services import ParticipantService +from fedlearner_webconsole.proto import (dataset_pb2, service_pb2, service_pb2_grpc, common_pb2, + workflow_definition_pb2) +from fedlearner_webconsole.proto.review_pb2 import ReviewStatus +from fedlearner_webconsole.proto.rpc.v2 import system_service_pb2_grpc, system_service_pb2, project_service_pb2_grpc +from fedlearner_webconsole.proto.service_pb2 import (TwoPcRequest, TwoPcResponse) +from fedlearner_webconsole.review.ticket_helper import get_ticket_helper +from fedlearner_webconsole.review.common import NO_CENTRAL_SERVER_UUID +from fedlearner_webconsole.rpc.auth import get_common_name +from fedlearner_webconsole.rpc.v2.system_service_server import SystemGrpcService +from fedlearner_webconsole.serving.services import NegotiatorServingService +from fedlearner_webconsole.setting.service import SettingService +from fedlearner_webconsole.two_pc.handlers import run_two_pc_action +from fedlearner_webconsole.utils.base_model.auth_model import AuthStatus +from fedlearner_webconsole.utils.base_model.review_ticket_model import TicketStatus +from fedlearner_webconsole.utils.pp_datetime import to_timestamp +from fedlearner_webconsole.utils.domain_name import get_pure_domain_name from fedlearner_webconsole.utils.es import es -from fedlearner_webconsole.db import db, get_session +from fedlearner_webconsole.db import db from fedlearner_webconsole.utils.kibana import Kibana from fedlearner_webconsole.project.models import Project -from fedlearner_webconsole.workflow.models import ( - Workflow, WorkflowState, TransactionState, - _merge_workflow_config -) - +from fedlearner_webconsole.participant.models import Participant +from fedlearner_webconsole.workflow.models import (Workflow, WorkflowState, TransactionState) +from fedlearner_webconsole.workflow.resource_manager import \ + merge_workflow_config, ResourceManager +from fedlearner_webconsole.workflow.service import WorkflowService +from fedlearner_webconsole.workflow.workflow_controller import invalidate_workflow_locally +from fedlearner_webconsole.utils.pp_datetime import now +from fedlearner_webconsole.utils.proto import to_json, to_dict from fedlearner_webconsole.job.models import Job from fedlearner_webconsole.job.service import JobService from fedlearner_webconsole.job.metrics import JobMetricsBuilder -from fedlearner_webconsole.exceptions import ( - UnauthorizedException, InvalidArgumentException -) -from envs import Envs - - +from fedlearner_webconsole.mmgr.models import ModelJobGroup +from fedlearner_webconsole.exceptions import (UnauthorizedException, InvalidArgumentException) +from fedlearner_webconsole.proto.audit_pb2 import Event +from fedlearner_webconsole.mmgr.models import ModelJob +from fedlearner_webconsole.mmgr.service import ModelJobService +from fedlearner_webconsole.dataset.services import DatasetService, DatasetJobService, BatchService +from fedlearner_webconsole.dataset.models import DatasetJob, DatasetJobKind, \ + Dataset, ProcessedDataset, DatasetKindV2, DatasetFormat, ResourceState +from fedlearner_webconsole.dataset.auth_service import AuthService +from fedlearner_webconsole.dataset.job_configer.dataset_job_configer import DatasetJobConfiger +from fedlearner_webconsole.proto.rpc.v2 import job_service_pb2_grpc, job_service_pb2, resource_service_pb2_grpc,\ + resource_service_pb2 +from fedlearner_webconsole.rpc.v2.auth_server_interceptor import AuthServerInterceptor +from fedlearner_webconsole.rpc.v2.job_service_server import JobServiceServicer +from fedlearner_webconsole.rpc.v2.resource_service_server import ResourceServiceServicer +from fedlearner_webconsole.rpc.v2.project_service_server import ProjectGrpcService +from fedlearner_webconsole.flag.models import Flag +from fedlearner_webconsole.audit.decorators import emits_rpc_event, get_two_pc_request_uuid + + +def _set_request_id_for_all_methods(): + """A hack way to wrap all gRPC methods to set request id in context. + + Why not service interceptor? + The request id is attached on thread local, but interceptor is not sharing + the same thread with service handler, we are not able to set the context on + thread local in interceptor as it will not work in service handler.""" + + def set_request_id_in_context(fn): + + @wraps(fn) + def wrapper(self, request, context): + GrpcRequestIdMiddleware.set_request_id_in_context(context) + return fn(self, request, context) + + return wrapper + + def decorate(cls): + # A hack to get all methods + grpc_methods = service_pb2.DESCRIPTOR.services_by_name['WebConsoleV2Service'].methods_by_name + for name, fn in inspect.getmembers(cls, inspect.isfunction): + # If this is a gRPC method + if name in grpc_methods: + setattr(cls, name, set_request_id_in_context(fn)) + return cls + + return decorate + + +@_set_request_id_for_all_methods() class RPCServerServicer(service_pb2_grpc.WebConsoleV2ServiceServicer): + def __init__(self, server): self._server = server def _secure_exc(self): exc_type, exc_obj, exc_tb = sys.exc_info() # filter out exc_obj to protect sensitive info - secure_exc = 'Error %s at '%exc_type + secure_exc = f'Error {exc_type} at ' secure_exc += ''.join(traceback.format_tb(exc_tb)) return secure_exc @@ -61,199 +129,254 @@ def _try_handle_request(self, func, request, context, resp_class): try: return func(request, context) except UnauthorizedException as e: - return resp_class( - status=common_pb2.Status( - code=common_pb2.STATUS_UNAUTHORIZED, - msg='Invalid auth: %s'%repr(request.auth_info))) + return resp_class(status=common_pb2.Status(code=common_pb2.STATUS_UNAUTHORIZED, + msg=f'Invalid auth: {repr(request.auth_info)}')) except Exception as e: logging.error('%s rpc server error: %s', func.__name__, repr(e)) - return resp_class( - status=common_pb2.Status( - code=common_pb2.STATUS_UNKNOWN_ERROR, - msg=self._secure_exc())) + return resp_class(status=common_pb2.Status(code=common_pb2.STATUS_UNKNOWN_ERROR, msg=self._secure_exc())) def CheckConnection(self, request, context): - return self._try_handle_request( - self._server.check_connection, request, context, - service_pb2.CheckConnectionResponse) + return self._try_handle_request(self._server.check_connection, request, context, + service_pb2.CheckConnectionResponse) - def Ping(self, request, context): - return self._try_handle_request( - self._server.ping, request, context, - service_pb2.PingResponse) + def CheckPeerConnection(self, request, context): + return self._try_handle_request(self._server.check_peer_connection, request, context, + service_pb2.CheckPeerConnectionResponse) + @emits_rpc_event(resource_type=Event.ResourceType.WORKFLOW, + op_type=Event.OperationType.UPDATE_STATE, + resource_name_fn=lambda request: request.uuid) def UpdateWorkflowState(self, request, context): - return self._try_handle_request( - self._server.update_workflow_state, request, context, - service_pb2.UpdateWorkflowStateResponse) + return self._try_handle_request(self._server.update_workflow_state, request, context, + service_pb2.UpdateWorkflowStateResponse) def GetWorkflow(self, request, context): - return self._try_handle_request( - self._server.get_workflow, request, context, - service_pb2.GetWorkflowResponse) + return self._try_handle_request(self._server.get_workflow, request, context, service_pb2.GetWorkflowResponse) + @emits_rpc_event(resource_type=Event.ResourceType.WORKFLOW, + op_type=Event.OperationType.UPDATE, + resource_name_fn=lambda request: request.workflow_uuid) def UpdateWorkflow(self, request, context): - return self._try_handle_request( - self._server.update_workflow, request, context, - service_pb2.UpdateWorkflowResponse) + return self._try_handle_request(self._server.update_workflow, request, context, + service_pb2.UpdateWorkflowResponse) + + @emits_rpc_event(resource_type=Event.ResourceType.WORKFLOW, + op_type=Event.OperationType.INVALIDATE, + resource_name_fn=lambda request: request.workflow_uuid) + def InvalidateWorkflow(self, request, context): + return self._try_handle_request(self._server.invalidate_workflow, request, context, + service_pb2.InvalidateWorkflowResponse) def GetJobMetrics(self, request, context): - return self._try_handle_request( - self._server.get_job_metrics, request, context, - service_pb2.GetJobMetricsResponse) + return self._try_handle_request(self._server.get_job_metrics, request, context, + service_pb2.GetJobMetricsResponse) def GetJobKibana(self, request, context): - return self._try_handle_request( - self._server.get_job_kibana, request, context, - service_pb2.GetJobKibanaResponse - ) + return self._try_handle_request(self._server.get_job_kibana, request, context, service_pb2.GetJobKibanaResponse) def GetJobEvents(self, request, context): - return self._try_handle_request( - self._server.get_job_events, request, context, - service_pb2.GetJobEventsResponse) + return self._try_handle_request(self._server.get_job_events, request, context, service_pb2.GetJobEventsResponse) def CheckJobReady(self, request, context): - return self._try_handle_request( - self._server.check_job_ready, request, context, - service_pb2.CheckJobReadyResponse) - - + return self._try_handle_request(self._server.check_job_ready, request, context, + service_pb2.CheckJobReadyResponse) + + def _run_2pc(self, request: TwoPcRequest, context: grpc.ServicerContext) -> TwoPcResponse: + with db.session_scope() as session: + project, _ = self._server.check_auth_info(request.auth_info, context, session) + succeeded, message = run_two_pc_action(session=session, + tid=request.transaction_uuid, + two_pc_type=request.type, + action=request.action, + data=request.data) + session.commit() + return TwoPcResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + transaction_uuid=request.transaction_uuid, + type=request.type, + action=request.action, + succeeded=succeeded, + message=message) + + @emits_rpc_event(resource_type=Event.ResourceType.UNKNOWN_RESOURCE_TYPE, + op_type=Event.OperationType.UNKNOWN_OPERATION_TYPE, + resource_name_fn=get_two_pc_request_uuid) + def Run2Pc(self, request: TwoPcRequest, context: grpc.ServicerContext): + return self._try_handle_request(self._run_2pc, request, context, service_pb2.TwoPcResponse) + + @emits_rpc_event(resource_type=Event.ResourceType.SERVING_SERVICE, + op_type=Event.OperationType.OPERATE, + resource_name_fn=lambda request: request.serving_model_uuid) + def ServingServiceManagement(self, request: service_pb2.ServingServiceRequest, + context: grpc.ServicerContext) -> service_pb2.ServingServiceResponse: + return self._try_handle_request(self._server.operate_serving_service, request, context, + service_pb2.ServingServiceResponse) + + @emits_rpc_event(resource_type=Event.ResourceType.SERVING_SERVICE, + op_type=Event.OperationType.INFERENCE, + resource_name_fn=lambda request: request.serving_model_uuid) + def ServingServiceInference(self, request: service_pb2.ServingServiceInferenceRequest, + context: grpc.ServicerContext) -> service_pb2.ServingServiceInferenceResponse: + return self._try_handle_request(self._server.inference_serving_service, request, context, + service_pb2.ServingServiceInferenceResponse) + + def ClientHeartBeat(self, request, context): + return self._server.client_heart_beat(request, context) + + def GetModelJob(self, request, context): + return self._server.get_model_job(request, context) + + def GetModelJobGroup(self, request, context): + return self._server.get_model_job_group(request, context) + + @emits_rpc_event(resource_type=Event.ResourceType.MODEL_JOB_GROUP, + op_type=Event.OperationType.UPDATE, + resource_name_fn=lambda request: request.uuid) + def UpdateModelJobGroup(self, request, context): + return self._server.update_model_job_group(request, context) + + def ListParticipantDatasets(self, request, context): + return self._server.list_participant_datasets(request, context) + + def GetDatasetJob(self, request: service_pb2.GetDatasetJobRequest, + context: grpc.ServicerContext) -> service_pb2.GetDatasetJobResponse: + return self._server.get_dataset_job(request, context) + + @emits_rpc_event(resource_type=Event.ResourceType.DATASET_JOB, + op_type=Event.OperationType.CREATE, + resource_name_fn=lambda request: request.dataset_job.uuid) + def CreateDatasetJob(self, request: service_pb2.CreateDatasetJobRequest, + context: grpc.ServicerContext) -> empty_pb2.Empty: + return self._server.create_dataset_job(request, context) + + +# TODO(wangsen.0914): make the rpc server clean, move business logic out class RpcServer(object): + def __init__(self): + self.started = False self._lock = threading.Lock() - self._started = False self._server = None - self._app = None - def start(self, app): - assert not self._started, 'Already started' - self._app = app - listen_port = app.config.get('GRPC_LISTEN_PORT', 1999) + def start(self, port: int): + assert not self.started, 'Already started' with self._lock: - self._server = grpc.server( - futures.ThreadPoolExecutor(max_workers=20)) - service_pb2_grpc.add_WebConsoleV2ServiceServicer_to_server( - RPCServerServicer(self), self._server) - # reflection support server find the proto file path automatically - # when using grpcurl - reflection.enable_server_reflection( - service_pb2.DESCRIPTOR.services_by_name, self._server) - self._server.add_insecure_port('[::]:%d' % listen_port) + self._server = grpc.server(futures.ThreadPoolExecutor(max_workers=30), + interceptors=[AuthServerInterceptor()]) + service_pb2_grpc.add_WebConsoleV2ServiceServicer_to_server(RPCServerServicer(self), self._server) + system_service_pb2_grpc.add_SystemServiceServicer_to_server(SystemGrpcService(), self._server) + job_service_pb2_grpc.add_JobServiceServicer_to_server(JobServiceServicer(), self._server) + resource_service_pb2_grpc.add_ResourceServiceServicer_to_server(ResourceServiceServicer(), self._server) + project_service_pb2_grpc.add_ProjectServiceServicer_to_server(ProjectGrpcService(), self._server) + # reflection supports server find service by using url, e.g. /SystemService.CheckHealth + reflection.enable_server_reflection(service_pb2.DESCRIPTOR.services_by_name, self._server) + reflection.enable_server_reflection(system_service_pb2.DESCRIPTOR.services_by_name, self._server) + reflection.enable_server_reflection(job_service_pb2.DESCRIPTOR.services_by_name, self._server) + reflection.enable_server_reflection(resource_service_pb2.DESCRIPTOR.services_by_name, self._server) + self._server.add_insecure_port(f'[::]:{port}') self._server.start() - self._started = True + self.started = True def stop(self): - if not self._started: + if not self.started: return with self._lock: self._server.stop(None).wait() del self._server - self._started = False + self.started = False - def check_auth_info(self, auth_info, context): + def check_auth_info(self, auth_info, context, session): logging.debug('auth_info: %s', auth_info) - project = Project.query.filter_by( - name=auth_info.project_name).first() + project = session.query(Project).filter_by(name=auth_info.project_name).first() if project is None: - raise UnauthorizedException('Invalid project') - project_config = project.get_config() + raise UnauthorizedException(f'Invalid project {auth_info.project_name}') # TODO: fix token verification # if project_config.token != auth_info.auth_token: # raise UnauthorizedException('Invalid token') - # Use first participant to mock for unit test + service = ParticipantService(session) + participants = service.get_participants_by_project(project.id) # TODO: Fix for multi-peer - source_party = project_config.participants[0] - if os.environ.get('FLASK_ENV') == 'production': + source_party = participants[0] + if Envs.FLASK_ENV == 'production': + source_party = None metadata = dict(context.invocation_metadata()) - # ssl-client-subject-dn example: - # CN=*.fl-xxx.com,OU=security,O=security,L=beijing,ST=beijing,C=CN - cn = metadata.get('ssl-client-subject-dn').split(',')[0][5:] - for party in project_config.participants: - if party.domain_name == cn: + cn = get_common_name(metadata.get('ssl-client-subject-dn')) + if not cn: + raise UnauthorizedException('Failed to get domain name from certs') + pure_domain_name = get_pure_domain_name(cn) + for party in participants: + if get_pure_domain_name(party.domain_name) == pure_domain_name: source_party = party if source_party is None: - raise UnauthorizedException('Invalid domain') + raise UnauthorizedException(f'Invalid domain {pure_domain_name}') return project, source_party def check_connection(self, request, context): - with self._app.app_context(): - _, party = self.check_auth_info(request.auth_info, context) - logging.debug( - 'received check_connection from %s', party.domain_name) - return service_pb2.CheckConnectionResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS)) - - def ping(self, request, context): - return service_pb2.PingResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS), - msg='Pong!') + with db.session_scope() as session: + _, party = self.check_auth_info(request.auth_info, context, session) + logging.debug('received check_connection from %s', party.domain_name) + return service_pb2.CheckConnectionResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS)) + + def check_peer_connection(self, request, context): + logging.debug('received request: check peer connection') + with db.session_scope() as session: + service = SettingService(session) + version = service.get_application_version() + return service_pb2.CheckPeerConnectionResponse(status=common_pb2.Status( + code=common_pb2.STATUS_SUCCESS, msg='participant received check request successfully!'), + application_version=version.to_proto()) def update_workflow_state(self, request, context): - with self._app.app_context(): - project, party = self.check_auth_info(request.auth_info, context) - logging.debug( - 'received update_workflow_state from %s: %s', - party.domain_name, request) + with db.session_scope() as session: + project, party = self.check_auth_info(request.auth_info, context, session) + logging.debug('received update_workflow_state from %s: %s', party.domain_name, request) name = request.workflow_name uuid = request.uuid forked_from_uuid = request.forked_from_uuid - forked_from = Workflow.query.filter_by( + forked_from = session.query(Workflow).filter_by( uuid=forked_from_uuid).first().id if forked_from_uuid else None state = WorkflowState(request.state) target_state = WorkflowState(request.target_state) transaction_state = TransactionState(request.transaction_state) - workflow = Workflow.query.filter_by( - name=request.workflow_name, - project_id=project.id).first() + workflow = session.query(Workflow).filter_by(name=request.workflow_name, project_id=project.id).first() if workflow is None: assert state == WorkflowState.NEW assert target_state == WorkflowState.READY - workflow = Workflow( - name=name, - project_id=project.id, - state=state, target_state=target_state, - transaction_state=transaction_state, - uuid=uuid, - forked_from=forked_from, - extra=request.extra - ) - db.session.add(workflow) - db.session.commit() - db.session.refresh(workflow) - - workflow.update_state( - state, target_state, transaction_state) - db.session.commit() - return service_pb2.UpdateWorkflowStateResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS), - state=workflow.state.value, - target_state=workflow.target_state.value, - transaction_state=workflow.transaction_state.value) + workflow = Workflow(name=name, + project_id=project.id, + state=state, + target_state=target_state, + transaction_state=transaction_state, + uuid=uuid, + forked_from=forked_from, + extra=request.extra) + session.add(workflow) + session.commit() + session.refresh(workflow) + + ResourceManager(session, workflow).update_state(state, target_state, transaction_state) + session.commit() + return service_pb2.UpdateWorkflowStateResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + state=workflow.state.value, + target_state=workflow.target_state.value, + transaction_state=workflow.transaction_state.value) def _filter_workflow(self, workflow, modes): # filter peer-readable and peer-writable variables if workflow is None: return None - new_wf = workflow_definition_pb2.WorkflowDefinition( - group_alias=workflow.group_alias, - is_left=workflow.is_left) + new_wf = workflow_definition_pb2.WorkflowDefinition(group_alias=workflow.group_alias) for var in workflow.variables: if var.access_mode in modes: new_wf.variables.append(var) for job_def in workflow.job_definitions: # keep yaml template private - new_jd = workflow_definition_pb2.JobDefinition( - name=job_def.name, - job_type=job_def.job_type, - is_federated=job_def.is_federated, - dependencies=job_def.dependencies) + new_jd = workflow_definition_pb2.JobDefinition(name=job_def.name, + job_type=job_def.job_type, + is_federated=job_def.is_federated, + dependencies=job_def.dependencies) for var in job_def.variables: if var.access_mode in modes: new_jd.variables.append(var) @@ -261,147 +384,300 @@ def _filter_workflow(self, workflow, modes): return new_wf def get_workflow(self, request, context): - with self._app.app_context(): - project, party = self.check_auth_info(request.auth_info, context) - workflow = Workflow.query.filter_by( - name=request.workflow_name, - project_id=project.id).first() + with db.session_scope() as session: + project, party = self.check_auth_info(request.auth_info, context, session) + # TODO(hangweiqiang): remove workflow name + # compatible method for previous version + if request.workflow_uuid: + workflow = session.query(Workflow).filter_by(uuid=request.workflow_uuid, project_id=project.id).first() + else: + workflow = session.query(Workflow).filter_by(name=request.workflow_name, project_id=project.id).first() assert workflow is not None, 'Workflow not found' config = workflow.get_config() - config = self._filter_workflow( - config, - [ - common_pb2.Variable.PEER_READABLE, - common_pb2.Variable.PEER_WRITABLE - ]) + config = self._filter_workflow(config, + [common_pb2.Variable.PEER_READABLE, common_pb2.Variable.PEER_WRITABLE]) # job details - jobs = [service_pb2.JobDetail( - name=job.name, - state=job.get_state_for_frontend(), - pods=json.dumps( - job.get_pods_for_frontend(include_private_info=False))) - for job in workflow.get_jobs()] + jobs = [ + service_pb2.JobDetail( + name=job.name, + state=job.state.name, + created_at=to_timestamp(job.created_at), + pods=json.dumps([to_dict(pod) + for pod in JobService.get_pods(job, include_private_info=False)])) + for job in workflow.get_jobs(session) + ] # fork info forked_from = '' if workflow.forked_from: - forked_from = Workflow.query.get(workflow.forked_from).name - return service_pb2.GetWorkflowResponse( - name=request.workflow_name, - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS), - config=config, - jobs=jobs, - state=workflow.state.value, - target_state=workflow.target_state.value, - transaction_state=workflow.transaction_state.value, - forkable=workflow.forkable, - forked_from=forked_from, - create_job_flags=workflow.get_create_job_flags(), - peer_create_job_flags=workflow.get_peer_create_job_flags(), - fork_proposal_config=workflow.get_fork_proposal_config(), - uuid=workflow.uuid, - metric_is_public=workflow.metric_is_public) + forked_from = session.query(Workflow).get(workflow.forked_from).name + return service_pb2.GetWorkflowResponse(name=workflow.name, + status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + config=config, + jobs=jobs, + state=workflow.state.value, + target_state=workflow.target_state.value, + transaction_state=workflow.transaction_state.value, + forkable=workflow.forkable, + forked_from=forked_from, + create_job_flags=workflow.get_create_job_flags(), + peer_create_job_flags=workflow.get_peer_create_job_flags(), + fork_proposal_config=workflow.get_fork_proposal_config(), + uuid=workflow.uuid, + metric_is_public=workflow.metric_is_public, + is_finished=workflow.is_finished()) def update_workflow(self, request, context): - with self._app.app_context(): - project, party = self.check_auth_info(request.auth_info, context) - workflow = Workflow.query.filter_by( - name=request.workflow_name, - project_id=project.id).first() + with db.session_scope() as session: + project, party = self.check_auth_info(request.auth_info, context, session) + # TODO(hangweiqiang): remove workflow name + # compatible method for previous version + if request.workflow_uuid: + workflow = session.query(Workflow).filter_by(uuid=request.workflow_uuid, project_id=project.id).first() + else: + workflow = session.query(Workflow).filter_by(name=request.workflow_name, project_id=project.id).first() assert workflow is not None, 'Workflow not found' config = workflow.get_config() - _merge_workflow_config( - config, request.config, - [common_pb2.Variable.PEER_WRITABLE]) - workflow.set_config(config) - db.session.commit() - - config = self._filter_workflow( - config, - [ - common_pb2.Variable.PEER_READABLE, - common_pb2.Variable.PEER_WRITABLE - ]) - return service_pb2.UpdateWorkflowResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS), - workflow_name=request.workflow_name, - config=config) + merge_workflow_config(config, request.config, [common_pb2.Variable.PEER_WRITABLE]) + WorkflowService(session).update_config(workflow, config) + session.commit() + + config = self._filter_workflow(config, + [common_pb2.Variable.PEER_READABLE, common_pb2.Variable.PEER_WRITABLE]) + # compatible method for previous version + if request.workflow_uuid: + return service_pb2.UpdateWorkflowResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + workflow_uuid=request.workflow_uuid, + config=config) + return service_pb2.UpdateWorkflowResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + workflow_name=request.workflow_name, + config=config) + + def invalidate_workflow(self, request, context): + with db.session_scope() as session: + project, party = self.check_auth_info(request.auth_info, context, session) + workflow = session.query(Workflow).filter_by(uuid=request.workflow_uuid, project_id=project.id).first() + if workflow is None: + logging.error(f'Failed to find workflow: {request.workflow_uuid}') + return service_pb2.InvalidateWorkflowResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + succeeded=False) + invalidate_workflow_locally(session, workflow) + session.commit() + return service_pb2.InvalidateWorkflowResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + succeeded=True) def _check_metrics_public(self, request, context): - project, party = self.check_auth_info(request.auth_info, context) - job = db.session.query(Job).filter_by(name=request.job_name, - project_id=project.id).first() - assert job is not None, f'job {request.job_name} not found' - workflow = job.workflow - if not workflow.metric_is_public: - raise UnauthorizedException('Metric is private!') - return job + with db.session_scope() as session: + project, party = self.check_auth_info(request.auth_info, context, session) + job = session.query(Job).filter_by(name=request.job_name, project_id=project.id).first() + assert job is not None, f'job {request.job_name} not found' + workflow = job.workflow + if not workflow.metric_is_public: + raise UnauthorizedException('Metric is private!') + return job def get_job_metrics(self, request, context): - with self._app.app_context(): + with db.session_scope(): job = self._check_metrics_public(request, context) metrics = JobMetricsBuilder(job).plot_metrics() - return service_pb2.GetJobMetricsResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS), - metrics=json.dumps(metrics)) + return service_pb2.GetJobMetricsResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + metrics=json.dumps(metrics)) def get_job_kibana(self, request, context): - with self._app.app_context(): + with db.session_scope(): job = self._check_metrics_public(request, context) try: - metrics = Kibana.remote_query(job, - json.loads(request.json_args)) + metrics = Kibana.remote_query(job, json.loads(request.json_args)) except UnauthorizedException as ua_e: return service_pb2.GetJobKibanaResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_UNAUTHORIZED, - msg=ua_e.message)) + status=common_pb2.Status(code=common_pb2.STATUS_UNAUTHORIZED, msg=ua_e.message)) except InvalidArgumentException as ia_e: return service_pb2.GetJobKibanaResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_INVALID_ARGUMENT, - msg=ia_e.message)) - return service_pb2.GetJobKibanaResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS), - metrics=json.dumps(metrics)) + status=common_pb2.Status(code=common_pb2.STATUS_INVALID_ARGUMENT, msg=ia_e.message)) + return service_pb2.GetJobKibanaResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + metrics=json.dumps(metrics)) def get_job_events(self, request, context): - with self._app.app_context(): - project, party = self.check_auth_info(request.auth_info, context) - job = Job.query.filter_by(name=request.job_name, - project_id=project.id).first() + with db.session_scope() as session: + project, party = self.check_auth_info(request.auth_info, context, session) + job = session.query(Job).filter_by(name=request.job_name, project_id=project.id).first() assert job is not None, \ f'Job {request.job_name} not found' - result = es.query_events('filebeat-*', job.name, - 'fedlearner-operator', - request.start_time, - int(time.time() * 1000), - Envs.OPERATOR_LOG_MATCH_PHRASE - )[:request.max_lines][::-1] + result = es.query_events('filebeat-*', job.name, 'fedlearner-operator', request.start_time, + int(time.time() * 1000), Envs.OPERATOR_LOG_MATCH_PHRASE)[:request.max_lines][::-1] - return service_pb2.GetJobEventsResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS), - logs=result) + return service_pb2.GetJobEventsResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + logs=result) def check_job_ready(self, request, context): - with self._app.app_context(): - project, _ = self.check_auth_info(request.auth_info, context) - job = db.session.query(Job).filter_by(name=request.job_name, - project_id=project.id).first() + with db.session_scope() as session: + project, _ = self.check_auth_info(request.auth_info, context, session) + job = session.query(Job).filter_by(name=request.job_name, project_id=project.id).first() assert job is not None, \ f'Job {request.job_name} not found' - with get_session(db.get_engine()) as session: - is_ready = JobService(session).is_ready(job) - return service_pb2.CheckJobReadyResponse( - status=common_pb2.Status( - code=common_pb2.STATUS_SUCCESS), - is_ready=is_ready) + is_ready = JobService(session).is_ready(job) + return service_pb2.CheckJobReadyResponse(status=common_pb2.Status(code=common_pb2.STATUS_SUCCESS), + is_ready=is_ready) + + def operate_serving_service(self, request, context) -> service_pb2.ServingServiceResponse: + with db.session_scope() as session: + project, _ = self.check_auth_info(request.auth_info, context, session) + return NegotiatorServingService(session).handle_participant_request(request, project) + + def inference_serving_service(self, request, context) -> service_pb2.ServingServiceInferenceResponse: + with db.session_scope() as session: + project, _ = self.check_auth_info(request.auth_info, context, session) + return NegotiatorServingService(session).handle_participant_inference_request(request, project) + + def client_heart_beat(self, request: service_pb2.ClientHeartBeatRequest, context): + with db.session_scope() as session: + party: Participant = session.query(Participant).filter_by(request.domain_name) + if party is None: + return service_pb2.ClientHeartBeatResponse(succeeded=False) + party.last_connected_at = now() + session.commit() + return service_pb2.ClientHeartBeatResponse(succeeded=True) + + def get_model_job(self, request: service_pb2.GetModelJobRequest, context) -> service_pb2.GetModelJobResponse: + with db.session_scope() as session: + project, _ = self.check_auth_info(request.auth_info, context, session) + model_job: ModelJob = session.query(ModelJob).filter_by(uuid=request.uuid).first() + group_uuid = None + if model_job.group: + group_uuid = model_job.group.uuid + config = model_job.workflow.get_config() + config = self._filter_workflow(config, + [common_pb2.Variable.PEER_READABLE, common_pb2.Variable.PEER_WRITABLE]) + metrics = None + if request.need_metrics and model_job.job is not None and model_job.metric_is_public: + metrics = to_json(ModelJobService(session).query_metrics(model_job)) + return service_pb2.GetModelJobResponse(name=model_job.name, + uuid=model_job.uuid, + algorithm_type=model_job.algorithm_type.name, + model_job_type=model_job.model_job_type.name, + state=model_job.state.name, + group_uuid=group_uuid, + config=config, + metrics=metrics, + metric_is_public=BoolValue(value=model_job.metric_is_public)) + + def get_model_job_group(self, request: service_pb2.GetModelJobGroupRequest, context): + with db.session_scope() as session: + project, _ = self.check_auth_info(request.auth_info, context, session) + group: ModelJobGroup = session.query(ModelJobGroup).filter_by(uuid=request.uuid).first() + return service_pb2.GetModelJobGroupResponse(name=group.name, + uuid=group.uuid, + role=group.role.name, + authorized=group.authorized, + algorithm_type=group.algorithm_type.name, + config=group.get_config()) + + def update_model_job_group(self, request: service_pb2.UpdateModelJobGroupRequest, context): + with db.session_scope() as session: + project, _ = self.check_auth_info(request.auth_info, context, session) + group: ModelJobGroup = session.query(ModelJobGroup).filter_by(uuid=request.uuid).first() + if not group.authorized: + raise UnauthorizedException(f'group {group.name} is not authorized for editing') + group.set_config(request.config) + session.commit() + return service_pb2.UpdateModelJobGroupResponse(uuid=group.uuid, config=group.get_config()) + + # TODO(liuhehan): delete after all participants support new rpc + def list_participant_datasets(self, request: service_pb2.ListParticipantDatasetsRequest, context): + kind = DatasetKindV2(request.kind) if request.kind else None + uuid = request.uuid if request.uuid else None + state = ResourceState.SUCCEEDED + with db.session_scope() as session: + project, _ = self.check_auth_info(request.auth_info, context, session) + datasets = DatasetService(session=session).get_published_datasets(project.id, kind, uuid, state) + return service_pb2.ListParticipantDatasetsResponse(participant_datasets=datasets) + + def get_dataset_job(self, request: service_pb2.GetDatasetJobRequest, + context: grpc.ServicerContext) -> service_pb2.GetDatasetJobResponse: + with db.session_scope() as session: + self.check_auth_info(request.auth_info, context, session) + dataset_job_model = session.query(DatasetJob).filter(DatasetJob.uuid == request.uuid).first() + if dataset_job_model is None: + context.abort(code=grpc.StatusCode.NOT_FOUND, details=f'could not find dataset {request.uuid}') + dataset_job = dataset_job_model.to_proto() + dataset_job.workflow_definition.MergeFrom( + DatasetJobConfiger.from_kind(dataset_job_model.kind, session).get_config()) + return service_pb2.GetDatasetJobResponse(dataset_job=dataset_job) + + def create_dataset_job(self, request: service_pb2.CreateDatasetJobRequest, + context: grpc.ServicerContext) -> empty_pb2.Empty: + with db.session_scope() as session: + project, participant = self.check_auth_info(request.auth_info, context, session) + + # this is a hack to allow no ticket_uuid, delete it after all customers update + ticket_uuid = request.ticket_uuid if request.ticket_uuid else NO_CENTRAL_SERVER_UUID + ticket_helper = get_ticket_helper(session=session) + validate = ticket_helper.validate_ticket( + ticket_uuid, lambda ticket: ticket.details.uuid == request.dataset_job.result_dataset_uuid and ticket. + status == ReviewStatus.APPROVED) + if not validate: + message = f'[create_dataset_job]: ticket status is not approved, ticket_uuid: {request.ticket_uuid}' + logging.warning(message) + context.abort(code=grpc.StatusCode.PERMISSION_DENIED, details=message) + + processed_dataset = session.query(ProcessedDataset).filter_by( + uuid=request.dataset_job.result_dataset_uuid).first() + if processed_dataset is None: + # create processed dataset + domain_name = SettingService.get_system_info().pure_domain_name + dataset_job_config = request.dataset_job.global_configs.global_configs.get(domain_name) + dataset = session.query(Dataset).filter_by(uuid=dataset_job_config.dataset_uuid).first() + dataset_param = dataset_pb2.DatasetParameter( + name=request.dataset_job.result_dataset_name, + type=dataset.dataset_type.value, + project_id=project.id, + kind=DatasetKindV2.PROCESSED.value, + format=DatasetFormat(dataset.dataset_format).name, + uuid=request.dataset_job.result_dataset_uuid, + is_published=True, + creator_username=request.dataset.creator_username, + ) + participants_info = request.dataset.participants_info + if not Flag.DATASET_AUTH_STATUS_CHECK_ENABLED.value: + # auto set participant auth_status and cache to authorized if no need check + dataset_param.auth_status = AuthStatus.AUTHORIZED.name + participants_info.participants_map[domain_name].auth_status = AuthStatus.AUTHORIZED.name + processed_dataset = DatasetService(session=session).create_dataset(dataset_param) + processed_dataset.ticket_uuid = request.ticket_uuid + processed_dataset.ticket_status = TicketStatus.APPROVED + session.flush([processed_dataset]) + # old dataset job will create data_batch in grpc level + # new dataset job will create data_batch before create dataset_job_stage + if not request.dataset_job.has_stages: + batch_parameter = dataset_pb2.BatchParameter(dataset_id=processed_dataset.id) + BatchService(session).create_batch(batch_parameter) + + dataset_job = session.query(DatasetJob).filter_by(uuid=request.dataset_job.uuid).first() + if dataset_job is None: + time_range = timedelta(days=request.dataset_job.time_range.days, + hours=request.dataset_job.time_range.hours) + dataset_job = DatasetJobService(session=session).create_as_participant( + project_id=project.id, + kind=DatasetJobKind(request.dataset_job.kind), + global_configs=request.dataset_job.global_configs, + config=request.dataset_job.workflow_definition, + output_dataset_id=processed_dataset.id, + coordinator_id=participant.id, + uuid=request.dataset_job.uuid, + creator_username=request.dataset_job.creator_username, + time_range=time_range if time_range else None) + session.flush() + AuthService(session=session, dataset_job=dataset_job).initialize_participants_info_as_participant( + participants_info=request.dataset.participants_info) + + session.commit() + return empty_pb2.Empty() + + def wait_for_termination(self): + if not self.started: + logging.warning('gRPC service is not yet started, failed to wait') + self._server.wait_for_termination() rpc_server = RpcServer() diff --git a/web_console_v2/api/fedlearner_webconsole/scheduler/scheduler.py b/web_console_v2/api/fedlearner_webconsole/scheduler/scheduler.py index d3e15aa1d..d7b649585 100644 --- a/web_console_v2/api/fedlearner_webconsole/scheduler/scheduler.py +++ b/web_console_v2/api/fedlearner_webconsole/scheduler/scheduler.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,52 +14,37 @@ # coding: utf-8 # pylint: disable=broad-except - -import os import threading import logging import traceback -from fedlearner_webconsole.job.yaml_formatter import generate_job_run_yaml +from queue import Queue, Empty +from envs import Envs from fedlearner_webconsole.db import db -from fedlearner_webconsole.dataset.import_handler import ImportHandler -from fedlearner_webconsole.utils.k8s_client import k8s_client from fedlearner_webconsole.workflow.models import Workflow, WorkflowState -from fedlearner_webconsole.job.models import Job, JobState from fedlearner_webconsole.scheduler.transaction import TransactionManager -from fedlearner_webconsole.db import get_session -from fedlearner_webconsole.job.service import JobService class Scheduler(object): + def __init__(self): self._condition = threading.Condition(threading.RLock()) self._running = False self._terminate = False self._thread = None - self._pending_workflows = [] - self._pending_jobs = [] - #TODO: remove app - self._app = None - self._db_engine = None - self._import_handler = ImportHandler() - - def start(self, app, force=False): + self.workflow_queue = Queue() + + def start(self, force=False): if self._running: if not force: raise RuntimeError('Scheduler is already started') self.stop() - self._app = app - with self._app.app_context(): - self._db_engine = db.get_engine() - with self._condition: self._running = True self._terminate = False self._thread = threading.Thread(target=self._routine) self._thread.daemon = True self._thread.start() - self._import_handler.init(app) logging.info('Scheduler started') def stop(self): @@ -68,62 +53,37 @@ def stop(self): with self._condition: self._terminate = True - self._condition.notify_all() + # Interrupt the block of workflow_queue.get to stop immediately. + self.workflow_queue.put(None) print('stopping') self._thread.join() self._running = False logging.info('Scheduler stopped') - def wakeup(self, workflow_ids=None, - job_ids=None, - data_batch_ids=None): - with self._condition: - if workflow_ids: - if isinstance(workflow_ids, int): - workflow_ids = [workflow_ids] - self._pending_workflows.extend(workflow_ids) - if job_ids: - if isinstance(job_ids, int): - job_ids = [job_ids] - self._pending_jobs.extend(job_ids) - if data_batch_ids: - self._import_handler.schedule_to_handle(data_batch_ids) - self._condition.notify_all() + def wakeup(self, workflow_id=None): + self.workflow_queue.put(workflow_id) def _routine(self): - self._app.app_context().push() - interval = int(os.environ.get( - 'FEDLEARNER_WEBCONSOLE_POLLING_INTERVAL', 60)) + interval = float(Envs.SCHEDULER_POLLING_INTERVAL) while True: - with self._condition: - notified = self._condition.wait(interval) - - # TODO(wangsen): use Sqlalchemy insdtead of flask-Sqlalchemy - # refresh a new session to catch the update of db - db.session.remove() - if self._terminate: - return - if notified: - workflow_ids = self._pending_workflows - self._pending_workflows = [] - self._poll_workflows(workflow_ids) - - job_ids = self._pending_jobs - self._pending_jobs = [] - job_ids.extend(_get_waiting_jobs()) - self._poll_jobs(job_ids) - - self._import_handler.handle(pull=False) - continue - - workflows = db.session.query(Workflow.id).filter( - Workflow.target_state != WorkflowState.INVALID).all() + try: + try: + pending_workflow = self.workflow_queue.get(timeout=interval) + except Empty: + pending_workflow = None + with self._condition: + if self._terminate: + return + if pending_workflow: + self._poll_workflows([pending_workflow]) + + with db.session_scope() as session: + workflows = session.query(Workflow.id).filter(Workflow.target_state != WorkflowState.INVALID).all() self._poll_workflows([wid for wid, in workflows]) - - self._poll_jobs(_get_waiting_jobs()) - - self._import_handler.handle(pull=True) + # make the scheduler routine run forever. + except Exception as e: + logging.error(f'Scheduler routine wrong: {str(e)}') def _poll_workflows(self, workflow_ids): logging.info(f'Scheduler polling {len(workflow_ids)} workflows...') @@ -131,58 +91,13 @@ def _poll_workflows(self, workflow_ids): try: self._schedule_workflow(workflow_id) except Exception as e: - logging.warning( - 'Error while scheduling workflow ' - f'{workflow_id}:\n{traceback.format_exc()}') - - def _poll_jobs(self, job_ids): - logging.info(f'Scheduler polling {len(job_ids)} jobs...') - for job_id in job_ids: - try: - self._schedule_job(job_id) - except Exception as e: - logging.warning( - 'Error while scheduling job ' - f'{job_id}:\n{traceback.format_exc()}') + logging.warning('Error while scheduling workflow ' f'{workflow_id}:\n{traceback.format_exc()}') def _schedule_workflow(self, workflow_id): logging.debug(f'Scheduling workflow {workflow_id}') - tm = TransactionManager(workflow_id) - return tm.process() - - def _schedule_job(self, job_id): - job = Job.query.get(job_id) - assert job is not None, f'Job {job_id} not found' - if job.state != JobState.WAITING: - return job.state - - with get_session(self._db_engine) as session: - job_service = JobService(session) - if not job_service.is_ready(job): - return job.state - config = job.get_config() - if config.is_federated: - if not job_service.is_peer_ready(job): - return job.state - - try: - yaml = generate_job_run_yaml(job) - k8s_client.create_flapp(yaml) - except Exception as e: - logging.error(f'Start job {job_id} has error msg: {e.args}') - job.error_message = str(e) - db.session.commit() - return job.state - job.error_message = None - job.start() - db.session.commit() - - return job.state - - -def _get_waiting_jobs(): - return [jid for jid, in db.session.query( - Job.id).filter(Job.state == JobState.WAITING)] + with db.session_scope() as session: + tm = TransactionManager(workflow_id, session) + return tm.process() scheduler = Scheduler() diff --git a/web_console_v2/api/fedlearner_webconsole/scheduler/transaction.py b/web_console_v2/api/fedlearner_webconsole/scheduler/transaction.py index aa605e157..706e1a75a 100644 --- a/web_console_v2/api/fedlearner_webconsole/scheduler/transaction.py +++ b/web_console_v2/api/fedlearner_webconsole/scheduler/transaction.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,18 +13,21 @@ # limitations under the License. # coding: utf-8 - -from fedlearner_webconsole.db import db +from fedlearner_webconsole.participant.services import ParticipantService from fedlearner_webconsole.rpc.client import RpcClient -from fedlearner_webconsole.workflow.models import ( - Workflow, WorkflowState, TransactionState, VALID_TRANSITIONS -) +from fedlearner_webconsole.workflow.models import (Workflow, WorkflowState, TransactionState, VALID_TRANSITIONS) from fedlearner_webconsole.proto import common_pb2 +from fedlearner_webconsole.workflow.resource_manager import ResourceManager +from fedlearner_webconsole.workflow.workflow_controller import invalidate_workflow_locally + class TransactionManager(object): - def __init__(self, workflow_id): + + def __init__(self, workflow_id, session): self._workflow_id = workflow_id - self._workflow = Workflow.query.get(workflow_id) + self._session = session + # TODO(hangweiqiang): remove workflow, project from __init__ + self._workflow = session.query(Workflow).get(workflow_id) assert self._workflow is not None self._project = self._workflow.project assert self._project is not None @@ -39,15 +42,14 @@ def project(self): def process(self): # process local workflow + manager = ResourceManager(self._session, self._workflow) if self._workflow.is_local(): - self._workflow.update_local_state() + manager.update_local_state() self._reload() return self._workflow # reload workflow and resolve -ing states - self._workflow.update_state( - self._workflow.state, self._workflow.target_state, - self._workflow.transaction_state) + manager.update_state(self._workflow.state, self._workflow.target_state, self._workflow.transaction_state) self._reload() if not self._recover_from_abort(): @@ -56,77 +58,67 @@ def process(self): if self._workflow.target_state == WorkflowState.INVALID: return self._workflow - if self._workflow.state == WorkflowState.INVALID: - raise RuntimeError( - f'Cannot process invalid workflow {self._workflow.name}') + if self._workflow.is_invalid(): + raise RuntimeError(f'Cannot process invalid workflow {self._workflow.name}') assert (self._workflow.state, self._workflow.target_state) \ - in VALID_TRANSITIONS + in VALID_TRANSITIONS if self._workflow.transaction_state == TransactionState.READY: # prepare self as coordinator - self._workflow.update_state( - self._workflow.state, - self._workflow.target_state, - TransactionState.COORDINATOR_PREPARE) + manager.update_state(self._workflow.state, self._workflow.target_state, + TransactionState.COORDINATOR_PREPARE) self._reload() if self._workflow.transaction_state == \ TransactionState.COORDINATOR_COMMITTABLE: # prepare self succeeded. Tell participants to prepare - states = self._broadcast_state( - self._workflow.state, self._workflow.target_state, - TransactionState.PARTICIPANT_PREPARE) + states = self._broadcast_state(self._workflow.state, self._workflow.target_state, + TransactionState.PARTICIPANT_PREPARE) committable = True for state in states: if state != TransactionState.PARTICIPANT_COMMITTABLE: committable = False if state == TransactionState.ABORTED: # abort as coordinator if some participants aborted - self._workflow.update_state( - None, None, TransactionState.COORDINATOR_ABORTING) + manager.update_state(None, None, TransactionState.COORDINATOR_ABORTING) self._reload() break # commit as coordinator if participants all committable if committable: - self._workflow.update_state( - None, None, TransactionState.COORDINATOR_COMMITTING) + manager.update_state(None, None, TransactionState.COORDINATOR_COMMITTING) self._reload() if self._workflow.transaction_state == \ TransactionState.COORDINATOR_COMMITTING: # committing as coordinator. tell participants to commit - if self._broadcast_state_and_check( - self._workflow.state, self._workflow.target_state, - TransactionState.PARTICIPANT_COMMITTING, - TransactionState.READY): + if self._broadcast_state_and_check(self._workflow.state, self._workflow.target_state, + TransactionState.PARTICIPANT_COMMITTING, TransactionState.READY): # all participants committed. finish. - self._workflow.commit() + manager.commit() self._reload() self._recover_from_abort() return self._workflow def _reload(self): - db.session.commit() - db.session.refresh(self._workflow) + self._session.commit() + self._session.refresh(self._workflow) - def _broadcast_state( - self, state, target_state, transaction_state): - project_config = self._project.get_config() + def _broadcast_state(self, state, target_state, transaction_state): + service = ParticipantService(self._session) + participants = service.get_platform_participants_by_project(self._project.id) states = [] - for party in project_config.participants: - client = RpcClient(project_config, party) - forked_from_uuid = Workflow.query.filter_by( - id=self._workflow.forked_from - ).first().uuid if self._workflow.forked_from else None - resp = client.update_workflow_state( - self._workflow.name, state, target_state, transaction_state, - self._workflow.uuid, - forked_from_uuid, self._workflow.extra) + for participant in participants: + client = RpcClient.from_project_and_participant(self._project.name, self._project.token, + participant.domain_name) + forked_from_uuid = self._session.query(Workflow).filter_by( + id=self._workflow.forked_from).first().uuid if self._workflow.forked_from else None + resp = client.update_workflow_state(self._workflow.name, state, target_state, transaction_state, + self._workflow.uuid, forked_from_uuid, self._workflow.extra) if resp.status.code == common_pb2.STATUS_SUCCESS: - if resp.state == WorkflowState.INVALID: - self._workflow.invalidate() + if WorkflowState(resp.state) == WorkflowState.INVALID: + invalidate_workflow_locally(self._session, self._workflow) self._reload() raise RuntimeError('Peer workflow invalidated. Abort.') states.append(TransactionState(resp.transaction_state)) @@ -134,8 +126,7 @@ def _broadcast_state( states.append(None) return states - def _broadcast_state_and_check(self, - state, target_state, transaction_state, target_transaction_state): + def _broadcast_state_and_check(self, state, target_state, transaction_state, target_transaction_state): states = self._broadcast_state(state, target_state, transaction_state) for i in states: if i != target_transaction_state: @@ -145,13 +136,10 @@ def _broadcast_state_and_check(self, def _recover_from_abort(self): if self._workflow.transaction_state == \ TransactionState.COORDINATOR_ABORTING: - if not self._broadcast_state_and_check( - self._workflow.state, WorkflowState.INVALID, - TransactionState.PARTICIPANT_ABORTING, - TransactionState.ABORTED): + if not self._broadcast_state_and_check(self._workflow.state, WorkflowState.INVALID, + TransactionState.PARTICIPANT_ABORTING, TransactionState.ABORTED): return False - self._workflow.update_state( - None, WorkflowState.INVALID, TransactionState.ABORTED) + self._workflow.update_state(None, WorkflowState.INVALID, TransactionState.ABORTED, self._session) self._reload() if self._workflow.transaction_state != TransactionState.ABORTED: @@ -159,10 +147,9 @@ def _recover_from_abort(self): assert self._workflow.target_state == WorkflowState.INVALID - if not self._broadcast_state_and_check( - self._workflow.state, WorkflowState.INVALID, - TransactionState.READY, TransactionState.READY): + if not self._broadcast_state_and_check(self._workflow.state, WorkflowState.INVALID, TransactionState.READY, + TransactionState.READY): return False - self._workflow.update_state(None, None, TransactionState.READY) + self._workflow.update_state(None, None, TransactionState.READY, self._session) self._reload() return True diff --git a/web_console_v2/api/fedlearner_webconsole/setting/__init__.py b/web_console_v2/api/fedlearner_webconsole/setting/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/web_console_v2/api/fedlearner_webconsole/setting/apis.py b/web_console_v2/api/fedlearner_webconsole/setting/apis.py index 339406ae4..58aaaaabb 100644 --- a/web_console_v2/api/fedlearner_webconsole/setting/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/setting/apis.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,99 +13,327 @@ # limitations under the License. # coding: utf-8 +import logging +from http import HTTPStatus from pathlib import Path +from flask_restful import Resource +from google.protobuf.json_format import ParseDict, ParseError +from marshmallow import fields -from flask_restful import Resource, reqparse - -from fedlearner_webconsole.utils.k8s_client import k8s_client -from fedlearner_webconsole.utils.decorators import jwt_required -from fedlearner_webconsole.utils.decorators import admin_required +from fedlearner_webconsole.k8s.k8s_client import k8s_client +from fedlearner_webconsole.auth.third_party_sso import credentials_required +from fedlearner_webconsole.proto.setting_pb2 import SystemVariables, SettingPb +from fedlearner_webconsole.utils.decorators.pp_flask import admin_required, use_kwargs, use_args +from fedlearner_webconsole.setting.service import DashboardService, SettingService +from fedlearner_webconsole.db import db +from fedlearner_webconsole.exceptions import (NotFoundException, NoAccessException, InvalidArgumentException) +from fedlearner_webconsole.utils.flask_utils import make_flask_response +from fedlearner_webconsole.flag.models import Flag _POD_NAMESPACE = 'default' # Ref: https://stackoverflow.com/questions/46046110/ # how-to-get-the-current-namespace-in-a-pod -_k8s_namespace_file = Path( - '/var/run/secrets/kubernetes.io/serviceaccount/namespace') +_k8s_namespace_file = Path('/var/run/secrets/kubernetes.io/serviceaccount/namespace') if _k8s_namespace_file.is_file(): - _POD_NAMESPACE = _k8s_namespace_file.read_text() + _POD_NAMESPACE = _k8s_namespace_file.read_text(encoding='utf-8') + +_SPECIAL_KEYS = ['webconsole_image', 'system_info', 'system_variables'] -class SettingsApi(Resource): - @jwt_required() +class SettingApi(Resource): + + @credentials_required @admin_required - def get(self): - deployment = k8s_client.get_deployment( - name='fedlearner-web-console-v2', namespace=_POD_NAMESPACE) + def _get_webconsole_image(self) -> SettingPb: + try: + deployment = k8s_client.get_deployment(name='fedlearner-web-console-v2') + image = deployment.spec.template.spec.containers[0].image + except Exception as e: # pylint: disable=broad-except + logging.error(f'settings: get deployment: {str(e)}') + image = None + return SettingPb( + uniq_key='webconsole_image', + value=image, + ) + + @credentials_required + @admin_required + def _get_system_variables(self) -> SystemVariables: + with db.session_scope() as session: + return SettingService(session).get_system_variables() + + def get(self, key: str): + """Gets a specific setting. + --- + tags: + - system + description: gets a specific setting. + parameters: + - in: path + name: key + schema: + type: string + required: true + responses: + 200: + description: the setting + content: + application/json: + schema: + oneOf: + - $ref: '#/definitions/fedlearner_webconsole.proto.SettingPb' + - $ref: '#/definitions/fedlearner_webconsole.proto.SystemVariables' + - $ref: '#/definitions/fedlearner_webconsole.proto.SystemInfo' + """ + if key == 'webconsole_image': + return make_flask_response(self._get_webconsole_image()) + + if key == 'system_variables': + return make_flask_response(self._get_system_variables()) + + if key == 'system_info': + return make_flask_response(SettingService.get_system_info()) + + setting = None + if key not in _SPECIAL_KEYS: + with db.session_scope() as session: + setting = SettingService(session).get_setting(key) + if setting is None: + raise NotFoundException(message=f'Failed to find setting {key}') + return make_flask_response(setting.to_proto()) + + @credentials_required + @admin_required + @use_kwargs({'value': fields.String(required=True)}) + def put(self, key: str, value: str): + """Updates a specific setting. + --- + tags: + - system + description: updates a specific setting. + parameters: + - in: path + name: key + schema: + type: string + required: true + - in: body + name: body + schema: + type: object + properties: + value: + type: str + required: true + responses: + 200: + description: logs + content: + application/json: + schema: + type: array + items: + type: string + """ + if key in _SPECIAL_KEYS: + raise NoAccessException(message=f'Not able to update {key}') + + with db.session_scope() as session: + setting = SettingService(session).create_or_update_setting(key, value) + return make_flask_response(setting.to_proto()) + - return { - 'data': { - 'webconsole_image': - deployment.spec.template.spec.containers[0].image - } - } +class UpdateSystemVariablesApi(Resource): - @jwt_required() + @credentials_required @admin_required - def patch(self): - parser = reqparse.RequestParser() - parser.add_argument('webconsole_image', - type=str, - required=False, - default=None, - help='image for webconsole') - data = parser.parse_args() - - if data['webconsole_image']: - new_image = data['webconsole_image'] - deployment = k8s_client.get_deployment('fedlearner-web-console-v2', - _POD_NAMESPACE) - spec = deployment.spec - spec.template.spec.containers[0].image = new_image - metadata = deployment.metadata - k8s_client.create_or_update_deployment( - metadata=metadata, - spec=spec, - name=metadata.name, - namespace=metadata.namespace) - - return {'data': {}} + @use_args({'variables': fields.List(fields.Dict())}) + def post(self, params: dict): + """Updates system variables. + --- + tags: + - system + description: updates all system variables. + parameters: + - in: body + name: body + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.SystemVariables' + responses: + 200: + description: updated system variables + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.SystemVariables' + """ + try: + system_variables = ParseDict(params, SystemVariables()) + except ParseError as e: + raise InvalidArgumentException(details=str(e)) from e + with db.session_scope() as session: + # TODO(xiangyuxuan.prs): check fixed flag + SettingService(session).set_system_variables(system_variables) + session.commit() + return make_flask_response(system_variables) + + +class UpdateImageApi(Resource): + + @credentials_required + @admin_required + @use_kwargs({'webconsole_image': fields.String(required=True)}) + def post(self, webconsole_image: str): + """Updates webconsole image. + --- + tags: + - system + description: updates webconsole image. + parameters: + - in: body + name: body + schema: + type: object + properties: + image_uri: + type: string + required: true + responses: + 204: + description: updated successfully + """ + deployment = k8s_client.get_deployment('fedlearner-web-console-v2') + spec = deployment.spec + spec.template.spec.containers[0].image = webconsole_image + metadata = deployment.metadata + k8s_client.create_or_update_deployment(metadata=metadata, + spec=spec, + name=metadata.name, + namespace=metadata.namespace) + return make_flask_response(status=HTTPStatus.NO_CONTENT) class SystemPodLogsApi(Resource): - @jwt_required() + + @credentials_required @admin_required - def get(self, pod_name): - parser = reqparse.RequestParser() - parser.add_argument('tail_lines', - type=int, - location='args', - required=True, - help='tail lines is required') - data = parser.parse_args() - tail_lines = data['tail_lines'] - return { - 'data': - k8s_client.get_pod_log(name=pod_name, - namespace=_POD_NAMESPACE, - tail_lines=tail_lines).split('\n') - } + @use_kwargs({'tail_lines': fields.Integer(required=True)}, location='query') + def get(self, pod_name: str, tail_lines: int): + """Gets webconsole pod logs. + --- + tags: + - system + description: gets webconsole pod logs. + parameters: + - in: path + name: pod_name + schema: + type: string + required: true + - in: query + name: tail_lines + schema: + type: integer + required: true + responses: + 200: + description: logs + content: + application/json: + schema: + type: array + items: + type: string + """ + return make_flask_response( + k8s_client.get_pod_log(name=pod_name, namespace=_POD_NAMESPACE, tail_lines=tail_lines).split('\n')) class SystemPodsApi(Resource): - @jwt_required() + + @credentials_required @admin_required def get(self): + """Gets webconsole pods. + --- + tags: + - system + description: gets webconsole pods. + responses: + 200: + description: name list of pods + content: + application/json: + schema: + type: array + items: + type: string + """ webconsole_v2_pod_list = list( - map( - lambda pod: pod.metadata.name, - k8s_client.get_pods( - _POD_NAMESPACE, - 'app.kubernetes.io/instance=fedlearner-web-console-v2'). - items)) - return {'data': webconsole_v2_pod_list} + map(lambda pod: pod.metadata.name, + k8s_client.get_pods(_POD_NAMESPACE, 'app.kubernetes.io/instance=fedlearner-web-console-v2').items)) + return make_flask_response(webconsole_v2_pod_list) + + +class VersionsApi(Resource): + # This is a system-based api, no JWT-Token for now. + def get(self): + """Gets the version info. + --- + tags: + - system + description: gets the version info. + responses: + 200: + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.ApplicationVersion' + """ + return make_flask_response(SettingService.get_application_version().to_proto()) + + +class DashboardsApi(Resource): + + @credentials_required + @admin_required + def get(self): + """Get dashboard information API + --- + tags: + - system + description: Get dashboard information API + responses: + 200: + description: a list of dashboard information. Note that the following dashboard ['overview'] is available. + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.DashboardInformation' + 500: + description: dashboard setup is wrong, please check. + content: + appliction/json: + schema: + type: object + properties: + code: + type: integer + message: + type: string + """ + if not Flag.DASHBOARD_ENABLED.value: + raise NoAccessException('if you want to view dashboard, please enable flag `DASHBOARD_ENABLED`') + return make_flask_response(DashboardService().get_dashboards()) def initialize_setting_apis(api): - api.add_resource(SettingsApi, '/settings') + api.add_resource(UpdateSystemVariablesApi, '/settings:update_system_variables') + api.add_resource(UpdateImageApi, '/settings:update_image') + api.add_resource(SettingApi, '/settings/') + api.add_resource(VersionsApi, '/versions') api.add_resource(SystemPodLogsApi, '/system_pods//logs') api.add_resource(SystemPodsApi, '/system_pods/name') + api.add_resource(DashboardsApi, '/dashboards') diff --git a/web_console_v2/api/fedlearner_webconsole/setting/models.py b/web_console_v2/api/fedlearner_webconsole/setting/models.py index 7d46db01f..f1b5bae1b 100644 --- a/web_console_v2/api/fedlearner_webconsole/setting/models.py +++ b/web_console_v2/api/fedlearner_webconsole/setting/models.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,16 +13,26 @@ # limitations under the License. # coding: utf-8 -# pylint: disable=raise-missing-from - -from sqlalchemy import UniqueConstraint +from sqlalchemy import UniqueConstraint, func from fedlearner_webconsole.db import db, default_table_args +from fedlearner_webconsole.proto.setting_pb2 import SettingPb class Setting(db.Model): __tablename__ = 'settings_v2' - __table_args__ = (UniqueConstraint('key', name='uniq_key'), - default_table_args('this is webconsole settings table')) - id = db.Column(db.Integer, primary_key=True, comment='id') - key = db.Column(db.String(255), nullable=False, comment='key') + __table_args__ = (UniqueConstraint('uniq_key', + name='uniq_key'), default_table_args('this is webconsole settings table')) + id = db.Column(db.Integer, primary_key=True, comment='id', autoincrement=True) + uniq_key = db.Column(db.String(255), nullable=False, comment='uniq_key') value = db.Column(db.Text, comment='value') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created at') + updated_at = db.Column(db.DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + comment='updated at') + + def to_proto(self): + return SettingPb( + uniq_key=self.uniq_key, + value=self.value, + ) diff --git a/web_console_v2/api/fedlearner_webconsole/sparkapp/apis.py b/web_console_v2/api/fedlearner_webconsole/sparkapp/apis.py index 70dfc6339..feca6f55a 100644 --- a/web_console_v2/api/fedlearner_webconsole/sparkapp/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/sparkapp/apis.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,54 +13,165 @@ # limitations under the License. # coding: utf-8 -import base64 from http import HTTPStatus +import logging -from flask import request from flask_restful import Api, Resource +from marshmallow import Schema, fields, post_load +from webargs.flaskparser import use_args, use_kwargs from fedlearner_webconsole.sparkapp.schema import SparkAppConfig -from fedlearner_webconsole.utils.decorators import jwt_required +from fedlearner_webconsole.auth.third_party_sso import credentials_required from fedlearner_webconsole.sparkapp.service import SparkAppService -from fedlearner_webconsole.exceptions import (InvalidArgumentException, - NotFoundException) +from fedlearner_webconsole.exceptions import (InternalException, NotFoundException) +from fedlearner_webconsole.utils.flask_utils import make_flask_response +from fedlearner_webconsole.swagger.models import schema_manager -class SparkAppsApi(Resource): - @jwt_required() - def post(self): - service = SparkAppService() - data = request.json +class SparkAppPodParameter(Schema): + cores = fields.Integer(required=True) + memory = fields.String(required=True) + instances = fields.Integer(required=False, load_default=1) + core_limit = fields.String(required=False) + volume_mounts = fields.List(fields.Dict(fields.String, fields.String), required=False) + envs = fields.Dict(fields.String, fields.String) - try: - config = SparkAppConfig.from_dict(data) - if config.files: - config.files = base64.b64decode(config.files) - except ValueError as err: - raise InvalidArgumentException(details=err) - res = service.submit_sparkapp(config=config) - return {'data': res.to_dict()}, HTTPStatus.CREATED +class SparkAppCreateParameter(Schema): + name = fields.String(required=True) + files = fields.String(required=False, load_default=None) + files_path = fields.String(required=False, load_default=None) + image_url = fields.String(required=False, load_default=None) + volumes = fields.List(fields.Dict(fields.String, fields.String), required=False, load_default=[]) + driver_config = fields.Nested(SparkAppPodParameter) + executor_config = fields.Nested(SparkAppPodParameter) + py_files = fields.List(fields.String, required=False, load_default=[]) + command = fields.List(fields.String, required=False, load_default=[]) + main_application = fields.String(required=True) + + @post_load + def make_spark_app_config(self, data, **kwargs): + del kwargs + return SparkAppConfig.from_dict(data) + + +class SparkAppsApi(Resource): + + @credentials_required + @use_args(SparkAppCreateParameter()) + def post(self, config: SparkAppConfig): + """Create sparkapp + --- + tags: + - sparkapp + description: Create sparkapp + parameters: + - in: body + name: body + schema: + $ref: '#/definitions/SparkAppCreateParameter' + responses: + 201: + description: The sparkapp is created + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.SparkAppInfo' + """ + service = SparkAppService() + return make_flask_response(data=service.submit_sparkapp(config=config), status=HTTPStatus.CREATED) class SparkAppApi(Resource): - @jwt_required() + + @credentials_required def get(self, sparkapp_name: str): + """Get sparkapp status + --- + tags: + - sparkapp + description: Get sparkapp status + parameters: + - in: path + name: sparkapp_name + schema: + type: string + responses: + 200: + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.SparkAppInfo' + """ service = SparkAppService() - return { - 'data': service.get_sparkapp_info(sparkapp_name).to_dict() - }, HTTPStatus.OK + return make_flask_response(data=service.get_sparkapp_info(sparkapp_name)) - @jwt_required() + @credentials_required def delete(self, sparkapp_name: str): + """Delete a sparkapp whether the existence of sparkapp + --- + tags: + - sparkapp + description: Delete a sparkapp whether the existence of sparkapp + parameters: + - in: path + name: sparkapp_name + schema: + type: string + responses: + 204: + description: finish sparkapp deletion + """ service = SparkAppService() try: - sparkapp_info = service.delete_sparkapp(sparkapp_name) - return {'data': sparkapp_info.to_dict()}, HTTPStatus.OK + service.delete_sparkapp(sparkapp_name) except NotFoundException: - return {'data': {'name': sparkapp_name}}, HTTPStatus.OK + logging.warning(f'[sparkapp] could not find sparkapp {sparkapp_name}') + + return make_flask_response(status=HTTPStatus.NO_CONTENT) + + +class SparkAppLogApi(Resource): + + @credentials_required + @use_kwargs({'lines': fields.Integer(required=True, help='lines is required')}, location='query') + def get(self, sparkapp_name: str, lines: int): + """Get sparkapp logs + --- + tags: + - sparkapp + description: Get sparkapp logs + parameters: + - in: path + name: sparkapp_name + schema: + type: string + - in: query + name: lines + schema: + type: integer + responses: + 200: + content: + application/json: + schema: + type: array + items: + type: string + """ + max_limit = 10000 + if lines is None or lines > max_limit: + lines = max_limit + service = SparkAppService() + try: + return make_flask_response(data=service.get_sparkapp_log(sparkapp_name, lines)) + except Exception as e: # pylint: disable=broad-except) + raise InternalException(details=f'error {e}') from e def initialize_sparkapps_apis(api: Api): api.add_resource(SparkAppsApi, '/sparkapps') api.add_resource(SparkAppApi, '/sparkapps/') + api.add_resource(SparkAppLogApi, '/sparkapps//log') + + schema_manager.append(SparkAppCreateParameter) diff --git a/web_console_v2/api/fedlearner_webconsole/sparkapp/schema.py b/web_console_v2/api/fedlearner_webconsole/sparkapp/schema.py index 31d91f44a..9b65b04ce 100644 --- a/web_console_v2/api/fedlearner_webconsole/sparkapp/schema.py +++ b/web_console_v2/api/fedlearner_webconsole/sparkapp/schema.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,26 +13,28 @@ # limitations under the License. # coding: utf-8 -from envs import Envs +import base64 +import logging +from typing import Optional +from google.protobuf.json_format import ParseDict, MessageToDict +from fedlearner_webconsole.db import db +from fedlearner_webconsole.setting.service import SettingService +from fedlearner_webconsole.utils.images import generate_unified_version_image +from fedlearner_webconsole.proto import sparkapp_pb2 -from fedlearner_webconsole.utils.mixins import from_dict_mixin, to_dict_mixin -SPARK_POD_CONFIG_SERILIZE_FIELDS = [ - 'cores', 'memory', 'instances', 'core_limit', 'envs', 'volume_mounts' -] +class SparkPodConfig(object): + def __init__(self, spark_pod_config: sparkapp_pb2.SparkPodConfig): + self._spark_pod_config = spark_pod_config -@to_dict_mixin(to_dict_fields=SPARK_POD_CONFIG_SERILIZE_FIELDS, - ignore_none=True) -@from_dict_mixin(from_dict_fields=SPARK_POD_CONFIG_SERILIZE_FIELDS) -class SparkPodConfig(object): - def __init__(self): - self.cores = None - self.memory = None - self.instances = None - self.core_limit = None - self.volume_mounts = [] - self.envs = {} + @classmethod + def from_dict(cls, inputs: dict) -> 'SparkPodConfig': + spark_pod_config = sparkapp_pb2.SparkPodConfig() + envs = inputs.pop('envs') + inputs['env'] = [{'name': k, 'value': v} for k, v in envs.items()] + spark_pod_config = ParseDict(inputs, spark_pod_config, ignore_unknown_fields=True) + return cls(spark_pod_config) def build_config(self) -> dict: """ build config for sparkoperator api @@ -41,171 +43,172 @@ def build_config(self) -> dict: Returns: dict: part of sparkoperator body """ - config = { - 'cores': self.cores, - 'memory': self.memory, - } - if self.instances: - config['instances'] = self.instances - if self.core_limit: - config['coreLimit'] = self.core_limit - if self.envs and len(self.envs) > 0: - config['env'] = [{ - 'name': k, - 'value': v - } for k, v in self.envs.items()] - if self.volume_mounts and len(self.volume_mounts) > 0: - config['volumeMounts'] = self.volume_mounts - - return config - - -SPARK_APP_CONFIG_SERILIZE_FIELDS = [ - 'name', 'files', 'files_path', 'volumes', 'image_url', 'driver_config', - 'executor_config', 'command', 'main_application', 'py_files' -] -SPARK_APP_CONFIG_REQUIRED_FIELDS = ['name', 'image_url'] - - -@to_dict_mixin(to_dict_fields=SPARK_APP_CONFIG_SERILIZE_FIELDS, - ignore_none=True) -@from_dict_mixin(from_dict_fields=SPARK_APP_CONFIG_SERILIZE_FIELDS, - required_fields=SPARK_APP_CONFIG_REQUIRED_FIELDS) + return MessageToDict(self._spark_pod_config, + including_default_value_fields=False, + preserving_proto_field_name=False) + + class SparkAppConfig(object): - def __init__(self): - self.name = None - # local files should be compressed to submit spark - self.files = None - # if nas/hdfs has those files, such as analyzer, only need files path \ - # to submit spark - self.files_path = None - self.image_url = None - self.volumes = [] - self.driver_config = SparkPodConfig() - self.executor_config = SparkPodConfig() - self.py_files = [] - self.command = [] - self.main_application = None - - def _replace_placeholder_with_real_path(self, exper: str, - sparkapp_path: str): + + def __init__(self, spark_app_config: sparkapp_pb2.SparkAppConfig): + self._spark_app_config = spark_app_config + self.files: Optional[bytes] = None + + @property + def files_path(self): + return self._spark_app_config.files_path + + @property + def name(self): + return self._spark_app_config.name + + @classmethod + def from_dict(cls, inputs: dict) -> 'SparkAppConfig': + self = cls(sparkapp_pb2.SparkAppConfig()) + if 'files' in inputs: + input_files = inputs.pop('files') + if isinstance(input_files, str): + self.files = base64.b64decode(input_files) + elif isinstance(input_files, (bytearray, bytes)): + self.files = input_files + else: + logging.debug(f'[SparkAppConfig]: ignore parsing files fields, expected type is str or bytes, \ + actually is {type(input_files)}') + self._spark_app_config = ParseDict(inputs, self._spark_app_config, ignore_unknown_fields=True) + return self + + def _replace_placeholder_with_real_path(self, exper: str, sparkapp_path: str) -> str: """ replace ${prefix} with real path Args: + exper (str): sparkapp expression in body sparkapp_path (str): sparkapp real path + + Returns: + return the real path without ${prefix} expression """ return exper.replace('${prefix}', sparkapp_path) def build_config(self, sparkapp_path: str) -> dict: - return { - 'apiVersion': 'sparkoperator.k8s.io/v1beta2', - 'kind': 'SparkApplication', - 'metadata': { - 'name': self.name, - 'namespace': Envs.K8S_NAMESPACE, - 'labels': Envs.K8S_LABEL_INFO - }, - 'spec': { - 'type': - 'Python', - 'pythonVersion': - '3', - 'mode': - 'cluster', - 'image': - self.image_url, - 'imagePullPolicy': - 'Always', - 'volumes': - self.volumes, - 'mainApplicationFile': - self._replace_placeholder_with_real_path( - self.main_application, sparkapp_path), - 'arguments': [ - self._replace_placeholder_with_real_path(c, sparkapp_path) - for c in self.command - ], - 'deps': { - 'pyFiles': [ - self._replace_placeholder_with_real_path( - f, sparkapp_path) for f in self.py_files - ] - }, - 'sparkConf': { - 'spark.shuffle.service.enabled': 'false', - }, - 'sparkVersion': - '3.0.0', - 'restartPolicy': { - 'type': 'Never', - }, - 'dynamicAllocation': { - 'enabled': False, - }, - 'driver': { - **self.driver_config.build_config(), - 'labels': { - 'version': '3.0.0' + # sparkapp configuration limitation: initial executors must [5, 30] + if self._spark_app_config.executor_config.instances > 30: + self._spark_app_config.dynamic_allocation.max_executors = self._spark_app_config.executor_config.instances + self._spark_app_config.executor_config.instances = 30 + + with db.session_scope() as session: + setting_service = SettingService(session) + sys_variables = setting_service.get_system_variables_dict() + namespace = setting_service.get_namespace() + labels = sys_variables.get('labels') + if not self._spark_app_config.image_url: + self._spark_app_config.image_url = generate_unified_version_image(sys_variables.get('spark_image')) + for volume in sys_variables.get('volumes_list', []): + self._spark_app_config.volumes.append( + ParseDict(volume, sparkapp_pb2.Volume(), ignore_unknown_fields=True)) + for volume_mount in sys_variables.get('volume_mounts_list', []): + volume_mount_pb = ParseDict(volume_mount, sparkapp_pb2.VolumeMount(), ignore_unknown_fields=True) + self._spark_app_config.executor_config.volume_mounts.append(volume_mount_pb) + self._spark_app_config.driver_config.volume_mounts.append(volume_mount_pb) + envs_list = [] + for env in sys_variables.get('envs_list', []): + envs_list.append(ParseDict(env, sparkapp_pb2.Env())) + self._spark_app_config.driver_config.env.extend(envs_list) + self._spark_app_config.executor_config.env.extend(envs_list) + base_config = { + 'apiVersion': 'sparkoperator.k8s.io/v1beta2', + 'kind': 'SparkApplication', + 'metadata': { + 'name': self._spark_app_config.name, + 'namespace': namespace, + 'labels': labels, + # Aimed for resource queue management purpose. + # It should work fine on where there is no resource queue service. + 'annotations': { + 'queue': 'fedlearner-spark', + 'schedulerName': 'batch', }, - 'serviceAccount': 'spark', }, - 'executor': { - **self.executor_config.build_config(), - 'labels': { - 'version': '3.0.0' + 'spec': { + 'type': + 'Python', + 'timeToLiveSeconds': + 1800, + 'pythonVersion': + '3', + 'mode': + 'cluster', + 'image': + self._spark_app_config.image_url, + 'imagePullPolicy': + 'IfNotPresent', + 'volumes': [ + MessageToDict(volume, including_default_value_fields=False, preserving_proto_field_name=False) + for volume in self._spark_app_config.volumes + ], + 'arguments': [ + self._replace_placeholder_with_real_path(c, sparkapp_path) + for c in self._spark_app_config.command + ], + 'sparkConf': { + 'spark.shuffle.service.enabled': 'false', }, + 'sparkVersion': + '3.0.0', + 'restartPolicy': { + 'type': 'Never', + }, + 'dynamicAllocation': + MessageToDict(self._spark_app_config.dynamic_allocation, + including_default_value_fields=False, + preserving_proto_field_name=False), + 'driver': { + **SparkPodConfig(self._spark_app_config.driver_config).build_config(), + 'labels': { + 'version': '3.0.0' + }, + 'serviceAccount': 'spark', + }, + 'executor': { + **SparkPodConfig(self._spark_app_config.executor_config).build_config(), + 'labels': { + 'version': '3.0.0' + }, + } } } - } - - -SPARK_APP_INFO_SERILIZE_FIELDS = [ - 'name', 'namespace', 'command', 'driver', 'executor', 'image_url', - 'main_application', 'spark_version', 'type', 'state' -] - - -@to_dict_mixin(to_dict_fields=SPARK_APP_INFO_SERILIZE_FIELDS, ignore_none=True) -@from_dict_mixin(from_dict_fields=SPARK_APP_INFO_SERILIZE_FIELDS) -class SparkAppInfo(object): - @classmethod - def from_k8s_resp(cls, resp): - sparkapp_info = cls() - if 'name' in resp['metadata']: - sparkapp_info.name = resp['metadata']['name'] - elif 'name' in resp['details']: - sparkapp_info.name = resp['details']['name'] - sparkapp_info.namespace = resp['metadata'].get('namespace', None) - sparkapp_info.state = None - if 'status' in resp: - if isinstance(resp['status'], str): - sparkapp_info.state = None - elif isinstance(resp['status'], dict): - sparkapp_info.state = resp.get('status', - {}).get('applicationState', - {}).get('state', None) - sparkapp_info.command = resp.get('spec', {}).get('arguments', None) - sparkapp_info.executor = SparkPodConfig.from_dict( - resp.get('spec', {}).get('executor', {})) - sparkapp_info.driver = SparkPodConfig.from_dict( - resp.get('spec', {}).get('driver', {})) - sparkapp_info.image_url = resp.get('spec', {}).get('image', None) - sparkapp_info.main_application = resp.get('spec', {}).get( - 'mainApplicationFile', None) - sparkapp_info.spark_version = resp.get('spec', - {}).get('sparkVersion', None) - sparkapp_info.type = resp.get('spec', {}).get('type', None) - - return sparkapp_info - - def __init__(self): - self.name = None - self.state = None - self.namespace = None - self.command = None - self.driver = SparkPodConfig() - self.executor = SparkPodConfig() - self.image_url = None - self.main_application = None - self.spark_version = None - self.type = None + if self._spark_app_config.main_application: + base_config['spec']['mainApplicationFile'] = self._replace_placeholder_with_real_path( + self._spark_app_config.main_application, sparkapp_path) + if self._spark_app_config.py_files: + base_config['spec']['deps'] = { + 'pyFiles': [ + self._replace_placeholder_with_real_path(f, sparkapp_path) + for f in self._spark_app_config.py_files + ] + } + return base_config + + +def from_k8s_resp(resp: dict) -> sparkapp_pb2.SparkAppInfo: + sparkapp_info = sparkapp_pb2.SparkAppInfo() + if 'name' in resp['metadata']: + sparkapp_info.name = resp['metadata']['name'] + elif 'name' in resp['details']: + sparkapp_info.name = resp['details']['name'] + sparkapp_info.namespace = resp['metadata'].get('namespace', '') + if 'status' in resp: + if isinstance(resp['status'], str): + sparkapp_info.state = resp['status'] + elif isinstance(resp['status'], dict): + sparkapp_info.state = resp.get('status', {}).get('applicationState', {}).get('state', '') + sparkapp_info.command.extend(resp.get('spec', {}).get('arguments', [])) + sparkapp_info.executor.MergeFrom( + ParseDict(resp.get('spec', {}).get('executor', {}), sparkapp_pb2.SparkPodConfig(), ignore_unknown_fields=True)) + sparkapp_info.driver.MergeFrom( + ParseDict(resp.get('spec', {}).get('driver', {}), sparkapp_pb2.SparkPodConfig(), ignore_unknown_fields=True)) + sparkapp_info.image_url = resp.get('spec', {}).get('image', '') + sparkapp_info.main_application = resp.get('spec', {}).get('mainApplicationFile', '') + sparkapp_info.spark_version = resp.get('spec', {}).get('sparkVersion', '3') + sparkapp_info.type = resp.get('spec', {}).get('type', '') + + return sparkapp_info diff --git a/web_console_v2/api/fedlearner_webconsole/sparkapp/service.py b/web_console_v2/api/fedlearner_webconsole/sparkapp/service.py index 21e612777..68a14f4be 100644 --- a/web_console_v2/api/fedlearner_webconsole/sparkapp/service.py +++ b/web_console_v2/api/fedlearner_webconsole/sparkapp/service.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,29 +20,23 @@ from typing import Tuple from envs import Envs +from fedlearner_webconsole.proto import sparkapp_pb2 from fedlearner_webconsole.utils.file_manager import FileManager -from fedlearner_webconsole.sparkapp.schema import SparkAppConfig, SparkAppInfo -from fedlearner_webconsole.utils.k8s_client import k8s_client -from fedlearner_webconsole.utils.tars import TarCli +from fedlearner_webconsole.sparkapp.schema import SparkAppConfig, from_k8s_resp +from fedlearner_webconsole.k8s.k8s_client import (SPARKOPERATOR_CUSTOM_GROUP, SPARKOPERATOR_CUSTOM_VERSION, CrdKind, + k8s_client, SPARKOPERATOR_NAMESPACE) +from fedlearner_webconsole.utils.file_operator import FileOperator UPLOAD_PATH = Envs.STORAGE_ROOT class SparkAppService(object): + def __init__(self) -> None: self._base_dir = os.path.join(UPLOAD_PATH, 'sparkapp') - self._file_client = FileManager() - - self._file_client.mkdir(self._base_dir) - - def _clear_and_make_an_empty_dir(self, dir_name: str): - try: - self._file_client.remove(dir_name) - except Exception as err: # pylint: disable=broad-except - logging.error('failed to remove %s with exception %s', dir_name, - err) - finally: - self._file_client.mkdir(dir_name) + self._file_manager = FileManager() + self._file_operator = FileOperator() + self._file_manager.mkdir(self._base_dir) def _get_sparkapp_upload_path(self, name: str) -> Tuple[bool, str]: """get upload path for specific sparkapp @@ -57,50 +51,10 @@ def _get_sparkapp_upload_path(self, name: str) -> Tuple[bool, str]: """ sparkapp_path = os.path.join(self._base_dir, name) - existable = False - try: - self._file_client.ls(sparkapp_path) - existable = True - except ValueError: - existable = False - + existable = self._file_manager.isdir(sparkapp_path) return existable, sparkapp_path - def _copy_files_to_target_filesystem(self, source_filesystem_path: str, - target_filesystem_path: str) -> bool: - """ copy files to remote filesystem - - untar if file is tared - - copy files to remote filesystem - - Args: - source_filesystem_path (str): local filesystem - target_filesystem_path (str): remote filesystem - - Returns: - bool: whether success - """ - temp_path = source_filesystem_path - if source_filesystem_path.find('.tar') != -1: - temp_path = os.path.abspath( - os.path.join(source_filesystem_path, '../tmp')) - os.makedirs(temp_path) - TarCli.untar_file(source_filesystem_path, temp_path) - - for root, dirs, files in os.walk(temp_path): - relative_path = os.path.relpath(root, temp_path) - for f in files: - file_path = os.path.join(root, f) - remote_file_path = os.path.join(target_filesystem_path, - relative_path, f) - self._file_client.copy(file_path, remote_file_path) - for d in dirs: - remote_dir_path = os.path.join(target_filesystem_path, - relative_path, d) - self._file_client.mkdir(remote_dir_path) - - return True - - def submit_sparkapp(self, config: SparkAppConfig) -> SparkAppInfo: + def submit_sparkapp(self, config: SparkAppConfig) -> sparkapp_pb2.SparkAppInfo: """submit sparkapp Args: @@ -112,25 +66,27 @@ def submit_sparkapp(self, config: SparkAppConfig) -> SparkAppInfo: Returns: SparkAppInfo: resp of sparkapp """ + logging.info(f'submit sparkapp with config:{config}') sparkapp_path = config.files_path - if config.files_path is None: + if not config.files_path: _, sparkapp_path = self._get_sparkapp_upload_path(config.name) - self._clear_and_make_an_empty_dir(sparkapp_path) + self._file_operator.clear_and_make_an_empty_dir(sparkapp_path) - with tempfile.TemporaryDirectory() as temp_dir: - tar_path = os.path.join(temp_dir, 'files.tar') - with open(tar_path, 'wb') as fwrite: - fwrite.write(config.files) - self._copy_files_to_target_filesystem( - source_filesystem_path=tar_path, - target_filesystem_path=sparkapp_path) + # In case there is no files + if config.files is not None: + with tempfile.TemporaryDirectory() as temp_dir: + tar_path = os.path.join(temp_dir, 'files.tar') + with open(tar_path, 'wb') as fwrite: + fwrite.write(config.files) + self._file_operator.copy_to(tar_path, sparkapp_path, extract=True) config_dict = config.build_config(sparkapp_path) logging.info(f'submit sparkapp, config: {config_dict}') - resp = k8s_client.create_sparkapplication(config_dict) - return SparkAppInfo.from_k8s_resp(resp) + resp = k8s_client.create_app(config_dict, SPARKOPERATOR_CUSTOM_GROUP, SPARKOPERATOR_CUSTOM_VERSION, + CrdKind.SPARK_APPLICATION.value) + return from_k8s_resp(resp) - def get_sparkapp_info(self, name: str) -> SparkAppInfo: + def get_sparkapp_info(self, name: str) -> sparkapp_pb2.SparkAppInfo: """ get sparkapp info Args: @@ -143,9 +99,21 @@ def get_sparkapp_info(self, name: str) -> SparkAppInfo: SparkAppInfo: resp of sparkapp """ resp = k8s_client.get_sparkapplication(name) - return SparkAppInfo.from_k8s_resp(resp) + return from_k8s_resp(resp) + + def get_sparkapp_log(self, name: str, lines: int) -> str: + """ get sparkapp log + + Args: + name (str): sparkapp name + lines (int): max lines of log + + Returns: + str: sparkapp log + """ + return k8s_client.get_pod_log(f'{name}-driver', SPARKOPERATOR_NAMESPACE, tail_lines=lines) - def delete_sparkapp(self, name: str) -> SparkAppInfo: + def delete_sparkapp(self, name: str) -> sparkapp_pb2.SparkAppInfo: """delete sparkapp - delete sparkapp. If failed, raise exception - delete the tmp filesystem @@ -162,9 +130,9 @@ def delete_sparkapp(self, name: str) -> SparkAppInfo: """ existable, sparkapp_path = self._get_sparkapp_upload_path(name) if existable: - self._file_client.remove(sparkapp_path) + self._file_manager.remove(sparkapp_path) resp = k8s_client.delete_sparkapplication(name) - sparkapp_info = SparkAppInfo.from_k8s_resp(resp) + sparkapp_info = from_k8s_resp(resp) return sparkapp_info diff --git a/web_console_v2/api/fedlearner_webconsole/utils/base64.py b/web_console_v2/api/fedlearner_webconsole/utils/base64.py deleted file mode 100644 index 06272b638..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/base64.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -from base64 import b64encode, b64decode - - -def base64encode(s: str) -> str: - return b64encode(s.encode('UTF-8')).decode('UTF-8') - - -def base64decode(s: str) -> str: - return b64decode(s).decode('UTF-8') diff --git a/web_console_v2/api/fedlearner_webconsole/utils/certificate.py b/web_console_v2/api/fedlearner_webconsole/utils/certificate.py deleted file mode 100644 index 9d400e94f..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/certificate.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import json -from base64 import b64encode - -from fedlearner_webconsole.utils.k8s_client import k8s_client - - -def create_image_pull_secret(): - """Create certificate for image hub (Once for a system)""" - image_hub_url = os.environ.get('IMAGE_HUB_URL') - image_hub_username = os.environ.get('IMAGE_HUB_USERNAME') - image_hub_password = os.environ.get('IMAGE_HUB_PASSWORD') - if image_hub_url is None or image_hub_username is None or \ - image_hub_password is None: - return - - # using base64 to encode authorization information - encoded_username_password = str(b64encode( - '{}:{}'.format(image_hub_username, image_hub_password) - )) - encoded_image_cert = str(b64encode( - json.dumps({ - 'auths': { - image_hub_url: { - 'username': image_hub_username, - 'password': image_hub_password, - 'auth': encoded_username_password - } - }})), 'utf-8') - - k8s_client.create_or_update_secret( - data={ - '.dockerconfigjson': encoded_image_cert - }, - metadata={ - 'name': 'regcred', - 'namespace': 'default' - }, - secret_type='kubernetes.io/dockerconfigjson', - name='regcred' - ) diff --git a/web_console_v2/api/fedlearner_webconsole/utils/decorators.py b/web_console_v2/api/fedlearner_webconsole/utils/decorators.py deleted file mode 100644 index 3f0a0aee2..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/decorators.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License - -# coding=utf-8 - -import logging -from functools import wraps -from traceback import format_exc -import flask_jwt_extended -from flask_jwt_extended.utils import get_current_user -from fedlearner_webconsole.auth.models import Role -from fedlearner_webconsole.exceptions import UnauthorizedException -from envs import Envs - - -def admin_required(f): - @wraps(f) - def wrapper_inside(*args, **kwargs): - current_user = get_current_user() - if current_user.role != Role.ADMIN: - raise UnauthorizedException('only admin can operate this') - return f(*args, **kwargs) - return wrapper_inside - - -def jwt_required(*jwt_args, **jwt_kwargs): - def decorator(f): - if Envs.DEBUG: - @wraps(f) - def wrapper(*args, **kwargs): - return f(*args, **kwargs) - else: - wrapper = flask_jwt_extended.jwt_required( - *jwt_args, **jwt_kwargs)(f) - return wrapper - return decorator - - -def retry_fn(retry_times: int = 3, needed_exceptions=None): - def decorator_retry_fn(f): - # to resolve pylint warning - # Dangerous default value [] as argument (dangerous-default-value) - nonlocal needed_exceptions - if needed_exceptions is None: - needed_exceptions = [Exception] - - @wraps(f) - def wrapper(*args, **kwargs): - for i in range(retry_times): - try: - return f(*args, **kwargs) - except tuple(needed_exceptions): - logging.error('Call function failed, retrying %s times...', - i + 1) - logging.error('Exceptions:\n%s', format_exc()) - logging.error( - 'function name is %s, args are %s, kwargs are %s', - f.__name__, repr(args), repr(kwargs)) - if i == retry_times - 1: - raise - continue - - return wrapper - - return decorator_retry_fn diff --git a/web_console_v2/api/fedlearner_webconsole/utils/es.py b/web_console_v2/api/fedlearner_webconsole/utils/es.py index 058d7fbf6..3e90ea39c 100644 --- a/web_console_v2/api/fedlearner_webconsole/utils/es.py +++ b/web_console_v2/api/fedlearner_webconsole/utils/es.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # coding: utf-8 # pylint: disable=invalid-string-quote import json +import time +from typing import Dict, List, Optional from elasticsearch import Elasticsearch @@ -26,26 +28,45 @@ class ElasticSearchClient(object): def __init__(self): self._es_client = None self._es_client = Elasticsearch([{ - 'host': Envs.ES_READ_HOST or Envs.ES_HOST, + 'host': Envs.ES_HOST, 'port': Envs.ES_PORT }], - http_auth=(Envs.ES_USERNAME, - Envs.ES_PASSWORD)) + http_auth=(Envs.ES_USERNAME, Envs.ES_PASSWORD), + timeout=10000) def search(self, *args, **kwargs): return self._es_client.search(*args, **kwargs) def query_log(self, - index, - keyword, - pod_name, - start_time, - end_time, - match_phrase=None): + index: str, + keyword: str, + pod_name: str, + start_time: int = 0, + end_time: Optional[int] = None, + match_phrase: Optional[Dict[str, str]] = None) -> List[str]: + """query log from es + + Args: + index (str): the es index you that you want to search from + keyword (str): some keyword you may want to filter + pod_name (str): the pod that you want to query + start_time (int, optional): start time for search range in microsecond + end_time (int, optional): end time for search range in microsecond. Defaults to None. + match_phrase (Dict[str, str], optional): match phrase. Defaults to None. + + Returns: + List[str]: List for logs per line + """ + end_time = end_time or int(time.time() * 1000) query_body = { 'version': True, 'size': 8000, 'sort': [{ + 'log.nanostimestamp': { + 'order': 'desc', + 'unmapped_type': 'long' + } + }, { '@timestamp': 'desc' }, { 'log.offset': { @@ -66,7 +87,7 @@ def query_log(self, 'query': keyword, 'analyze_wildcard': True, 'default_operator': 'AND', - 'default_field': '*' + 'default_field': 'message' } }] if keyword else [] match_phrase_list = [ @@ -88,17 +109,16 @@ def query_log(self, response = self._es_client.search(index=index, body=query_body) return [item['_source']['message'] for item in response['hits']['hits']] - def query_events(self, - index, - keyword, - pod_name, - start_time, - end_time, - match_phrase=None): + def query_events(self, index, keyword, pod_name, start_time, end_time, match_phrase=None): query_body = { 'version': True, 'size': 8000, 'sort': [{ + 'log.nanostimestamp': { + 'order': 'desc', + 'unmapped_type': 'long' + } + }, { '@timestamp': 'desc' }, { 'log.offset': { @@ -119,7 +139,7 @@ def query_events(self, 'query': f'{keyword} AND Event', 'analyze_wildcard': True, 'default_operator': 'AND', - 'default_field': '*' + 'default_field': 'message' } }] if keyword else [] match_phrase_list = [ @@ -141,11 +161,7 @@ def query_events(self, response = self._es_client.search(index=index, body=query_body) return [item['_source']['message'] for item in response['hits']['hits']] - def put_ilm(self, - ilm_name, - hot_size='50gb', - hot_age='10d', - delete_age='30d'): + def put_ilm(self, ilm_name, hot_size='50gb', hot_age='10d', delete_age='30d'): if self._es_client is None: raise RuntimeError('ES client not yet initialized.') ilm_body = { @@ -264,43 +280,50 @@ def query_data_join_metrics(self, job_name, num_buckets): } } } - return es.search(index='data_join*', body=query) - def query_nn_metrics(self, job_name, num_buckets): + def query_nn_metrics(self, job_name: str, metric_list: List[str], num_buckets: int = 30): query = { - "size": 0, - "query": { - "bool": { - "must": [{ - "term": { - "tags.application_id": job_name + 'size': 0, + 'query': { + 'bool': { + 'must': [{ + 'term': { + 'tags.application_id': job_name } }, { - "term": { - "name": "auc" + 'terms': { + 'name': metric_list } }] } }, - "aggs": { - "PROCESS_TIME": { - "auto_date_histogram": { - "field": "tags.process_time", - "format": "strict_date_optional_time", - "buckets": num_buckets + 'aggs': { + metric: { + 'filter': { + 'term': { + 'name': metric + } }, - "aggs": { - "AUC": { - "avg": { - "field": "value" + 'aggs': { + 'PROCESS_TIME': { + 'auto_date_histogram': { + 'field': 'tags.process_time', + 'format': 'strict_date_optional_time', + 'buckets': num_buckets + }, + 'aggs': { + 'VALUE': { + 'avg': { + 'field': 'value' + } + } } } } - } + } for metric in metric_list } } - return es.search(index='metrics*', body=query) def query_tree_metrics(self, job_name, metric_list): @@ -340,7 +363,9 @@ def query_tree_metrics(self, job_name, metric_list): "TOP": { "top_hits": { "size": 100, - "sort": [{"tags.process_time": "asc"}], + "sort": [{ + "tags.process_time": "asc" + }], "_source": ["value", "tags.iteration"] } } @@ -350,8 +375,7 @@ def query_tree_metrics(self, job_name, metric_list): } for metric in metric_list } } - response = es.search(index='metrics*', body=query) - return response['aggregations'] + return es.search(index='metrics*', body=query) def query_time_metrics(self, job_name, num_buckets, index='raw_data*'): query = { diff --git a/web_console_v2/api/fedlearner_webconsole/utils/es_misc.py b/web_console_v2/api/fedlearner_webconsole/utils/es_misc.py index 87861097a..2915c308f 100644 --- a/web_console_v2/api/fedlearner_webconsole/utils/es_misc.py +++ b/web_console_v2/api/fedlearner_webconsole/utils/es_misc.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. @@ -19,16 +19,14 @@ _es_datetime_format = 'strict_date_optional_time' RAW_DATA_MAPPINGS = { 'dynamic': True, - 'dynamic_templates': [ - { - 'strings': { - 'match_mapping_type': 'string', - 'mapping': { - 'type': 'keyword' - } + 'dynamic_templates': [{ + 'strings': { + 'match_mapping_type': 'string', + 'mapping': { + 'type': 'keyword' } } - ], + }], 'properties': { 'tags': { 'properties': { @@ -54,16 +52,14 @@ DATA_JOIN_MAPPINGS = { 'dynamic': True, # for dynamically adding string fields, use keyword to reduce space - 'dynamic_templates': [ - { - 'strings': { - 'match_mapping_type': 'string', - 'mapping': { - 'type': 'keyword' - } + 'dynamic_templates': [{ + 'strings': { + 'match_mapping_type': 'string', + 'mapping': { + 'type': 'keyword' } } - ], + }], 'properties': { 'tags': { 'properties': { @@ -105,16 +101,14 @@ } METRICS_MAPPINGS = { 'dynamic': True, - 'dynamic_templates': [ - { - 'strings': { - 'match_mapping_type': 'string', - 'mapping': { - 'type': 'keyword' - } + 'dynamic_templates': [{ + 'strings': { + 'match_mapping_type': 'string', + 'mapping': { + 'type': 'keyword' } } - ], + }], 'properties': { 'name': { 'type': 'keyword' @@ -155,33 +149,33 @@ } } } -ALIAS_NAME = {'metrics': 'metrics_v2', - 'raw_data': 'raw_data', - 'data_join': 'data_join'} -INDEX_MAP = {'metrics': METRICS_MAPPINGS, - 'raw_data': RAW_DATA_MAPPINGS, - 'data_join': DATA_JOIN_MAPPINGS} +ALIAS_NAME = {'metrics': 'metrics_v2', 'raw_data': 'raw_data', 'data_join': 'data_join'} +INDEX_MAP = {'metrics': METRICS_MAPPINGS, 'raw_data': RAW_DATA_MAPPINGS, 'data_join': DATA_JOIN_MAPPINGS} def get_es_template(index_type, shards): assert index_type in ALIAS_NAME alias_name = ALIAS_NAME[index_type] - template = {'index_patterns': ['{}-*'.format(alias_name)], - 'settings': { - 'index': { - 'lifecycle': { - 'name': 'fedlearner_{}_ilm'.format(index_type), - 'rollover_alias': alias_name - }, - 'codec': 'best_compression', - 'routing': { - 'allocation': { - 'total_shards_per_node': '1' - } - }, - 'number_of_shards': str(shards), - 'number_of_replicas': '1', + # pylint: disable=consider-using-f-string + template = { + 'index_patterns': ['{}-*'.format(alias_name)], + 'settings': { + 'index': { + 'lifecycle': { + 'name': 'fedlearner_{}_ilm'.format(index_type), + 'rollover_alias': alias_name + }, + 'codec': 'best_compression', + 'routing': { + 'allocation': { + 'total_shards_per_node': '1' } }, - 'mappings': INDEX_MAP[index_type]} + 'number_of_shards': str(shards), + 'number_of_replicas': '1', + } + }, + 'mappings': INDEX_MAP[index_type] + } + # pylint: enable=consider-using-f-string return template diff --git a/web_console_v2/api/fedlearner_webconsole/utils/fake_k8s_client.py b/web_console_v2/api/fedlearner_webconsole/utils/fake_k8s_client.py deleted file mode 100644 index 1c24824be..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/fake_k8s_client.py +++ /dev/null @@ -1,263 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -# pylint: disable=logging-format-interpolation -import logging -import datetime -from kubernetes import client - -_RAISE_EXCEPTION_KEY = 'raise_exception' - - -class FakeK8sClient(object): - """A fake k8s client for development. - - With this client we can decouple the dependency of k8s cluster. - """ - def close(self): - pass - - def create_or_update_secret(self, - data, - metadata, - secret_type, - name, - namespace='default'): - # User may pass two type of data: - # 1. dictionary - # 2. K8s Object - # They are both accepted by real K8s client, - # but K8s Object is not iterable. - if isinstance(data, dict) and _RAISE_EXCEPTION_KEY in data: - raise RuntimeError('[500] Fake exception for save_secret') - # Otherwise succeeds - logging.info('======================') - logging.info('Saved a secret with: data: {}, ' - 'metadata: {}, type: {}'.format(data, metadata, - secret_type)) - - def delete_secret(self, name, namespace='default'): - logging.info('======================') - logging.info('Deleted a secret with: name: {}'.format(name)) - - def get_secret(self, name, namespace='default'): - return client.V1Secret(api_version='v1', - data={'test': 'test'}, - kind='Secret', - metadata={ - 'name': name, - 'namespace': namespace - }, - type='Opaque') - - def create_or_update_service(self, - metadata, - spec, - name, - namespace='default'): - logging.info('======================') - logging.info('Saved a service with: spec: {}, metadata: {}'.format( - spec, metadata)) - - def delete_service(self, name, namespace='default'): - logging.info('======================') - logging.info('Deleted a service with: name: {}'.format(name)) - - def get_service(self, name, namespace='default'): - return client.V1Service( - api_version='v1', - kind='Service', - metadata=client.V1ObjectMeta(name=name, namespace=namespace), - spec=client.V1ServiceSpec(selector={'app': 'nginx'})) - - def create_or_update_ingress(self, - metadata, - spec, - name, - namespace='default'): - logging.info('======================') - logging.info('Saved a ingress with: spec: {}, metadata: {}'.format( - spec, metadata)) - - def delete_ingress(self, name, namespace='default'): - logging.info('======================') - logging.info('Deleted a ingress with: name: {}'.format(name)) - - def get_ingress(self, name, namespace='default'): - return client.NetworkingV1beta1Ingress( - api_version='networking.k8s.io/v1beta1', - kind='Ingress', - metadata=client.V1ObjectMeta(name=name, namespace=namespace), - spec=client.NetworkingV1beta1IngressSpec()) - - def create_or_update_deployment(self, - metadata, - spec, - name, - namespace='default'): - logging.info('======================') - logging.info('Saved a deployment with: spec: {}, metadata: {}'.format( - spec, metadata)) - - def delete_deployment(self, name, namespace='default'): - logging.info('======================') - logging.info('Deleted a deployment with: name: {}'.format(name)) - - def get_deployment(self, name, namespace='default'): - return client.V1Deployment( - api_version='apps/v1', - kind='Deployment', - metadata=client.V1ObjectMeta(name=name, namespace=namespace), - spec=client.V1DeploymentSpec( - selector={'matchLabels': { - 'app': 'fedlearner-operator' - }}, - template=client.V1PodTemplateSpec(spec=client.V1PodSpec( - containers=[ - client.V1Container(name='fedlearner-operator', - args=['test']) - ])))) - - def delete_flapp(self, flapp_name): - pass - - def create_flapp(self, flapp_yaml): - pass - - def get_flapp(self, flapp_name): - pods = { - 'pods': { - 'metadata': { - 'selfLink': '/api/v1/namespaces/default/pods', - 'resourceVersion': '780480990' - } - }, - 'items': [{ - 'metadata': { - 'name': '{}-0'.format(flapp_name) - } - }, { - 'metadata': { - 'name': '{}-1'.format(flapp_name) - } - }] - } - flapp = { - 'kind': 'FLAPP', - 'metadata': { - 'name': flapp_name, - 'namesapce': 'default' - }, - 'status': { - 'appState': 'FLStateRunning', - 'flReplicaStatus': { - 'Master': { - 'active': { - 'laomiao-raw-data-1223-v1-follower' - '-master-0-717b53c4-' - 'fef7-4d65-a309-63cf62494286': {} - } - }, - 'Worker': { - 'active': { - 'laomiao-raw-data-1223-v1-follower' - '-worker-0-61e49961-' - 'e6dd-4015-a246-b6d25e69a61c': {}, - 'laomiao-raw-data-1223-v1-follower' - '-worker-1-accef16a-' - '317f-440f-8f3f-7dd5b3552d25': {} - } - } - } - } - } - return {'flapp': flapp, 'pods': pods} - - def get_webshell_session(self, - flapp_name, - container_name: str, - namespace='default'): - return {'id': 1} - - def get_sparkapplication(self, - name: str, - namespace: str = 'default') -> dict: - logging.info('======================') - logging.info( - f'get spark application, name: {name}, namespace: {namespace}') - return { - 'apiVersion': 'sparkoperator.k8s.io/v1beta2', - 'kind': 'SparkApplication', - 'metadata': { - 'creationTimestamp': '2021-04-15T10:43:15Z', - 'generation': 1, - 'name': name, - 'namespace': namespace, - }, - 'status': { - 'applicationState': { - 'state': 'COMPLETED' - }, - } - } - - def create_sparkapplication(self, - json_object: dict, - namespace: str = 'default') -> dict: - logging.info('======================') - logging.info(f'create spark application, namespace: {namespace}, ' - f'json: {json_object}') - return { - 'apiVersion': 'sparkoperator.k8s.io/v1beta2', - 'kind': 'SparkApplication', - 'metadata': { - 'creationTimestamp': '2021-04-15T10:43:15Z', - 'generation': 1, - 'name': 'fl-transformer-yaml', - 'namespace': 'fedlearner', - 'resourceVersion': '348817823', - }, - 'spec': { - 'arguments': [ - 'hdfs://user/feature/data.csv', - 'hdfs://user/feature/data_tfrecords/' - ], - } - } - - def delete_sparkapplication(self, - name: str, - namespace: str = 'default') -> dict: - logging.info('======================') - logging.info( - f'delete spark application, name: {name}, namespace: {namespace}') - return { - 'kind': 'Status', - 'apiVersion': 'v1', - 'metadata': {}, - 'status': 'Success', - 'details': { - 'name': name, - 'group': 'sparkoperator.k8s.io', - 'kind': 'sparkapplications', - 'uid': '790603b6-9dd6-11eb-9282-b8599fb51ea8' - } - } - - def get_pod_log(self, name: str, namespace: str, tail_lines: int): - return [str(datetime.datetime.now())] - - def get_pods(self, namespace, label_selector): - return ['fake_fedlearner_web_console_v2'] diff --git a/web_console_v2/api/fedlearner_webconsole/utils/file_manager.py b/web_console_v2/api/fedlearner_webconsole/utils/file_manager.py index be9e51110..18420df2d 100644 --- a/web_console_v2/api/fedlearner_webconsole/utils/file_manager.py +++ b/web_console_v2/api/fedlearner_webconsole/utils/file_manager.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,29 +17,38 @@ import logging import os import re +import fsspec from collections import namedtuple -from typing import List +from typing import List, Dict, Union, Optional -from tensorflow.io import gfile +from tensorflow.io import gfile # pylint: disable=import-error + +from envs import Envs # path: absolute path of the file # size: file size in bytes # mtime: time of last modification, unix timestamp in seconds. -File = namedtuple('File', ['path', 'size', 'mtime']) -# Currently the supported format '/' or 'hdfs://' +File = namedtuple('File', ['path', 'size', 'mtime', 'is_directory']) +# Currently the supported format '/', 'hdfs://' or 'file://' # TODO(chenyikan): Add oss format when verified. -SUPPORTED_FILE_PREFIXES = r'\.+\/|^\/|^hdfs:\/\/' +SUPPORTED_FILE_PREFIXES = r'\.+\/|^\/|^hdfs:\/\/|^file:\/\/' +FILE_PREFIX = 'file://' class FileManagerBase(object): """A base interface for file manager, please implement this interface if you have specific logic to handle files, for example, HDFS with ACL.""" + def can_handle(self, path: str) -> bool: """If the manager can handle such file.""" raise NotImplementedError() - def ls(self, path: str, recursive=False) -> List[str]: + def info(self) -> Dict: + """Give details of entry at path.""" + raise NotImplementedError() + + def ls(self, path: str, include_directory=False) -> List[File]: """Lists files under a path. Raises: ValueError: When the path does not exist. @@ -53,7 +62,7 @@ def move(self, source: str, destination: str) -> bool: raise NotImplementedError() def remove(self, path: str) -> bool: - """Removes files under a path.""" + """Removes files under a path. Raises exception when path is not exists""" raise NotImplementedError() def copy(self, source: str, destination: str) -> bool: @@ -67,42 +76,101 @@ def mkdir(self, path: str) -> bool: raise NotImplementedError() def read(self, path: str) -> str: + """Read from a file path.""" + raise NotImplementedError() + + def read_bytes(self, path: str) -> bytes: + """Read from a file path by Bytes""" + raise NotImplementedError() + + def write(self, path: str, payload: str, mode: str = 'w') -> bool: + """Write payload to a file path. Will override original content.""" + raise NotImplementedError() + + def exists(self, path: str) -> bool: + """Determine whether a path exists or not""" + raise NotImplementedError() + + def isdir(self, path: str) -> bool: + """Return whether the path is a directory or not""" + raise NotImplementedError() + + def listdir(self, path: str) -> List[str]: + """Return all file/directory names in this path, not recursive""" + raise NotImplementedError() + + def rename(self, source: str, dest: str): + """Rename or move a file / directory""" raise NotImplementedError() class GFileFileManager(FileManagerBase): """Gfile file manager for all FS supported by TF, currently it covers all file types we have.""" + + # TODO(gezhengqiang): change the class name + def __init__(self): + self._fs_dict = {} + + def get_customized_fs(self, path: str) -> fsspec.spec.AbstractFileSystem: + """ + Ref: https://filesystem-spec.readthedocs.io/en/latest/_modules/fsspec/core.html?highlight=split_protocol# + # >>> from fsspec.core import split_protocol + # >>> split_protocol('hdfs:///user/test') + # >>> ('hdfs', '/user/test') + """ + protocol = self._get_protocol_from_path(path) or 'file' + if protocol not in self._fs_dict: + self._fs_dict[protocol] = fsspec.get_mapper(path).fs + return self._fs_dict[protocol] + def can_handle(self, path): if path.startswith('fake://'): return False return re.match(SUPPORTED_FILE_PREFIXES, path) - def ls(self, path: str, recursive=False) -> List[File]: - def _get_file_stats(path: str): - stat = gfile.stat(path) - return File(path=path, - size=stat.length, - mtime=int(stat.mtime_nsec / 1e9)) - - if not gfile.exists(path): - raise ValueError( - f'cannot access {path}: No such file or directory') + @staticmethod + def _get_protocol_from_path(path: str) -> Optional[str]: + """If path is '/data', then return None. If path is 'file:///data', then return 'file'.""" + return fsspec.core.split_protocol(path)[0] + + @staticmethod + def _get_file_stats_from_dict(file: Dict) -> File: + return File(path=file['path'], + size=file['size'], + mtime=int(file['mtime'] if 'mtime' in file else file['last_modified_time']), + is_directory=(file['type'] == 'directory')) + + def info(self, path: str) -> str: + fs = self.get_customized_fs(path) + info = fs.info(path) + if 'last_modified' in info: + info['last_modified_time'] = info['last_modified'] + return info + + def ls(self, path: str, include_directory=False) -> List[File]: + fs = self.get_customized_fs(path) + if not fs.exists(path): + raise ValueError(f'cannot access {path}: No such file or directory') # If it is a file - if not gfile.isdir(path): - return [_get_file_stats(path)] + info = self.info(path) + if info['type'] != 'directory': + info['path'] = path + return [self._get_file_stats_from_dict(info)] files = [] - if recursive: - for root, _, res in gfile.walk(path): - for file in res: - if not gfile.isdir(os.path.join(root, file)): - files.append(_get_file_stats(os.path.join(root, file))) - else: - for file in gfile.listdir(path): - if not gfile.isdir(os.path.join(path, file)): - files.append(_get_file_stats(os.path.join(path, file))) - # Files only + for file in fs.ls(path, detail=True): + # file['name'] from 'fs.ls' delete the protocol of the path, + # here use 'join' to obtain the file['path'] with protocol + base_path = self.info(path)['name'] # base_path does not have protocol + rel_path = os.path.relpath(file['name'], base_path) # file['name'] does not have protocol + file['path'] = os.path.join(path, rel_path) # file['path'] has protocol as well as path + if file['type'] == 'directory': + if include_directory: + files.append(self._get_file_stats_from_dict(file)) + else: + files.append(self._get_file_stats_from_dict(file)) + return files def move(self, source: str, destination: str) -> bool: @@ -112,16 +180,13 @@ def move(self, source: str, destination: str) -> bool: def remove(self, path: str) -> bool: if not gfile.isdir(path): - return os.remove(path) + return gfile.remove(path) return gfile.rmtree(path) def copy(self, source: str, destination: str) -> bool: if gfile.isdir(destination): # gfile requires a file name for copy destination. - return gfile.copy(source, - os.path.join(destination, - os.path.basename(source)), - overwrite=True) + return gfile.copy(source, os.path.join(destination, os.path.basename(source)), overwrite=True) return gfile.copy(source, destination, overwrite=True) def mkdir(self, path: str) -> bool: @@ -130,6 +195,33 @@ def mkdir(self, path: str) -> bool: def read(self, path: str) -> str: return gfile.GFile(path).read() + def read_bytes(self, path: str) -> bytes: + return gfile.GFile(path, 'rb').read() + + def write(self, path: str, payload: str, mode: str = 'w') -> bool: + if gfile.isdir(path): + raise ValueError(f'{path} is a directory: Must provide a filename') + if gfile.exists(path): + self.remove(path) + if not gfile.exists(os.path.dirname(path)): + self.mkdir(os.path.dirname(path)) + return gfile.GFile(path, mode).write(payload) + + def exists(self, path: str) -> bool: + return gfile.exists(path) + + def isdir(self, path: str) -> bool: + return gfile.isdir(path) + + def listdir(self, path: str) -> List[str]: + """Return all file/directory names in this path, not recursive""" + if not gfile.isdir(path): + raise ValueError(f'{path} must be a directory!') + return gfile.listdir(path) + + def rename(self, source: str, dest: str): + gfile.rename(source, dest) + class FileManager(FileManagerBase): """A centralized manager to handle files. @@ -138,9 +230,10 @@ class FileManager(FileManagerBase): `CUSTOMIZED_FILE_MANAGER`. For example, 'fedlearner_webconsole.utils.file_manager:HdfsFileManager' """ + def __init__(self): self._file_managers = [] - cfm_path = os.environ.get('CUSTOMIZED_FILE_MANAGER') + cfm_path = Envs.CUSTOMIZED_FILE_MANAGER if cfm_path: module_path, class_name = cfm_path.split(':') module = importlib.import_module(module_path) @@ -149,16 +242,22 @@ def __init__(self): self._file_managers.append(customized_file_manager()) self._file_managers.append(GFileFileManager()) - def can_handle(self, path): + def can_handle(self, path) -> bool: for fm in self._file_managers: if fm.can_handle(path): return True return False - def ls(self, path: str, recursive=False) -> List[File]: + def info(self, path: str) -> Dict: + for fm in self._file_managers: + if fm.can_handle(path): + return fm.info(path) + raise RuntimeError(f'info is not supported for {path}') + + def ls(self, path: str, include_directory=False) -> List[File]: for fm in self._file_managers: if fm.can_handle(path): - return fm.ls(path, recursive=recursive) + return fm.ls(path, include_directory=include_directory) raise RuntimeError(f'ls is not supported for {path}') def move(self, source: str, destination: str) -> bool: @@ -167,8 +266,7 @@ def move(self, source: str, destination: str) -> bool: if fm.can_handle(source) and fm.can_handle(destination): return fm.move(source, destination) # TODO(chenyikan): Support cross FileManager move by using buffers. - raise RuntimeError( - f'move is not supported for {source} and {destination}') + raise RuntimeError(f'move is not supported for {source} and {destination}') def remove(self, path: str) -> bool: logging.info('Removing file [%s]', path) @@ -183,8 +281,7 @@ def copy(self, source: str, destination: str) -> bool: if fm.can_handle(source) and fm.can_handle(destination): return fm.copy(source, destination) # TODO(chenyikan): Support cross FileManager move by using buffers. - raise RuntimeError( - f'copy is not supported for {source} and {destination}') + raise RuntimeError(f'copy is not supported for {source} and {destination}') def mkdir(self, path: str) -> bool: logging.info('Create directory [%s]', path) @@ -199,3 +296,49 @@ def read(self, path: str) -> str: if fm.can_handle(path): return fm.read(path) raise RuntimeError(f'read is not supported for {path}') + + def read_bytes(self, path: str) -> bytes: + logging.info(f'Read file from [{path}]') + for fm in self._file_managers: + if fm.can_handle(path): + return fm.read_bytes(path) + raise RuntimeError(f'read_bytes is not supported for {path}') + + def write(self, path: str, payload: Union[str, bytes], mode: str = 'w') -> bool: + logging.info(f'Write file to [{path}]') + for fm in self._file_managers: + if fm.can_handle(path): + return fm.write(path, payload, mode) + raise RuntimeError(f'write is not supported for {path}') + + def exists(self, path: str) -> bool: + logging.info(f'Check [{path}] existence') + for fm in self._file_managers: + if fm.can_handle(path): + return fm.exists(path) + raise RuntimeError(f'check existence is not supported for {path}') + + def isdir(self, path: str) -> bool: + logging.info(f'Determine whether [{path}] is a directory') + for fm in self._file_managers: + if fm.can_handle(path): + return fm.isdir(path) + raise RuntimeError(f'check isdir is not supported for {path}') + + def listdir(self, path: str) -> List[str]: + logging.info(f'get file/directory names from [{path}]') + for fm in self._file_managers: + if fm.can_handle(path): + return fm.listdir(path) + raise RuntimeError(f'listdir is not supported for {path}') + + def rename(self, source: str, dest: str): + logging.info(f'Rename[{source}] to [{dest}]') + for fm in self._file_managers: + if fm.can_handle(source): + fm.rename(source, dest) + return + raise RuntimeError(f'rename is not supported for {source}') + + +file_manager = FileManager() diff --git a/web_console_v2/api/fedlearner_webconsole/utils/hooks.py b/web_console_v2/api/fedlearner_webconsole/utils/hooks.py index 25d0b9a6b..b9327d3a7 100644 --- a/web_console_v2/api/fedlearner_webconsole/utils/hooks.py +++ b/web_console_v2/api/fedlearner_webconsole/utils/hooks.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,18 +14,37 @@ # coding: utf-8 import importlib +from typing import Any from envs import Envs -from fedlearner_webconsole.db import db_handler as db, get_database_uri +from fedlearner_webconsole.db import db, get_database_uri +from fedlearner_webconsole.middleware.middlewares import flask_middlewares +from fedlearner_webconsole.middleware.request_id import FlaskRequestId +from fedlearner_webconsole.middleware.api_latency import api_latency_middleware + + +def parse_and_get_fn(module_fn_path: str) -> Any: + if module_fn_path.find(':') == -1: + raise RuntimeError(f'Invalid module_fn_path: {module_fn_path}') + + module_path, func_name = module_fn_path.split(':') + try: + module = importlib.import_module(module_path) + fn = getattr(module, func_name) + except (ModuleNotFoundError, AttributeError) as e: + raise RuntimeError(f'Skipping run {module_fn_path} for no fn found') from e + # Dynamically run the function + return fn def pre_start_hook(): before_hook_path = Envs.PRE_START_HOOK if before_hook_path: - module_path, func_name = before_hook_path.split(':') - module = importlib.import_module(module_path) - # Dynamically run the function - getattr(module, func_name)() + parse_and_get_fn(before_hook_path)() # explicit rebind db engine to make hook work db.rebind(get_database_uri()) + + # Applies middlewares + flask_middlewares.register(FlaskRequestId()) + flask_middlewares.register(api_latency_middleware) diff --git a/web_console_v2/api/fedlearner_webconsole/utils/k8s_cache.py b/web_console_v2/api/fedlearner_webconsole/utils/k8s_cache.py deleted file mode 100644 index 44783205b..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/k8s_cache.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import threading -from enum import Enum - - -class EventType(Enum): - ADDED = 'ADDED' - MODIFIED = 'MODIFIED' - DELETED = 'DELETED' - - -class ObjectType(Enum): - POD = 'POD' - FLAPP = 'FLAPP' - - -class Event(object): - def __init__(self, flapp_name, event_type, obj_type, obj_dict): - self.flapp_name = flapp_name - self.event_type = event_type - self.obj_type = obj_type - # {'status': {}, 'metadata': {}} - self.obj_dict = obj_dict - - @staticmethod - def from_json(event, obj_type): - # TODO(xiangyuxuan): move this to k8s/models.py - event_type = event['type'] - obj = event['object'] - if obj_type == ObjectType.POD: - obj = obj.to_dict() - metadata = obj.get('metadata') - status = obj.get('status') - flapp_name = metadata['labels']['app-name'] - return Event(flapp_name, - EventType(event_type), - obj_type, - obj_dict={'status': status, - 'metadata': metadata}) - metadata = obj.get('metadata') - status = obj.get('status') - # put event to queue - return Event(metadata['name'], - EventType(event_type), - obj_type, - obj_dict={'status': status}) - - -class K8sCache(object): - - def __init__(self): - self._lock = threading.Lock() - # key: flapp_name, value: a dict - # {'flapp': flapp cache, 'pods': pods cache, - # 'deleted': is flapp deleted} - self._cache = {} - - # TODO(xiangyuxuan): use class instead of json to manage cache and queue - def update_cache(self, event: Event): - with self._lock: - flapp_name = event.flapp_name - if flapp_name not in self._cache: - self._cache[flapp_name] = {'pods': {'items': []}, - 'deleted': False} - # if not flapp's then pod's event - if event.obj_type == ObjectType.FLAPP: - if event.event_type == EventType.DELETED: - self._cache[flapp_name] = {'pods': {'items': []}, - 'deleted': True} - else: - self._cache[flapp_name]['deleted'] = False - self._cache[flapp_name]['flapp'] = event.obj_dict - else: - if self._cache[flapp_name]['deleted']: - return - existed = False - for index, pod in enumerate( - self._cache[flapp_name]['pods']['items']): - if pod['metadata']['name'] == \ - event.obj_dict['metadata']['name']: - existed = True - self._cache[flapp_name]['pods']['items'][index] \ - = event.obj_dict - break - if not existed: - self._cache[flapp_name]['pods'][ - 'items'].append(event.obj_dict) - - def get_cache(self, flapp_name): - # use read-write lock to fast - with self._lock: - if flapp_name in self._cache: - return self._cache[flapp_name] - return {'flapp': None, 'pods': {'items': []}} - - -k8s_cache = K8sCache() diff --git a/web_console_v2/api/fedlearner_webconsole/utils/k8s_client.py b/web_console_v2/api/fedlearner_webconsole/utils/k8s_client.py deleted file mode 100644 index 3106bdecd..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/k8s_client.py +++ /dev/null @@ -1,389 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import enum -import logging -import os -from http import HTTPStatus -from typing import Optional - -import kubernetes -import requests -from kubernetes import client -from kubernetes.client.exceptions import ApiException - -from envs import Envs -from fedlearner_webconsole.exceptions import (InvalidArgumentException, - NotFoundException, - ResourceConflictException, - InternalException) -from fedlearner_webconsole.utils.decorators import retry_fn -from fedlearner_webconsole.utils.fake_k8s_client import FakeK8sClient -from fedlearner_webconsole.utils.k8s_cache import k8s_cache - - -class CrdKind(enum.Enum): - FLAPP = 'flapps' - SPARK_APPLICATION = 'sparkapplications' - - -FEDLEARNER_CUSTOM_GROUP = 'fedlearner.k8s.io' -FEDLEARNER_CUSTOM_VERSION = 'v1alpha1' - -SPARKOPERATOR_CUSTOM_GROUP = 'sparkoperator.k8s.io' -SPARKOPERATOR_CUSTOM_VERSION = 'v1beta2' -SPARKOPERATOR_NAMESPACE = Envs.K8S_NAMESPACE - - -class K8sClient(object): - def __init__(self): - self.core = None - self.crds = None - self._networking = None - self._app = None - self._api_server_url = 'http://{}:{}'.format( - os.environ.get('FL_API_SERVER_HOST', 'fedlearner-apiserver'), - os.environ.get('FL_API_SERVER_PORT', 8101)) - - def init(self, config_path: Optional[str] = None): - # Sets config - if config_path is None: - kubernetes.config.load_incluster_config() - else: - kubernetes.config.load_kube_config(config_path) - # Inits API clients - self.core = client.CoreV1Api() - self.crds = client.CustomObjectsApi() - self._networking = client.NetworkingV1beta1Api() - self._app = client.AppsV1Api() - - def close(self): - self.core.api_client.close() - self._networking.api_client.close() - - def _raise_runtime_error(self, exception: ApiException): - raise RuntimeError('[{}] {}'.format(exception.status, - exception.reason)) - - def create_or_update_secret(self, - data, - metadata, - secret_type, - name, - namespace='default'): - """Create secret. If existed, then replace""" - request = client.V1Secret(api_version='v1', - data=data, - kind='Secret', - metadata=metadata, - type=secret_type) - try: - self.core.read_namespaced_secret(name, namespace) - # If the secret already exists, then we use patch to replace it. - # We don't use replace method because it requires `resourceVersion`. - self.core.patch_namespaced_secret(name, namespace, request) - return - except ApiException as e: - # 404 is expected if the secret does not exist - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - try: - self.core.create_namespaced_secret(namespace, request) - except ApiException as e: - self._raise_runtime_error(e) - - def delete_secret(self, name, namespace='default'): - try: - self.core.delete_namespaced_secret(name, namespace) - except ApiException as e: - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - - def get_secret(self, name, namespace='default'): - try: - return self.core.read_namespaced_secret(name, namespace) - except ApiException as e: - self._raise_runtime_error(e) - - def create_or_update_service(self, - metadata, - spec, - name, - namespace='default'): - """Create secret. If existed, then replace""" - request = client.V1Service(api_version='v1', - kind='Service', - metadata=metadata, - spec=spec) - try: - self.core.read_namespaced_service(name, namespace) - # If the service already exists, then we use patch to replace it. - # We don't use replace method because it requires `resourceVersion`. - self.core.patch_namespaced_service(name, namespace, request) - return - except ApiException as e: - # 404 is expected if the service does not exist - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - try: - self.core.create_namespaced_service(namespace, request) - except ApiException as e: - self._raise_runtime_error(e) - - def delete_service(self, name, namespace='default'): - try: - self.core.delete_namespaced_service(name, namespace) - except ApiException as e: - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - - def get_service(self, name, namespace='default'): - try: - return self.core.read_namespaced_service(name, namespace) - except ApiException as e: - self._raise_runtime_error(e) - - def create_or_update_ingress(self, - metadata, - spec, - name, - namespace='default'): - request = client.NetworkingV1beta1Ingress( - api_version='networking.k8s.io/v1beta1', - kind='Ingress', - metadata=metadata, - spec=spec) - try: - self._networking.read_namespaced_ingress(name, namespace) - # If the ingress already exists, then we use patch to replace it. - # We don't use replace method because it requires `resourceVersion`. - self._networking.patch_namespaced_ingress(name, namespace, request) - return - except ApiException as e: - # 404 is expected if the ingress does not exist - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - try: - self._networking.create_namespaced_ingress(namespace, request) - except ApiException as e: - self._raise_runtime_error(e) - - def delete_ingress(self, name, namespace='default'): - try: - self._networking.delete_namespaced_ingress(name, namespace) - except ApiException as e: - self._raise_runtime_error(e) - - def get_ingress(self, name, namespace='default'): - try: - return self._networking.read_namespaced_ingress(name, namespace) - except ApiException as e: - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - - def create_or_update_deployment(self, - metadata, - spec, - name, - namespace='default'): - request = client.V1Deployment(api_version='apps/v1', - kind='Deployment', - metadata=metadata, - spec=spec) - try: - self._app.read_namespaced_deployment(name, namespace) - # If the deployment already exists, then we use patch to replace it. - # We don't use replace method because it requires `resourceVersion`. - self._app.patch_namespaced_deployment(name, namespace, request) - return - except ApiException as e: - # 404 is expected if the deployment does not exist - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - try: - self._app.create_namespaced_deployment(namespace, request) - except ApiException as e: - self._raise_runtime_error(e) - - def delete_deployment(self, name, namespace='default'): - try: - self._app.delete_namespaced_deployment(name, namespace) - except ApiException as e: - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - - def get_deployment(self, name, namespace='default'): - try: - return self._app.read_namespaced_deployment(name, namespace) - except ApiException as e: - self._raise_runtime_error(e) - - @retry_fn(retry_times=3) - def delete_flapp(self, flapp_name): - try: - self.crds.delete_namespaced_custom_object( - group=FEDLEARNER_CUSTOM_GROUP, - version=FEDLEARNER_CUSTOM_VERSION, - namespace=Envs.K8S_NAMESPACE, - plural=CrdKind.FLAPP.value, - name=flapp_name) - except ApiException as e: - # If the flapp has been deleted then the exception gets ignored - if e.status != HTTPStatus.NOT_FOUND: - self._raise_runtime_error(e) - - @retry_fn(retry_times=3) - def create_flapp(self, flapp_yaml): - try: - self.crds.create_namespaced_custom_object( - group=FEDLEARNER_CUSTOM_GROUP, - version=FEDLEARNER_CUSTOM_VERSION, - namespace=Envs.K8S_NAMESPACE, - plural=CrdKind.FLAPP.value, - body=flapp_yaml) - except ApiException as e: - # If the flapp exists then we delete it - if e.status == HTTPStatus.CONFLICT: - self.delete_flapp(flapp_yaml['metadata']['name']) - # Raise to make it retry - raise - - def get_flapp(self, flapp_name): - return k8s_cache.get_cache(flapp_name) - - def get_webshell_session(self, - flapp_name: str, - container_name: str, - namespace='default'): - response = requests.get( - '{api_server_url}/namespaces/{namespace}/pods/{custom_object_name}/' - 'shell/${container_name}'.format( - api_server_url=self._api_server_url, - namespace=namespace, - custom_object_name=flapp_name, - container_name=container_name)) - if response.status_code != HTTPStatus.OK: - raise RuntimeError('{}:{}'.format(response.status_code, - response.content)) - return response.json() - - def get_sparkapplication(self, - name: str, - namespace: str = SPARKOPERATOR_NAMESPACE) -> dict: - """get sparkapp - - Args: - name (str): sparkapp name - namespace (str, optional): namespace to submit. - - Raises: - ApiException - - Returns: - dict: resp of k8s - """ - try: - return self.crds.get_namespaced_custom_object( - group=SPARKOPERATOR_CUSTOM_GROUP, - version=SPARKOPERATOR_CUSTOM_VERSION, - namespace=namespace, - plural=CrdKind.SPARK_APPLICATION.value, - name=name) - except ApiException as err: - if err.status == 404: - raise NotFoundException() - raise InternalException(details=err.body) - - def create_sparkapplication( - self, - json_object: dict, - namespace: str = SPARKOPERATOR_NAMESPACE) -> dict: - """ create sparkapp - - Args: - json_object (dict): json object of config - namespace (str, optional): namespace to submit. - - Raises: - ApiException - - Returns: - dict: resp of k8s - """ - try: - logging.debug('create sparkapp json is %s', json_object) - return self.crds.create_namespaced_custom_object( - group=SPARKOPERATOR_CUSTOM_GROUP, - version=SPARKOPERATOR_CUSTOM_VERSION, - namespace=namespace, - plural=CrdKind.SPARK_APPLICATION.value, - body=json_object) - except ApiException as err: - if err.status == 409: - raise ResourceConflictException(message=err.reason) - if err.status == 400: - raise InvalidArgumentException(details=err.reason) - raise InternalException(details=err.body) - - def delete_sparkapplication(self, - name: str, - namespace: str = SPARKOPERATOR_NAMESPACE - ) -> dict: - """ delete sparkapp - - Args: - name (str): sparkapp name - namespace (str, optional): namespace to delete. - - Raises: - ApiException - - Returns: - dict: resp of k8s - """ - try: - return self.crds.delete_namespaced_custom_object( - group=SPARKOPERATOR_CUSTOM_GROUP, - version=SPARKOPERATOR_CUSTOM_VERSION, - namespace=namespace, - plural=CrdKind.SPARK_APPLICATION.value, - name=name, - body=client.V1DeleteOptions()) - except ApiException as err: - if err.status == 404: - raise NotFoundException() - raise InternalException(details=err.body) - - def get_pod_log(self, name: str, namespace: str, tail_lines: int): - try: - return self.core.read_namespaced_pod_log(name=name, - namespace=namespace, - tail_lines=tail_lines) - except ApiException as e: - self._raise_runtime_error(e) - - def get_pods(self, namespace, label_selector): - try: - return self.core.list_namespaced_pod(namespace=namespace, - label_selector=label_selector) - except ApiException as e: - self._raise_runtime_error(e) - - -k8s_client = FakeK8sClient() -if Envs.FLASK_ENV == 'production' or \ - Envs.K8S_CONFIG_PATH is not None: - k8s_client = K8sClient() - k8s_client.init(Envs.K8S_CONFIG_PATH) diff --git a/web_console_v2/api/fedlearner_webconsole/utils/k8s_watcher.py b/web_console_v2/api/fedlearner_webconsole/utils/k8s_watcher.py deleted file mode 100644 index 22372a1ab..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/k8s_watcher.py +++ /dev/null @@ -1,182 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import logging -import threading -import queue -import traceback -from http import HTTPStatus -from kubernetes import client, watch -from envs import Envs, Features -from fedlearner_webconsole.utils.k8s_cache import k8s_cache, \ - Event, ObjectType -from fedlearner_webconsole.utils.k8s_client import ( - k8s_client, FEDLEARNER_CUSTOM_GROUP, - FEDLEARNER_CUSTOM_VERSION) -from fedlearner_webconsole.mmgr.service import ModelService -from fedlearner_webconsole.db import make_session_context -from fedlearner_webconsole.job.service import JobService - - -session_context = make_session_context() - -class K8sWatcher(object): - def __init__(self): - self._lock = threading.Lock() - self._running = False - self._flapp_watch_thread = None - self._pods_watch_thread = None - self._event_consumer_thread = None - - # https://stackoverflow.com/questions/62223424/ - # simplequeue-vs-queue-in-python-what-is-the- - # advantage-of-using-simplequeue - # if use simplequeue, put opt never block. - # TODO(xiangyuxuan): change to simplequeue - self._queue = queue.Queue() - self._cache = {} - self._cache_lock = threading.Lock() - - def start(self): - with self._lock: - if self._running: - logging.warning('K8s watcher has already started') - return - self._running = True - self._flapp_watch_thread = threading.Thread( - target=self._k8s_flapp_watcher, - name='flapp_watcher', - daemon=True) - self._pods_watch_thread = threading.Thread( - target=self._k8s_pods_watch, - name='pods_watcher', - daemon=True) - self._event_consumer_thread = threading.Thread( - target=self._event_consumer, - name='cache_consumer', - daemon=True) - self._pods_watch_thread.start() - self._flapp_watch_thread.start() - self._event_consumer_thread.start() - logging.info('K8s watcher started') - - def _event_consumer(self): - # TODO(xiangyuxuan): do more business level operations - while True: - try: - event = self._queue.get() - k8s_cache.update_cache(event) - # job state must be updated before model service - self._update_hook(event) - if Features.FEATURE_MODEL_K8S_HOOK: - with session_context() as session: - ModelService(session).k8s_watcher_hook(event) - session.commit() - except Exception as e: # pylint: disable=broad-except - logging.error(f'K8s event_consumer : {str(e)}. ' - f'traceback:{traceback.format_exc()}') - - def _update_hook(self, event: Event): - if event.obj_type == ObjectType.FLAPP: - logging.debug('[k8s_watcher][_update_hook]receive event %s', - event.flapp_name) - with session_context() as session: - JobService(session).update_running_state(event.flapp_name) - session.commit() - - def _k8s_flapp_watcher(self): - resource_version = '0' - watcher = watch.Watch() - while True: - logging.info(f'new stream of flapps watch rv:{resource_version}') - if not self._running: - watcher.stop() - break - # resource_version '0' means getting a recent resource without - # consistency guarantee, this is to reduce the load of etcd. - # Ref: https://kubernetes.io/docs/reference/using-api - # /api-concepts/ #the-resourceversion-parameter - stream = watcher.stream( - k8s_client.crds.list_namespaced_custom_object, - group=FEDLEARNER_CUSTOM_GROUP, - version=FEDLEARNER_CUSTOM_VERSION, - namespace=Envs.K8S_NAMESPACE, - plural='flapps', - resource_version=resource_version, - _request_timeout=900, # Sometimes watch gets stuck - ) - try: - for event in stream: - - self._produce_event(event, ObjectType.FLAPP) - - metadata = event['object'].get('metadata') - if metadata['resourceVersion'] is not None: - resource_version = max(metadata['resourceVersion'], - resource_version) - logging.debug( - f'resource_version now: {resource_version}') - except client.exceptions.ApiException as e: - logging.error(f'watcher:{str(e)}') - if e.status == HTTPStatus.GONE: - # It has been too old, resources should be relisted - resource_version = '0' - except Exception as e: # pylint: disable=broad-except - logging.error(f'K8s watcher gets event error: {str(e)}', - exc_info=True) - - def _produce_event(self, event, obj_type): - self._queue.put(Event.from_json(event, obj_type)) - - def _k8s_pods_watch(self): - resource_version = '0' - watcher = watch.Watch() - while True: - logging.info(f'new stream of pods watch rv: {resource_version}') - if not self._running: - watcher.stop() - break - # resource_version '0' means getting a recent resource without - # consistency guarantee, this is to reduce the load of etcd. - # Ref: https://kubernetes.io/docs/reference/using-api - # /api-concepts/ #the-resourceversion-parameter - stream = watcher.stream( - k8s_client.core.list_namespaced_pod, - namespace=Envs.K8S_NAMESPACE, - label_selector='app-name', - resource_version=resource_version, - _request_timeout=900, # Sometimes watch gets stuck - ) - - try: - for event in stream: - self._produce_event(event, ObjectType.POD) - metadata = event['object'].metadata - if metadata.resource_version is not None: - resource_version = max(metadata.resource_version, - resource_version) - logging.debug( - f'resource_version now: {resource_version}') - except client.exceptions.ApiException as e: - logging.error(f'watcher:{str(e)}') - if e.status == HTTPStatus.GONE: - # It has been too old, resources should be relisted - resource_version = '0' - except Exception as e: # pylint: disable=broad-except - logging.error(f'K8s watcher gets event error: {str(e)}', - exc_info=True) - - -k8s_watcher = K8sWatcher() diff --git a/web_console_v2/api/fedlearner_webconsole/utils/kibana.py b/web_console_v2/api/fedlearner_webconsole/utils/kibana.py index d9271adcf..1a7824cd3 100644 --- a/web_console_v2/api/fedlearner_webconsole/utils/kibana.py +++ b/web_console_v2/api/fedlearner_webconsole/utils/kibana.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ # limitations under the License. # coding: utf-8 -# pylint: disable=invalid-string-quote +# pylint: disable=invalid-string-quote,missing-type-doc,missing-return-type-doc,consider-using-f-string import hashlib import os import re @@ -37,34 +37,22 @@ class Kibana(object): """ TSVB = ('Rate', 'Ratio', 'Numeric') TIMELION = ('Time', 'Timer') - RISON_REPLACEMENT = {' ': '%20', - '"': '%22', - '#': '%23', - '%': '%25', - '&': '%26', - '+': '%2B', - '/': '%2F', - '=': '%3D'} - TIMELION_QUERY_REPLACEMENT = {' and ': ' AND ', - ' or ': ' OR '} + RISON_REPLACEMENT = {' ': '%20', '"': '%22', '#': '%23', '%': '%25', '&': '%26', '+': '%2B', '/': '%2F', '=': '%3D'} + TIMELION_QUERY_REPLACEMENT = {' and ': ' AND ', ' or ': ' OR '} LOGICAL_PATTERN = re.compile(' and | or ', re.IGNORECASE) - TSVB_AGG_TYPE = {'Average': 'avg', - 'Sum': 'sum', - 'Max': 'max', - 'Min': 'min', - 'Variance': 'variance', - 'Std. Deviation': 'std_deviation', - 'Sum of Squares': 'sum_of_squares'} - TIMELION_AGG_TYPE = {'Average': 'avg', - 'Sum': 'sum', - 'Max': 'max', - 'Min': 'min'} - COLORS = ['#DA6E6E', '#FA8080', '#789DFF', - '#66D4FF', '#6EB518', '#9AF02E'] + TSVB_AGG_TYPE = { + 'Average': 'avg', + 'Sum': 'sum', + 'Max': 'max', + 'Min': 'min', + 'Variance': 'variance', + 'Std. Deviation': 'std_deviation', + 'Sum of Squares': 'sum_of_squares' + } + TIMELION_AGG_TYPE = {'Average': 'avg', 'Sum': 'sum', 'Max': 'max', 'Min': 'min'} + COLORS = ['#DA6E6E', '#FA8080', '#789DFF', '#66D4FF', '#6EB518', '#9AF02E'] # metrics* for all other job types - JOB_INDEX = {JobType.RAW_DATA: 'raw_data', - JobType.DATA_JOIN: 'data_join', - JobType.PSI_DATA_JOIN: 'data_join'} + JOB_INDEX = {JobType.RAW_DATA: 'raw_data', JobType.DATA_JOIN: 'data_join', JobType.PSI_DATA_JOIN: 'data_join'} BASIC_QUERY = "app/kibana#/visualize/create" \ "?type={type}&_g=(refreshInterval:(pause:!t,value:0)," \ "time:(from:'{start_time}',to:'{end_time}'))&" \ @@ -83,17 +71,10 @@ def remote_query(job, args): if 'query' in args and args['query']: panel['filter']['query'] += ' and ({})'.format(args['query']) st, et = Kibana._parse_start_end_time(args, use_now=False) - req = { - 'timerange': { - 'timezone': Envs.TZ.zone, - 'min': st, - 'max': et - }, - 'panels': [panel] - } - res = requests.post( - os.path.join(Envs.KIBANA_SERVICE_ADDRESS, 'api/metrics/vis/data'), - json=req, headers={'kbn-xsrf': 'true'}) + req = {'timerange': {'timezone': Envs.TZ.zone, 'min': st, 'max': et}, 'panels': [panel]} + res = requests.post(os.path.join(Envs.KIBANA_SERVICE_ADDRESS, 'api/metrics/vis/data'), + json=req, + headers={'kbn-xsrf': 'true'}) try: res.raise_for_status() @@ -102,18 +83,15 @@ def remote_query(job, args): data = list(map(lambda x: [x[0], x[1] or 0], data)) return data except Exception as e: # pylint: disable=broad-except - raise InternalException(repr(e)) + raise InternalException(repr(e)) from e @staticmethod def _check_remote_args(args): - for arg in ('type', 'interval', 'x_axis_field', - 'start_time', 'end_time'): + for arg in ('type', 'interval', 'x_axis_field', 'start_time', 'end_time'): Kibana._check_present(args, arg) Kibana._check_authorization(args.get('query')) - Kibana._check_authorization(args['x_axis_field'], - extra_allowed={'tags.event_time', - 'tags.process_time'}) + Kibana._check_authorization(args['x_axis_field'], extra_allowed={'tags.event_time', 'tags.process_time'}) if args['type'] == 'Ratio': for arg in ('numerator', 'denominator'): @@ -128,8 +106,7 @@ def _check_remote_args(args): @staticmethod def _check_present(args, arg_name): if arg_name not in args or args[arg_name] is None: - raise InvalidArgumentException( - 'Missing required argument [{}].'.format(arg_name)) + raise InvalidArgumentException('Missing required argument [{}].'.format(arg_name)) @staticmethod def _check_authorization(arg, extra_allowed: set = None): @@ -140,8 +117,7 @@ def _check_authorization(arg, extra_allowed: set = None): if not query: continue if query.split(':')[0] not in allowed_fields: - raise UnauthorizedException( - 'Query [{}] is not authorized.'.format(query)) + raise UnauthorizedException('Query [{}] is not authorized.'.format(query)) @staticmethod def create_tsvb(job, args): @@ -163,17 +139,14 @@ def create_tsvb(job, args): vis_state['params']['filter']['query'] += \ ' and ({})'.format(args['query']) # rison-ify and replace - vis_state = Kibana._regex_process( - prison.dumps(vis_state), Kibana.RISON_REPLACEMENT - ) + vis_state = Kibana._regex_process(prison.dumps(vis_state), Kibana.RISON_REPLACEMENT) start_time, end_time = Kibana._parse_start_end_time(args) # a single-item list return [ - os.path.join(Envs.KIBANA_ADDRESS, - Kibana.BASIC_QUERY.format(type='metrics', - start_time=start_time, - end_time=end_time, - vis_state=vis_state)) + os.path.join( + Envs.KIBANA_ADDRESS, + Kibana.BASIC_QUERY.format(type='metrics', start_time=start_time, end_time=end_time, + vis_state=vis_state)) ] @staticmethod @@ -190,16 +163,11 @@ def create_timelion(job, args): vis_states, times = Kibana._create_timer_visualization(job, args) # a generator, rison-ify and replace - vis_states = ( - Kibana._regex_process(vs, Kibana.RISON_REPLACEMENT) - for vs in map(prison.dumps, vis_states) - ) + vis_states = (Kibana._regex_process(vs, Kibana.RISON_REPLACEMENT) for vs in map(prison.dumps, vis_states)) return [ - os.path.join(Envs.KIBANA_ADDRESS, - Kibana.BASIC_QUERY.format(type='timelion', - start_time=start, - end_time=end, - vis_state=vis_state)) + os.path.join( + Envs.KIBANA_ADDRESS, + Kibana.BASIC_QUERY.format(type='timelion', start_time=start, end_time=end, vis_state=vis_state)) for (start, end), vis_state in zip(times, vis_states) ] @@ -210,15 +178,13 @@ def _parse_start_end_time(args, use_now=True): else Kibana._normalize_datetime( datetime.now(tz=pytz.utc) - timedelta(days=365 * 5)) else: - st = Kibana._normalize_datetime( - datetime.fromtimestamp(args['start_time'], tz=pytz.utc)) + st = Kibana._normalize_datetime(datetime.fromtimestamp(args['start_time'], tz=pytz.utc)) if args['end_time'] < 0: et = 'now' if use_now \ else Kibana._normalize_datetime(datetime.now(tz=pytz.utc)) else: - et = Kibana._normalize_datetime( - datetime.fromtimestamp(args['end_time'], tz=pytz.utc)) + et = Kibana._normalize_datetime(datetime.fromtimestamp(args['end_time'], tz=pytz.utc)) return st, et @staticmethod @@ -238,10 +204,7 @@ def _regex_process(string, replacement): re_mode = re.IGNORECASE escaped_keys = map(re.escape, replacement) pattern = re.compile("|".join(escaped_keys), re_mode) - return pattern.sub( - lambda match: replacement[match.group(0).lower()], - string - ) + return pattern.sub(lambda match: replacement[match.group(0).lower()], string) @staticmethod def _create_rate_visualization(job, args): @@ -259,51 +222,46 @@ def _create_rate_visualization(job, args): params = vis_state['params'] # `w/`, `w/o` = `with`, `without` # Total w/ Fake series - twf = Kibana._tsvb_series( - label='Total w/ Fake', - metrics={'type': 'count'} - ) + twf = Kibana._tsvb_series(label='Total w/ Fake', metrics={'type': 'count'}) # Total w/o Fake series twof = Kibana._tsvb_series( labele='Total w/o Fake', metrics={'type': 'count'}, # unjoined and normal joined - series_filter={'query': 'tags.joined: "-1" or tags.joined: 1'} - ) + series_filter={'query': 'tags.joined: "-1" or tags.joined: 1'}) # Joined w/ Fake series jwf = Kibana._tsvb_series( label='Joined w/ Fake', metrics={'type': 'count'}, # faked joined and normal joined - series_filter={'query': 'tags.joined: 0 or tags.joined: 1'} - ) + series_filter={'query': 'tags.joined: 0 or tags.joined: 1'}) # Joined w/o Fake series jwof = Kibana._tsvb_series( label='Joined w/o Fake', metrics={'type': 'count'}, # normal joined - series_filter={'query': 'tags.joined: 1'} - ) + series_filter={'query': 'tags.joined: 1'}) # Join Rate w/ Fake series jrwf = Kibana._tsvb_series( series_type='ratio', label='Join Rate w/ Fake', - metrics={'numerator': 'tags.joined: 1 or tags.joined: 0', - 'denominator': '*', # joined == -1 or 0 or 1 - 'type': 'filter_ratio'}, + metrics={ + 'numerator': 'tags.joined: 1 or tags.joined: 0', + 'denominator': '*', # joined == -1 or 0 or 1 + 'type': 'filter_ratio' + }, line_width='2', - fill='0' - ) + fill='0') # Join Rate w/o Fake series - jrwof = Kibana._tsvb_series( - series_type='ratio', - label='Join Rate w/o Fake', - metrics={'numerator': 'tags.joined: 1', - 'denominator': 'tags.joined: 1 or tags.joined: "-1"', - 'type': 'filter_ratio'}, - line_width='2', - fill='0' - ) + jrwof = Kibana._tsvb_series(series_type='ratio', + label='Join Rate w/o Fake', + metrics={ + 'numerator': 'tags.joined: 1', + 'denominator': 'tags.joined: 1 or tags.joined: "-1"', + 'type': 'filter_ratio' + }, + line_width='2', + fill='0') series = [twf, twof, jwf, jwof, jrwf, jrwof] for series_, color in zip(series, Kibana.COLORS): series_['color'] = color @@ -323,37 +281,34 @@ def _create_ratio_visualization(job, args): Returns: dict. A Kibana vis state dict + Raises: + ValueError: if some args not exist + This method will create 3 time series and stack them in vis state. """ for k in ('numerator', 'denominator'): if k not in args or args[k] is None: - raise ValueError( - '[{}] should be provided in Ratio visualization'.format(k) - ) + raise ValueError('[{}] should be provided in Ratio visualization'.format(k)) vis_state = Kibana._basic_tsvb_vis_state(job, args) params = vis_state['params'] # Denominator series - denominator = Kibana._tsvb_series( - label=args['denominator'], - metrics={'type': 'count'}, - series_filter={'query': args['denominator']} - ) + denominator = Kibana._tsvb_series(label=args['denominator'], + metrics={'type': 'count'}, + series_filter={'query': args['denominator']}) # Numerator series - numerator = Kibana._tsvb_series( - label=args['numerator'], - metrics={'type': 'count'}, - series_filter={'query': args['numerator']} - ) + numerator = Kibana._tsvb_series(label=args['numerator'], + metrics={'type': 'count'}, + series_filter={'query': args['numerator']}) # Ratio series - ratio = Kibana._tsvb_series( - series_type='ratio', - label='Ratio', - metrics={'numerator': args['numerator'], - 'denominator': args['denominator'], - 'type': 'filter_ratio'}, - line_width='2', - fill='0' - ) + ratio = Kibana._tsvb_series(series_type='ratio', + label='Ratio', + metrics={ + 'numerator': args['numerator'], + 'denominator': args['denominator'], + 'type': 'filter_ratio' + }, + line_width='2', + fill='0') series = [denominator, numerator, ratio] for series_, color in zip(series, Kibana.COLORS[1::2]): series_['color'] = color @@ -371,6 +326,9 @@ def _create_numeric_visualization(job, args): Returns: dict. A Kibana vis state dict + Raises: + ValueError: if some args not exist + This method will create 1 time series. The series will filter data further by `name: args['metric_name']`. Aggregation will be applied on data's `args['value_field']` field. Aggregation types @@ -378,21 +336,17 @@ def _create_numeric_visualization(job, args): """ for k in ('aggregator', 'value_field'): if k not in args or args[k] is None: - raise ValueError( - '[{}] should be provided in Numeric visualization.' - .format(k) - ) + raise ValueError('[{}] should be provided in Numeric visualization.'.format(k)) assert args['aggregator'] in Kibana.TSVB_AGG_TYPE vis_state = Kibana._basic_tsvb_vis_state(job, args) params = vis_state['params'] - series = Kibana._tsvb_series( - label='{} of {}'.format(args['aggregator'], - args['value_field']), - metrics={'type': Kibana.TSVB_AGG_TYPE[args['aggregator']], - 'field': args['value_field']}, - line_width=2, - fill='0.5' - ) + series = Kibana._tsvb_series(label='{} of {}'.format(args['aggregator'], args['value_field']), + metrics={ + 'type': Kibana.TSVB_AGG_TYPE[args['aggregator']], + 'field': args['value_field'] + }, + line_width=2, + fill='0.5') series['color'] = Kibana.COLORS[-2] params['series'] = [series] return vis_state @@ -422,23 +376,14 @@ def _create_time_visualization(job, args): for t1, t2 in ((et, pt), (pt, et)): # t1 vs t2, max/min/median of t1 as Y axis, t2 as X axis # aggregate on t1 and histogram on t2 - max_series = Kibana._timelion_series( - query=query, index=index, - metric='max:' + t1, timefield=t2 - ) - min_series = Kibana._timelion_series( - query=query, index=index, - metric='min:' + t1, timefield=t2 - ) - median_series = Kibana._timelion_series( - query=query, index=index, - metric='percentiles:' + t1 + ':50', timefield=t2 - ) + max_series = Kibana._timelion_series(query=query, index=index, metric='max:' + t1, timefield=t2) + min_series = Kibana._timelion_series(query=query, index=index, metric='min:' + t1, timefield=t2) + median_series = Kibana._timelion_series(query=query, + index=index, + metric='percentiles:' + t1 + ':50', + timefield=t2) series = ','.join((max_series, min_series, median_series)) - vis_state = {"type": "timelion", - "params": {"expression": series, - "interval": interval}, - "aggs": []} + vis_state = {"type": "timelion", "params": {"expression": series, "interval": interval}, "aggs": []} vis_states.append(vis_state) by_pt_start = Kibana._get_start_from_job(job) by_pt_end = 'now' @@ -451,12 +396,10 @@ def _create_timer_visualization(job, args): if not names: return [], [] # split by comma, strip whitespaces of each name, filter out empty ones - args['timer_names'] = [name for name in - map(str.strip, names.split(',')) if name] + args['timer_names'] = [name for name in map(str.strip, names.split(',')) if name] if args['aggregator'] not in Kibana.TIMELION_AGG_TYPE: - raise TypeError('Aggregator [{}] is not supported in Timer ' - 'visualization.'.format(args['aggregator'])) + raise TypeError('Aggregator [{}] is not supported in Timer ' 'visualization.'.format(args['aggregator'])) metric = '{}:value'.format(Kibana.TIMELION_AGG_TYPE[args['aggregator']]) query = 'tags.application_id:{}'.format(job.name) @@ -465,25 +408,29 @@ def _create_timer_visualization(job, args): interval = args['interval'] if args['interval'] != '' else 'auto' series = [] for timer in args['timer_names']: - s = Kibana._timelion_series( - query=query + ' AND name:{}'.format(timer), index='metrics*', - metric=metric, timefield='tags.process_time' - ) + s = Kibana._timelion_series(query=query + ' AND name:{}'.format(timer), + index='metrics*', + metric=metric, + timefield='tags.process_time') series.append(s) if args['split']: # split series to different plots vis_states = [{ "type": "timelion", - "params": {"expression": s, - "interval": interval}, + "params": { + "expression": s, + "interval": interval + }, "aggs": [] } for s in series] else: # multiple series in one plot, a single-item list vis_states = [{ "type": "timelion", - "params": {"expression": ','.join(series), - "interval": interval}, + "params": { + "expression": ','.join(series), + "interval": interval + }, "aggs": [] }] start = Kibana._get_start_from_job(job) @@ -508,27 +455,29 @@ def _basic_tsvb_vis_state(job, args): """ assert 'x_axis_field' in args and args['x_axis_field'] - vis_state = {"aggs": [], - "params": {"axis_formatter": "number", - "axis_min": "", - "axis_position": "left", - "axis_scale": "normal", - "default_index_pattern": "metrics*", - "filter": {}, - "index_pattern": "", - "interval": "", - "isModelInvalid": False, - "show_grid": 1, - "show_legend": 1, - "time_field": "", - "type": "timeseries"}} + vis_state = { + "aggs": [], + "params": { + "axis_formatter": "number", + "axis_min": "", + "axis_position": "left", + "axis_scale": "normal", + "default_index_pattern": "metrics*", + "filter": {}, + "index_pattern": "", + "interval": "", + "isModelInvalid": False, + "show_grid": 1, + "show_legend": 1, + "time_field": "", + "type": "timeseries" + } + } params = vis_state['params'] params['interval'] = args.get('interval', '') params['index_pattern'] = Kibana.JOB_INDEX \ .get(job.job_type, 'metrics') + '*' - params['filter'] = Kibana._filter_query( - 'tags.application_id:"{}"'.format(job.name) - ) + params['filter'] = Kibana._filter_query('tags.application_id:"{}"'.format(job.name)) params['time_field'] = args['x_axis_field'] return vis_state @@ -547,7 +496,8 @@ def _tsvb_series(series_type='normal', **kwargs): 'series_filter': dict, additional filter on data, only applied on this series. - Returns: dict, a Kibana TSVB visualization time series definition + Returns: + dict, a Kibana TSVB visualization time series definition """ # series_id is meaningless and arbitrary to us but necessary @@ -576,9 +526,7 @@ def _tsvb_series(series_type='normal', **kwargs): } if 'series_filter' in kwargs and 'query' in kwargs['series_filter']: series['split_mode'] = 'filter' - series['filter'] = Kibana._filter_query( - kwargs['series_filter']['query'] - ) + series['filter'] = Kibana._filter_query(kwargs['series_filter']['query']) if series_type == 'ratio': # if this is a ratio series, split axis and set axis range series['separate_axis'] = 1 @@ -591,9 +539,7 @@ def _timelion_series(**kwargs): assert 'metric' in kwargs assert 'timefield' in kwargs # convert all logical `and` and `or` to `AND` and `OR` - query = Kibana._regex_process( - kwargs.get('query', '*'), Kibana.TIMELION_QUERY_REPLACEMENT - ) + query = Kibana._regex_process(kwargs.get('query', '*'), Kibana.TIMELION_QUERY_REPLACEMENT) return ".es(q=\"{query}\", index={index}, " \ "metric={metric}, timefield={timefield})" \ ".legend(showTime=true)" \ @@ -602,8 +548,10 @@ def _timelion_series(**kwargs): @staticmethod def _filter_query(query): - return {'language': 'kuery', # Kibana query - 'query': query} + return { + 'language': 'kuery', # Kibana query + 'query': query + } @staticmethod def _get_start_from_job(job): diff --git a/web_console_v2/api/fedlearner_webconsole/utils/metrics.py b/web_console_v2/api/fedlearner_webconsole/utils/metrics.py index c7c1971e2..d468e8703 100644 --- a/web_console_v2/api/fedlearner_webconsole/utils/metrics.py +++ b/web_console_v2/api/fedlearner_webconsole/utils/metrics.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. @@ -15,11 +15,56 @@ # coding: utf-8 import logging from abc import ABCMeta, abstractmethod +import sys +from typing import Dict, Union +from threading import Lock + +from opentelemetry import trace, _metrics as metrics +from opentelemetry._metrics.instrument import UpDownCounter +from opentelemetry._metrics.measurement import Measurement +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk._metrics import MeterProvider +from opentelemetry.sdk._metrics.export import (PeriodicExportingMetricReader, ConsoleMetricExporter, MetricExporter, + MetricExportResult, Metric, Sequence) +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.exporter.otlp.proto.grpc._metric_exporter import OTLPMetricExporter +from opentelemetry.sdk.trace.export import (BatchSpanProcessor, ConsoleSpanExporter, SpanExportResult, SpanExporter, + ReadableSpan) + +from envs import Envs + + +def _validate_tags(tags: Dict[str, str]): + if tags is None: + return + for k, v in tags.items(): + if not isinstance(k, str) or not isinstance(v, str): + raise TypeError(f'Expected str, actually {type(k)}: {type(v)}') + + +class DevNullSpanExporter(SpanExporter): + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + return SpanExportResult.SUCCESS + + def shutdown(self): + pass + + +class DevNullMetricExporter(MetricExporter): + + def export(self, metrics: Sequence[Metric]) -> MetricExportResult: # pylint: disable=redefined-outer-name + return MetricExportResult.SUCCESS + + def shutdown(self): + pass class MetricsHandler(metaclass=ABCMeta): + @abstractmethod - def emit_counter(self, name, value: int, tags: dict = None): + def emit_counter(self, name: str, value: Union[int, float], tags: Dict[str, str] = None): """Emits counter metrics which will be accumulated. Args: @@ -29,7 +74,7 @@ def emit_counter(self, name, value: int, tags: dict = None): """ @abstractmethod - def emit_store(self, name, value: int, tags: dict = None): + def emit_store(self, name: str, value: Union[int, float], tags: Dict[str, str] = None): """Emits store metrics. Args: @@ -41,11 +86,99 @@ def emit_store(self, name, value: int, tags: dict = None): class _DefaultMetricsHandler(MetricsHandler): - def emit_counter(self, name, value: int, tags: dict = None): - logging.info(f'[Metric][Counter] {name}: {value}', extra=tags or {}) - - def emit_store(self, name, value: int, tags: dict = None): - logging.info(f'[Metric][Store] {name}: {value}', extra=tags or {}) + def emit_counter(self, name, value: Union[int, float], tags: Dict[str, str] = None): + tags = tags or {} + logging.info(f'[Metric][Counter] {name}: {value}, tags={tags}') + + def emit_store(self, name, value: Union[int, float], tags: Dict[str, str] = None): + tags = tags or {} + logging.info(f'[Metric][Store] {name}: {value}, tags={tags}') + + +class OpenTelemetryMetricsHandler(MetricsHandler): + + class Callback: + + def __init__(self) -> None: + self._measurement_list = [] + + def add(self, value: Union[int, float], tags: Dict[str, str]): + self._measurement_list.append(Measurement(value=value, attributes=tags)) + + def __iter__(self): + return self + + def __next__(self): + if len(self._measurement_list) == 0: + raise StopIteration + return self._measurement_list.pop(0) + + def __call__(self): + return iter(self) + + @classmethod + def new_handler(cls) -> 'OpenTelemetryMetricsHandler': + instrument_module_name = 'fedlearner_webconsole' + resource = Resource.create(attributes={ + 'service.name': instrument_module_name, + 'deployment.environment': Envs.CLUSTER + }) + # initiailized trace stuff + if Envs.APM_SERVER_ENDPOINT == 'stdout': + span_exporter = ConsoleSpanExporter(out=sys.stdout) + elif Envs.APM_SERVER_ENDPOINT == '/dev/null': + span_exporter = DevNullSpanExporter() + else: + span_exporter = OTLPSpanExporter(endpoint=Envs.APM_SERVER_ENDPOINT) + tracer_provider = TracerProvider(resource=resource) + tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter)) + trace.set_tracer_provider(tracer_provider) + + # initiailized meter stuff + if Envs.APM_SERVER_ENDPOINT == 'stdout': + metric_exporter = ConsoleMetricExporter(out=sys.stdout) + elif Envs.APM_SERVER_ENDPOINT == '/dev/null': + metric_exporter = DevNullMetricExporter() + else: + metric_exporter = OTLPMetricExporter(endpoint=Envs.APM_SERVER_ENDPOINT) + reader = PeriodicExportingMetricReader(metric_exporter, export_interval_millis=60000) + meter_provider = MeterProvider(metric_readers=[reader], resource=resource) + metrics.set_meter_provider(meter_provider=meter_provider) + + return cls(tracer=tracer_provider.get_tracer(instrument_module_name), + meter=meter_provider.get_meter(instrument_module_name)) + + def __init__(self, tracer: trace.Tracer, meter: metrics.Meter): + self._tracer = tracer + self._meter = meter + + self._lock = Lock() + self._cache: Dict[str, Union[UpDownCounter, OpenTelemetryMetricsHandler.Callback]] = {} + + def emit_counter(self, name: str, value: Union[int, float], tags: Dict[str, str] = None): + # Note that the `values.` prefix is used for Elastic Index Dynamic Inference. + # Optimize by decreasing lock. + if name not in self._cache: + with self._lock: + # Double check `self._cache` content. + if name not in self._cache: + counter = self._meter.create_up_down_counter(name=f'values.{name}') + self._cache[name] = counter + assert isinstance(self._cache[name], UpDownCounter) + self._cache[name].add(value, attributes=tags) + + def emit_store(self, name: str, value: Union[int, float], tags: Dict[str, str] = None): + # Note that the `values.` prefix is used for Elastic Index Dynamic Inference. + # Optimize by decreasing lock. + if name not in self._cache: + with self._lock: + # Double check `self._cache` content. + if name not in self._cache: + cb = OpenTelemetryMetricsHandler.Callback() + self._meter.create_observable_gauge(name=f'values.{name}', callback=cb) + self._cache[name] = cb + assert isinstance(self._cache[name], OpenTelemetryMetricsHandler.Callback) + self._cache[name].add(value=value, tags=tags) class _Client(MetricsHandler): @@ -57,12 +190,16 @@ class _Client(MetricsHandler): def __init__(self): self._handlers.append(_DefaultMetricsHandler()) + # TODO(wangsen.0914): unify this behaviour to py_libs + self._handlers.append(OpenTelemetryMetricsHandler.new_handler()) - def emit_counter(self, name, value: int, tags: dict = None): + def emit_counter(self, name, value: Union[int, float], tags: Dict[str, str] = None): + _validate_tags(tags) for handler in self._handlers: handler.emit_counter(name, value, tags) - def emit_store(self, name, value: int, tags: dict = None): + def emit_store(self, name, value: Union[int, float], tags: Dict[str, str] = None): + _validate_tags(tags) for handler in self._handlers: handler.emit_store(name, value, tags) diff --git a/web_console_v2/api/fedlearner_webconsole/utils/middlewares.py b/web_console_v2/api/fedlearner_webconsole/utils/middlewares.py deleted file mode 100644 index cda20152d..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/middlewares.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import logging - - -class _MiddlewareRegistry(object): - def __init__(self): - self.middlewares = [] - - def register(self, middleware): - self.middlewares.append(middleware) - - -_middleware_registry = _MiddlewareRegistry() -register = _middleware_registry.register - - -def init_app(app): - logging.info('Initializing app with middlewares') - # Wraps app with middlewares - for middleware in _middleware_registry.middlewares: - app = middleware(app) - return app diff --git a/web_console_v2/api/fedlearner_webconsole/utils/mixins.py b/web_console_v2/api/fedlearner_webconsole/utils/mixins.py index 6ec201857..6206fbf87 100644 --- a/web_console_v2/api/fedlearner_webconsole/utils/mixins.py +++ b/web_console_v2/api/fedlearner_webconsole/utils/mixins.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,13 +14,30 @@ # coding: utf-8 from typing import List, Dict, Callable -from datetime import datetime, timezone +from datetime import datetime from enum import Enum + from sqlalchemy.ext.declarative import DeclarativeMeta from google.protobuf.message import Message from google.protobuf.json_format import MessageToDict +from fedlearner_webconsole.utils.pp_datetime import to_timestamp + + +def _to_dict_value(value): + if isinstance(value, datetime): + return to_timestamp(value) + if isinstance(value, Message): + return MessageToDict(value, preserving_proto_field_name=True, including_default_value_fields=True) + if isinstance(value, Enum): + return value.name + if isinstance(value, list): + return [_to_dict_value(v) for v in value] + if hasattr(value, 'to_dict'): + return value.to_dict() + return value + def to_dict_mixin(ignores: List[str] = None, extras: Dict[str, Callable] = None, @@ -40,6 +57,7 @@ def _get_fields(self: object) -> List[str]: def decorator(cls): """A decorator to add a to_dict method to a class.""" + def to_dict(self: object): """A helper function to convert a class to dict.""" dic = {} @@ -54,27 +72,7 @@ def to_dict(self: object): dic[extra_key] = func(self) # Converts type for key in dic: - value = dic[key] - if isinstance(value, datetime): - # If there is no timezone, we should treat it as - # UTC datetime,otherwise it will be calculated - # as local time when converting to timestamp. - # Context: all datetime in db is UTC datetime, - # see details in config.py#turn_db_timezone_to_utc - if value.tzinfo is None: - dic[key] = int( - value.replace(tzinfo=timezone.utc).timestamp()) - else: - dic[key] = int(value.timestamp()) - elif isinstance(value, Message): - dic[key] = MessageToDict( - value, - preserving_proto_field_name=True, - including_default_value_fields=True) - elif isinstance(value, Enum): - dic[key] = value.name - elif hasattr(value, 'to_dict'): - dic[key] = value.to_dict() + dic[key] = _to_dict_value(dic[key]) # remove None and emtry list and dict if ignore_none: @@ -86,33 +84,3 @@ def to_dict(self: object): return cls return decorator - - -def from_dict_mixin(from_dict_fields: List[str] = None, - required_fields: List[str] = None): - if from_dict_fields is None: - from_dict_fields = [] - if required_fields is None: - required_fields = [] - - def decorator(cls: object): - @classmethod - def from_dict(cls: object, content: dict): - obj = cls() # pylint: disable=no-value-for-parameter - for k in from_dict_fields: - if k in content: - current_type = type(getattr(obj, k)) - if hasattr(current_type, 'from_dict'): - setattr(obj, k, current_type.from_dict(content[k])) - else: - setattr(obj, k, content[k]) - for k in required_fields: - if getattr(obj, k) is None: - raise ValueError(f'{type(obj)} should have attribute {k}') - - return obj - - setattr(cls, 'from_dict', from_dict) - return cls - - return decorator diff --git a/web_console_v2/api/fedlearner_webconsole/utils/system_envs.py b/web_console_v2/api/fedlearner_webconsole/utils/system_envs.py index b75f607a6..6431a70b8 100644 --- a/web_console_v2/api/fedlearner_webconsole/utils/system_envs.py +++ b/web_console_v2/api/fedlearner_webconsole/utils/system_envs.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. @@ -13,8 +13,8 @@ # limitations under the License. # coding: utf-8 -import json -import os + +from envs import Envs def _is_valid_env(env: dict) -> bool: @@ -22,105 +22,118 @@ def _is_valid_env(env: dict) -> bool: env.get('value', None) is not None +def _normalize_env(env: dict) -> dict: + if 'value' in env: + env['value'] = str(env['value']) + return env + + def get_system_envs(): """Gets a JSON string to represent system envs.""" # Most envs should be from pod's env - envs = [ - { - 'name': 'POD_IP', - 'valueFrom': { - 'fieldRef': { - 'fieldPath': 'status.podIP' - } + envs = [{ + 'name': 'POD_IP', + 'valueFrom': { + 'fieldRef': { + 'fieldPath': 'status.podIP' } - }, - { - 'name': 'POD_NAME', - 'valueFrom': { - 'fieldRef': { - 'fieldPath': 'metadata.name' - } + } + }, { + 'name': 'POD_NAME', + 'valueFrom': { + 'fieldRef': { + 'fieldPath': 'metadata.name' } - }, - { - 'name': 'CPU_REQUEST', - 'valueFrom': { - 'resourceFieldRef': { - 'resource': 'requests.cpu' - } + } + }, { + 'name': 'CPU_REQUEST', + 'valueFrom': { + 'resourceFieldRef': { + 'resource': 'requests.cpu' } - }, - { - 'name': 'MEM_REQUEST', - 'valueFrom': { - 'resourceFieldRef': { - 'resource': 'requests.memory' - } + } + }, { + 'name': 'MEM_REQUEST', + 'valueFrom': { + 'resourceFieldRef': { + 'resource': 'requests.memory' } - }, - { - 'name': 'CPU_LIMIT', - 'valueFrom': { - 'resourceFieldRef': { - 'resource': 'limits.cpu' - } + } + }, { + 'name': 'CPU_LIMIT', + 'valueFrom': { + 'resourceFieldRef': { + 'resource': 'limits.cpu' } - }, - { - 'name': 'MEM_LIMIT', - 'valueFrom': { - 'resourceFieldRef': { - 'resource': 'limits.memory' - } + } + }, { + 'name': 'MEM_LIMIT', + 'valueFrom': { + 'resourceFieldRef': { + 'resource': 'limits.memory' } - }, - { - 'name': 'ES_HOST', - 'value': os.getenv('ES_HOST') - }, - { - 'name': 'ES_PORT', - 'value': os.getenv('ES_PORT') - }, - { - 'name': 'DB_HOST', - 'value': os.getenv('DB_HOST') - }, - { - 'name': 'DB_PORT', - 'value': os.getenv('DB_PORT') - }, - { - 'name': 'DB_DATABASE', - 'value': os.getenv('DB_DATABASE') - }, - { - 'name': 'DB_USERNAME', - 'value': os.getenv('DB_USERNAME') - }, - { - 'name': 'DB_PASSWORD', - 'value': os.getenv('DB_PASSWORD') - }, - { - 'name': 'KVSTORE_TYPE', - 'value': os.getenv('KVSTORE_TYPE') - }, - { - 'name': 'ETCD_NAME', - 'value': os.getenv('ETCD_NAME') - }, - { - 'name': 'ETCD_ADDR', - 'value': os.getenv('ETCD_ADDR') - }, - { - 'name': 'ETCD_BASE_DIR', - 'value': os.getenv('ETCD_BASE_DIR') } - ] - return ','.join([json.dumps(env) - for env in envs if _is_valid_env(env)]) + }, { + 'name': 'ES_HOST', + 'value': Envs.ES_HOST + }, { + 'name': 'ES_PORT', + 'value': Envs.ES_PORT + }, { + 'name': 'DB_HOST', + 'value': Envs.DB_HOST + }, { + 'name': 'DB_PORT', + 'value': Envs.DB_PORT + }, { + 'name': 'DB_DATABASE', + 'value': Envs.DB_DATABASE + }, { + 'name': 'DB_USERNAME', + 'value': Envs.DB_USERNAME + }, { + 'name': 'DB_PASSWORD', + 'value': Envs.DB_PASSWORD + }, { + 'name': 'KVSTORE_TYPE', + 'value': Envs.KVSTORE_TYPE + }, { + 'name': 'ETCD_NAME', + 'value': Envs.ETCD_NAME + }, { + 'name': 'ETCD_ADDR', + 'value': Envs.ETCD_ADDR + }, { + 'name': 'ETCD_BASE_DIR', + 'value': Envs.ETCD_BASE_DIR + }, { + 'name': 'ROBOT_USERNAME', + 'value': Envs.ROBOT_USERNAME + }, { + 'name': 'ROBOT_PWD', + 'value': Envs.ROBOT_PWD + }, { + 'name': 'WEB_CONSOLE_V2_ENDPOINT', + 'value': Envs.WEB_CONSOLE_V2_ENDPOINT + }, { + 'name': 'HADOOP_HOME', + 'value': Envs.HADOOP_HOME + }, { + 'name': 'JAVA_HOME', + 'value': Envs.JAVA_HOME + }, { + 'name': 'PRE_START_HOOK', + 'value': Envs.PRE_START_HOOK + }, { + 'name': 'METRIC_COLLECTOR_EXPORT_ENDPOINT', + 'value': Envs.APM_SERVER_ENDPOINT + }, { + 'name': 'CLUSTER', + 'value': Envs.CLUSTER + }] + valid_envs = [env for env in envs if _is_valid_env(env)] + envs = [_normalize_env(env) for env in valid_envs] + return envs if __name__ == '__main__': diff --git a/web_console_v2/api/fedlearner_webconsole/utils/tars.py b/web_console_v2/api/fedlearner_webconsole/utils/tars.py deleted file mode 100644 index 3e7a59ea1..000000000 --- a/web_console_v2/api/fedlearner_webconsole/utils/tars.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import tarfile - - -class TarCli: - @staticmethod - def untar_file(tar_name, extract_path_prefix): - with tarfile.open(tar_name, 'r:*') as tar_pack: - tar_pack.extractall(extract_path_prefix) - - return True diff --git a/web_console_v2/api/fedlearner_webconsole/workflow/apis.py b/web_console_v2/api/fedlearner_webconsole/workflow/apis.py index abc5c19a5..5e7012d59 100644 --- a/web_console_v2/api/fedlearner_webconsole/workflow/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/workflow/apis.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,423 +14,698 @@ # pylint: disable=global-statement # coding: utf-8 -import logging import json -from uuid import uuid4 +import logging from http import HTTPStatus -from flask_restful import Resource, reqparse, request +from typing import Optional, List + +from flask_restful import Resource from google.protobuf.json_format import MessageToDict -from fedlearner_webconsole.composer.models import ItemStatus -from fedlearner_webconsole.utils.decorators import jwt_required -from fedlearner_webconsole.workflow.models import ( - Workflow, WorkflowState, TransactionState -) -from fedlearner_webconsole.job.yaml_formatter import generate_job_run_yaml -from fedlearner_webconsole.proto import common_pb2 -from fedlearner_webconsole.workflow_template.apis import \ - dict_to_workflow_definition +from sqlalchemy.orm import Session +from marshmallow import Schema, fields, validate, post_load + +from fedlearner_webconsole.audit.decorators import emits_event from fedlearner_webconsole.db import db -from fedlearner_webconsole.exceptions import ( - NotFoundException, ResourceConflictException, InvalidArgumentException, - InternalException, NoAccessException) -from fedlearner_webconsole.scheduler.scheduler import scheduler +from fedlearner_webconsole.exceptions import (NotFoundException, InvalidArgumentException, InternalException) +from fedlearner_webconsole.iam.permission import Permission +from fedlearner_webconsole.participant.services import ParticipantService +from fedlearner_webconsole.proto import common_pb2 +from fedlearner_webconsole.proto.filtering_pb2 import FilterExpression +from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition from fedlearner_webconsole.rpc.client import RpcClient -from fedlearner_webconsole.composer.composer import composer -from fedlearner_webconsole.workflow.cronjob import WorkflowCronJobItem -from fedlearner_webconsole.utils.metrics import emit_counter +from fedlearner_webconsole.scheduler.scheduler import scheduler +from fedlearner_webconsole.swagger.models import schema_manager +from fedlearner_webconsole.utils.decorators.pp_flask import input_validator, use_kwargs +from fedlearner_webconsole.auth.third_party_sso import credentials_required +from fedlearner_webconsole.utils.flask_utils import download_json, get_current_user, make_flask_response, FilterExpField +from fedlearner_webconsole.utils.paginate import paginate +from fedlearner_webconsole.utils.proto import to_dict +from fedlearner_webconsole.workflow.models import Workflow +from fedlearner_webconsole.workflow.service import WorkflowService, \ + ForkWorkflowParams, CreateNewWorkflowParams +from fedlearner_webconsole.workflow_template.service import \ + dict_to_workflow_definition + +from fedlearner_webconsole.iam.iam_required import iam_required +from fedlearner_webconsole.workflow.workflow_job_controller import start_workflow, stop_workflow, \ + invalidate_workflow_job +from fedlearner_webconsole.proto.audit_pb2 import Event -def _get_workflow(workflow_id) -> Workflow: - result = Workflow.query.filter_by(id=workflow_id).first() - if result is None: +def _get_workflow(workflow_id: int, project_id: int, session: Session) -> Workflow: + workflow_query = session.query(Workflow) + # project_id 0 means search in all projects + if project_id != 0: + workflow_query = workflow_query.filter_by(project_id=project_id) + workflow = workflow_query.filter_by(id=workflow_id).first() + if workflow is None: raise NotFoundException() - return result - -def start_or_stop_cronjob(batch_update_interval: int, workflow: Workflow): - """start a cronjob for workflow if batch_update_interval is valid - - Args: - batch_update_interval (int): restart workflow interval, unit is minutes - - Returns: - raise when workflow is_left is False - """ - item_name = f'workflow_cron_job_{workflow.id}' - batch_update_interval = batch_update_interval * 60 - if workflow.get_config().is_left and batch_update_interval > 0: - status = composer.get_item_status(name=item_name) - # create a cronjob - if not status: - composer.collect(name=item_name, - items=[WorkflowCronJobItem(workflow.id)], - metadata={}, - interval=batch_update_interval) - return - if status == ItemStatus.OFF: - raise InvalidArgumentException( - f'cannot set item [{item_name}], since item is off') - # patch a cronjob - try: - composer.patch_item_attr(name=item_name, - key='interval_time', - value=batch_update_interval) - except ValueError as err: - raise InvalidArgumentException(details=repr(err)) - - - elif batch_update_interval < 0: - composer.finish(name=item_name) - elif not workflow.get_config().is_left: - raise InvalidArgumentException('Only left can operate this') - else: - logging.info('skip cronjob since batch_update_interval is -1') - -def is_peer_job_inheritance_matched(workflow): - # TODO: Move it to workflow service - if workflow.forked_from is None: - return True - job_flags = workflow.get_create_job_flags() - peer_job_flags = workflow.get_peer_create_job_flags() - job_defs = workflow.get_config().job_definitions - project = workflow.project - if project is None: - return True - project_config = project.get_config() - # TODO: Fix for multi-peer - client = RpcClient(project_config, project_config.participants[0]) - parent_workflow = db.session.query(Workflow).get(workflow.forked_from) - resp = client.get_workflow(parent_workflow.name) - if resp.status.code != common_pb2.STATUS_SUCCESS: - emit_counter('get_workflow_failed', 1) - raise InternalException(resp.status.msg) - peer_job_defs = resp.config.job_definitions - for i, job_def in enumerate(job_defs): - if job_def.is_federated: - for j, peer_job_def in enumerate(peer_job_defs): - if job_def.name == peer_job_def.name: - if job_flags[i] != peer_job_flags[j]: - return False - return True + return workflow + + +class GetWorkflowsParameter(Schema): + keyword = fields.String(required=False, load_default=None) + page = fields.Integer(required=False, load_default=None) + page_size = fields.Integer(required=False, load_default=None) + states = fields.List(fields.String(required=False, + validate=validate.OneOf([ + 'completed', 'failed', 'stopped', 'running', 'warmup', 'pending', 'ready', + 'configuring', 'invalid' + ])), + required=False, + load_default=None) + favour = fields.Integer(required=False, load_default=None, validate=validate.OneOf([0, 1])) + uuid = fields.String(required=False, load_default=None) + name = fields.String(required=False, load_default=None) + template_revision_id = fields.Integer(required=False, load_default=None) + filter_exp = FilterExpField(data_key='filter', required=False, load_default=None) + + +class PostWorkflowsParameter(Schema): + name = fields.Str(required=True) + config = fields.Dict(required=True) + template_id = fields.Int(required=False, load_default=None) + template_revision_id = fields.Int(required=False, load_default=None) + forkable = fields.Bool(required=True) + forked_from = fields.Int(required=False, load_default=None) + create_job_flags = fields.List(required=False, load_default=None, cls_or_instance=fields.Int) + peer_create_job_flags = fields.List(required=False, load_default=None, cls_or_instance=fields.Int) + fork_proposal_config = fields.Dict(required=False, load_default=None) + comment = fields.Str(required=False, load_default=None) + cron_config = fields.Str(required=False, load_default=None) + + @post_load() + def make(self, data, **kwargs): + data['config'] = dict_to_workflow_definition(data['config']) + data['fork_proposal_config'] = dict_to_workflow_definition(data['fork_proposal_config']) + return data + + +class PutWorkflowParameter(Schema): + config = fields.Dict(required=True) + template_id = fields.Integer(required=False, load_default=None) + template_revision_id = fields.Integer(required=False, load_default=None) + forkable = fields.Boolean(required=True) + create_job_flags = fields.List(required=False, load_default=None, cls_or_instance=fields.Integer) + comment = fields.String(required=False, load_default=None) + cron_config = fields.String(required=False, load_default=None) + + @post_load() + def make(self, data, **kwargs): + data['config'] = dict_to_workflow_definition(data['config']) + return data + + +class PatchWorkflowParameter(Schema): + config = fields.Dict(required=False, load_default=None) + template_id = fields.Integer(required=False, load_default=None) + template_revision_id = fields.Integer(required=False, load_default=None) + forkable = fields.Boolean(required=False, load_default=None) + create_job_flags = fields.List(required=False, load_default=None, cls_or_instance=fields.Integer) + cron_config = fields.String(required=False, load_default=None) + favour = fields.Boolean(required=False, load_default=None) + metric_is_public = fields.Boolean(required=False, load_default=None) + + @post_load() + def make(self, data, **kwargs): + data['config'] = data['config'] and dict_to_workflow_definition(data['config']) + return data + + +class PatchPeerWorkflowParameter(Schema): + config = fields.Dict(required=False, load_default=None) + + @post_load() + def make(self, data, **kwargs): + data['config'] = data['config'] and dict_to_workflow_definition(data['config']) + return data -class WorkflowsApi(Resource): - @jwt_required() - def get(self): - result = Workflow.query - if 'project' in request.args and request.args['project'] is not None: - project_id = request.args['project'] - result = result.filter_by(project_id=project_id) - if 'keyword' in request.args and request.args['keyword'] is not None: - keyword = request.args['keyword'] - result = result.filter(Workflow.name.like( - '%{}%'.format(keyword))) - if 'uuid' in request.args and request.args['uuid'] is not None: - uuid = request.args['uuid'] - result = result.filter_by(uuid=uuid) - res = [] - for row in result.order_by(Workflow.created_at.desc()).all(): - try: - wf_dict = row.to_dict() - except Exception as e: # pylint: disable=broad-except - wf_dict = { - 'id': row.id, - 'name': row.name, - 'uuid': row.uuid, - 'error': f'Failed to get workflow state {repr(e)}' - } - res.append(wf_dict) - return {'data': res}, HTTPStatus.OK - - @jwt_required() - def post(self): - parser = reqparse.RequestParser() - parser.add_argument('name', required=True, help='name is empty') - parser.add_argument('project_id', type=int, required=True, - help='project_id is empty') - # TODO: should verify if the config is compatible with - # workflow template - parser.add_argument('config', type=dict, required=True, - help='config is empty') - parser.add_argument('forkable', type=bool, required=True, - help='forkable is empty') - parser.add_argument('forked_from', type=int, required=False, - help='fork from base workflow') - parser.add_argument('create_job_flags', type=list, required=False, - location='json', - help='flags in common.CreateJobFlag') - parser.add_argument('peer_create_job_flags', type=list, - required=False, location='json', - help='peer flags in common.CreateJobFlag') - parser.add_argument('fork_proposal_config', type=dict, required=False, - help='fork and edit peer config') - parser.add_argument('batch_update_interval', - type=int, - required=False, - help='interval for workflow cronjob in minute') - parser.add_argument('extra', - type=str, - required=False, - help='extra json string that needs send to peer') - - parser.add_argument('comment') - data = parser.parse_args() - name = data['name'] - if Workflow.query.filter_by(name=name).first() is not None: - raise ResourceConflictException( - 'Workflow {} already exists.'.format(name)) - - # form to proto buffer - template_proto = dict_to_workflow_definition(data['config']) - workflow = Workflow(name=name, - # 20 bytes - # a DNS-1035 label must start with an - # alphabetic character. substring uuid[:19] has - # no collision in 10 million draws - uuid=f'u{uuid4().hex[:19]}', - comment=data['comment'], - project_id=data['project_id'], - forkable=data['forkable'], - forked_from=data['forked_from'], - state=WorkflowState.NEW, - target_state=WorkflowState.READY, - transaction_state=TransactionState.READY, - extra=data['extra'] - ) - workflow.set_config(template_proto) - workflow.set_create_job_flags(data['create_job_flags']) - - if workflow.forked_from is not None: - fork_config = dict_to_workflow_definition( - data['fork_proposal_config']) - # TODO: more validations - if len(fork_config.job_definitions) != \ - len(template_proto.job_definitions): - raise InvalidArgumentException( - 'Forked workflow\'s template does not match base workflow') - workflow.set_fork_proposal_config(fork_config) - workflow.set_peer_create_job_flags(data['peer_create_job_flags']) - if not is_peer_job_inheritance_matched(workflow): - raise InvalidArgumentException('Forked workflow has federated \ - job with unmatched inheritance') - - db.session.add(workflow) - db.session.commit() - logging.info('Inserted a workflow to db') - scheduler.wakeup(workflow.id) - - # start cronjob every interval time - # should start after inserting to db - batch_update_interval = data['batch_update_interval'] - if batch_update_interval: - start_or_stop_cronjob(batch_update_interval, workflow) - - return {'data': workflow.to_dict()}, HTTPStatus.CREATED +class WorkflowsApi(Resource): -class WorkflowApi(Resource): - @jwt_required() - def get(self, workflow_id): - workflow = _get_workflow(workflow_id) - result = workflow.to_dict() - result['jobs'] = [job.to_dict() for job in workflow.get_jobs()] - result['owned_jobs'] = [job.to_dict() for job in workflow.owned_jobs] - result['config'] = None - if workflow.get_config() is not None: - result['config'] = MessageToDict( - workflow.get_config(), - preserving_proto_field_name=True, - including_default_value_fields=True) - return {'data': result}, HTTPStatus.OK - - @jwt_required() - def put(self, workflow_id): - parser = reqparse.RequestParser() - parser.add_argument('config', type=dict, required=True, - help='config is empty') - parser.add_argument('forkable', type=bool, required=True, - help='forkable is empty') - parser.add_argument('create_job_flags', type=list, required=False, - location='json', - help='flags in common.CreateJobFlag') - parser.add_argument( - 'batch_update_interval', - type=int, - required=False, - help='interval time for cronjob of workflow in minute') - parser.add_argument('comment') - data = parser.parse_args() - - workflow = _get_workflow(workflow_id) - if workflow.config: - raise ResourceConflictException( - 'Resetting workflow is not allowed') - - batch_update_interval = data['batch_update_interval'] - if batch_update_interval: - start_or_stop_cronjob(batch_update_interval, workflow) - - workflow.comment = data['comment'] - workflow.forkable = data['forkable'] - workflow.set_config(dict_to_workflow_definition(data['config'])) - workflow.set_create_job_flags(data['create_job_flags']) - workflow.update_target_state(WorkflowState.READY) - db.session.commit() - scheduler.wakeup(workflow_id) - logging.info('update workflow %d target_state to %s', - workflow.id, workflow.target_state) - return {'data': workflow.to_dict()}, HTTPStatus.OK - - @jwt_required() - def patch(self, workflow_id): - parser = reqparse.RequestParser() - parser.add_argument('target_state', type=str, required=False, - default=None, help='target_state is empty') - parser.add_argument('state', - type=str, - required=False, - help='state is empty') - parser.add_argument('forkable', type=bool) - parser.add_argument('metric_is_public', type=bool) - parser.add_argument('config', - type=dict, - required=False, - help='updated config') - parser.add_argument('create_job_flags', type=list, required=False, - location='json', - help='flags in common.CreateJobFlag') - parser.add_argument('batch_update_interval', - type=int, - required=False, - help='interval for restart workflow in minute') - data = parser.parse_args() - - workflow = _get_workflow(workflow_id) - - # start workflow every interval time - batch_update_interval = data['batch_update_interval'] - if batch_update_interval: - start_or_stop_cronjob(batch_update_interval, workflow) - - forkable = data['forkable'] - if forkable is not None: - workflow.forkable = forkable - db.session.flush() - - metric_is_public = data['metric_is_public'] - if metric_is_public is not None: - workflow.metric_is_public = metric_is_public - db.session.flush() - - target_state = data['target_state'] - if target_state: + @credentials_required + @use_kwargs(GetWorkflowsParameter(), location='query') + def get( + self, + page: Optional[int], + page_size: Optional[int], + name: Optional[str], + uuid: Optional[str], + keyword: Optional[str], + favour: Optional[bool], + template_revision_id: Optional[int], + states: Optional[List[str]], + filter_exp: Optional[FilterExpression], + project_id: int, + ): + """Get workflows. + --- + tags: + - workflow + description: Get workflows. + parameters: + - in: path + name: project_id + required: true + schema: + type: integer + description: The ID of the project. 0 means get all workflows. + - in: query + name: page + schema: + type: integer + - in: query + name: page_size + schema: + type: integer + - in: query + name: name + schema: + type: string + - in: query + name: uuid + schema: + type: string + - in: query + name: keyword + schema: + type: string + - in: query + name: favour + schema: + type: boolean + - in: query + name: template_revision_id + schema: + type: integer + - in: query + name: states + schema: + type: array + collectionFormat: multi + items: + type: string + enum: [completed, failed, stopped, running, warmup, pending, ready, configuring, invalid] + - in: query + name: filter + schema: + type: string + responses: + 200: + description: list of workflows. + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowRef' + """ + with db.session_scope() as session: + result = session.query(Workflow) + if project_id != 0: + result = result.filter_by(project_id=project_id) + if name is not None: + result = result.filter_by(name=name) + if keyword is not None: + result = result.filter(Workflow.name.like(f'%{keyword}%')) + if uuid is not None: + result = result.filter_by(uuid=uuid) + if favour is not None: + result = result.filter_by(favour=favour) + if states is not None: + result = WorkflowService.filter_workflows(result, states) + if template_revision_id is not None: + result = result.filter_by(template_revision_id=template_revision_id) + if filter_exp is not None: + result = WorkflowService(session).build_filter_query(result, filter_exp) + result = result.order_by(Workflow.id.desc()) + pagination = paginate(result, page, page_size) + res = [] + for item in pagination.get_items(): + try: + wf_dict = to_dict(item.to_workflow_ref()) + except Exception as e: # pylint: disable=broad-except + wf_dict = { + 'id': item.id, + 'name': item.name, + 'uuid': item.uuid, + 'error': f'Failed to get workflow state {repr(e)}' + } + res.append(wf_dict) + # To resolve the issue of that MySQL 8 Select Count(*) is very slow + # https://bugs.mysql.com/bug.php?id=97709 + pagination.query = pagination.query.filter(Workflow.id > -1) + page_meta = pagination.get_metadata() + return make_flask_response(data=res, page_meta=page_meta) + + @input_validator + @credentials_required + @iam_required(Permission.WORKFLOWS_POST) + @emits_event(resource_type=Event.ResourceType.WORKFLOW, audit_fields=['forkable']) + @use_kwargs(PostWorkflowsParameter(), location='json') + def post( + self, + name: str, + comment: Optional[str], + forkable: bool, + forked_from: Optional[bool], + create_job_flags: Optional[List[int]], + peer_create_job_flags: Optional[List[int]], + # Peer config + fork_proposal_config: Optional[WorkflowDefinition], + template_id: Optional[int], + config: WorkflowDefinition, + cron_config: Optional[str], + template_revision_id: Optional[int], + project_id: int): + """Create workflows. + --- + tags: + - workflow + description: Get workflows. + parameters: + - in: path + description: The ID of the project. + name: project_id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PostWorkflowsParameter' + responses: + 201: + description: detail of workflows. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ + with db.session_scope() as session: + if forked_from: + params = ForkWorkflowParams(fork_from_id=forked_from, + fork_proposal_config=fork_proposal_config, + peer_create_job_flags=peer_create_job_flags) + else: + params = CreateNewWorkflowParams(project_id=project_id, + template_id=template_id, + template_revision_id=template_revision_id) try: - if WorkflowState[target_state] == WorkflowState.RUNNING: - for job in workflow.owned_jobs: - try: - generate_job_run_yaml(job) - # TODO: check if peer variables is valid - except Exception as e: # pylint: disable=broad-except - raise ValueError( - f'Invalid Variable when try ' - f'to format the job {job.name}:{str(e)}') - workflow.update_target_state(WorkflowState[target_state]) - db.session.flush() - logging.info('updated workflow %d target_state to %s', - workflow.id, workflow.target_state) + workflow = WorkflowService(session).create_workflow(name=name, + comment=comment, + forkable=forkable, + config=config, + create_job_flags=create_job_flags, + cron_config=cron_config, + params=params, + creator_username=get_current_user().username) except ValueError as e: raise InvalidArgumentException(details=str(e)) from e + session.commit() + logging.info('Inserted a workflow to db') + scheduler.wakeup(workflow.id) + return make_flask_response(data=workflow.to_proto(), status=HTTPStatus.CREATED) + - state = data['state'] - if state: +class WorkflowApi(Resource): + + @credentials_required + @use_kwargs({'download': fields.Bool(required=False, load_default=False)}, location='query') + def get(self, download: Optional[bool], project_id: int, workflow_id: int): + """Get workflow and with jobs. + --- + tags: + - workflow + description: Get workflow. + parameters: + - in: path + name: project_id + required: true + schema: + type: integer + description: The ID of the project. 0 means get all workflows. + - in: path + name: workflow_id + schema: + type: integer + required: true + - in: query + name: download + schema: + type: boolean + responses: + 200: + description: detail of workflow. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ + del project_id + with db.session_scope() as session: + workflow = session.query(Workflow).get(workflow_id) + if workflow is None: + raise NotFoundException(f'workflow {workflow_id} is not found') + result = workflow.to_proto() + result.jobs.extend([job.to_proto() for job in workflow.get_jobs(session)]) + if download: + return download_json(content=to_dict(result), filename=workflow.name) + return make_flask_response(data=result) + + @credentials_required + @iam_required(Permission.WORKFLOW_PUT) + @emits_event(resource_type=Event.ResourceType.WORKFLOW, audit_fields=['forkable']) + @use_kwargs(PutWorkflowParameter(), location='json') + def put(self, config: WorkflowDefinition, template_id: Optional[int], forkable: bool, + create_job_flags: Optional[List[int]], cron_config: Optional[str], comment: Optional[str], + template_revision_id: Optional[int], project_id: int, workflow_id: int): + """Config workflow. + --- + tags: + - workflow + description: Config workflow. + parameters: + - in: path + name: project_id + required: true + schema: + type: integer + description: The ID of the project. + - in: path + name: workflow_id + required: true + schema: + type: integer + description: The ID of the workflow. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PutWorkflowParameter' + responses: + 200: + description: detail of workflow. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ + with db.session_scope() as session: + workflow = _get_workflow(workflow_id, project_id, session) try: - assert state == 'INVALID', \ - 'Can only set state to INVALID for invalidation' - workflow.invalidate() - db.session.flush() - logging.info('invalidate workflow %d', workflow.id) + WorkflowService(session).config_workflow(workflow=workflow, + template_id=template_id, + config=config, + forkable=forkable, + comment=comment, + cron_config=cron_config, + create_job_flags=create_job_flags, + creator_username=get_current_user().username, + template_revision_id=template_revision_id) except ValueError as e: raise InvalidArgumentException(details=str(e)) from e - - config = data['config'] - if config: + session.commit() + scheduler.wakeup(workflow_id) + logging.info('update workflow %d target_state to %s', workflow.id, workflow.target_state) + return make_flask_response(data=workflow.to_proto()) + + @input_validator + @credentials_required + @iam_required(Permission.WORKFLOW_PATCH) + @emits_event(resource_type=Event.ResourceType.WORKFLOW, audit_fields=['forkable', 'metric_is_public']) + @use_kwargs(PatchWorkflowParameter(), location='json') + def patch(self, forkable: Optional[bool], metric_is_public: Optional[bool], config: Optional[WorkflowDefinition], + template_id: Optional[int], create_job_flags: Optional[List[int]], cron_config: Optional[str], + favour: Optional[bool], template_revision_id: Optional[int], project_id: int, workflow_id: int): + """Patch workflow. + --- + tags: + - workflow + description: Patch workflow. + parameters: + - in: path + name: project_id + required: true + schema: + type: integer + description: The ID of the project. + - in: path + name: workflow_id + required: true + schema: + type: integer + description: The ID of the workflow. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PatchWorkflowParameter' + responses: + 200: + description: detail of workflow. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ + with db.session_scope() as session: + workflow = _get_workflow(workflow_id, project_id, session) try: - if workflow.target_state != WorkflowState.INVALID or \ - workflow.state not in \ - [WorkflowState.READY, WorkflowState.STOPPED]: - raise NoAccessException('Cannot edit running workflow') - config_proto = dict_to_workflow_definition(data['config']) - workflow.set_config(config_proto) - db.session.flush() + WorkflowService(session).patch_workflow(workflow=workflow, + forkable=forkable, + metric_is_public=metric_is_public, + config=config, + template_id=template_id, + create_job_flags=create_job_flags, + cron_config=cron_config, + favour=favour, + template_revision_id=template_revision_id) + session.commit() except ValueError as e: raise InvalidArgumentException(details=str(e)) from e - - create_job_flags = data['create_job_flags'] - if create_job_flags: - jobs = workflow.get_jobs() - if len(create_job_flags) != len(jobs): - raise InvalidArgumentException( - details='Number of job defs does not match number ' - f'of create_job_flags {len(jobs)} ' - f'vs {len(create_job_flags)}') - workflow.set_create_job_flags(create_job_flags) - flags = workflow.get_create_job_flags() - for i, job in enumerate(jobs): - if job.workflow_id == workflow.id: - job.is_disabled = flags[i] == \ - common_pb2.CreateJobFlag.DISABLED - - db.session.commit() - scheduler.wakeup(workflow.id) - return {'data': workflow.to_dict()}, HTTPStatus.OK + return make_flask_response(data=workflow.to_proto()) class PeerWorkflowsApi(Resource): - @jwt_required() - def get(self, workflow_id): - workflow = _get_workflow(workflow_id) - project_config = workflow.project.get_config() + + @credentials_required + def get(self, project_id: int, workflow_id: int): + """Get peer workflow and with jobs. + --- + tags: + - workflow + description: Get peer workflow. + parameters: + - in: path + name: project_id + required: true + schema: + type: integer + - in: path + name: workflow_id + schema: + type: integer + required: true + responses: + 200: + description: detail of workflow. + content: + application/json: + schema: + type: object + additionalProperties: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ peer_workflows = {} - for party in project_config.participants: - client = RpcClient(project_config, party) - # TODO(xiangyxuan): use uuid to identify the workflow - resp = client.get_workflow(workflow.name) - if resp.status.code != common_pb2.STATUS_SUCCESS: - raise InternalException(resp.status.msg) - peer_workflow = MessageToDict( - resp, - preserving_proto_field_name=True, - including_default_value_fields=True) - for job in peer_workflow['jobs']: - if 'pods' in job: - job['pods'] = json.loads(job['pods']) - peer_workflows[party.name] = peer_workflow - return {'data': peer_workflows}, HTTPStatus.OK - - @jwt_required() - def patch(self, workflow_id): - parser = reqparse.RequestParser() - parser.add_argument('config', type=dict, required=True, - help='new config for peer') - data = parser.parse_args() - config_proto = dict_to_workflow_definition(data['config']) - - workflow = _get_workflow(workflow_id) - project_config = workflow.project.get_config() + with db.session_scope() as session: + workflow = _get_workflow(workflow_id, project_id, session) + service = ParticipantService(session) + participants = service.get_platform_participants_by_project(workflow.project.id) + + for participant in participants: + client = RpcClient.from_project_and_participant(workflow.project.name, workflow.project.token, + participant.domain_name) + # TODO(xiangyxuan): use uuid to identify the workflow + resp = client.get_workflow(workflow.uuid, workflow.name) + if resp.status.code != common_pb2.STATUS_SUCCESS: + raise InternalException(resp.status.msg) + peer_workflow = MessageToDict(resp, + preserving_proto_field_name=True, + including_default_value_fields=True) + for job in peer_workflow['jobs']: + if 'pods' in job: + job['pods'] = json.loads(job['pods']) + peer_workflows[participant.name] = peer_workflow + return make_flask_response(peer_workflows) + + @credentials_required + @iam_required(Permission.WORKFLOW_PATCH) + @use_kwargs(PatchPeerWorkflowParameter(), location='json') + def patch(self, config: WorkflowDefinition, project_id: int, workflow_id: int): + """Patch peer workflow. + --- + tags: + - workflow + description: patch peer workflow. + parameters: + - in: path + name: project_id + required: true + schema: + type: integer + - in: path + name: workflow_id + schema: + type: integer + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PatchPeerWorkflowParameter' + responses: + 200: + description: detail of workflow. + content: + application/json: + schema: + type: object + additionalProperties: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ peer_workflows = {} - for party in project_config.participants: - client = RpcClient(project_config, party) - resp = client.update_workflow( - workflow.name, config_proto) - if resp.status.code != common_pb2.STATUS_SUCCESS: - raise InternalException(resp.status.msg) - peer_workflows[party.name] = MessageToDict( - resp, - preserving_proto_field_name=True, - including_default_value_fields=True) - return {'data': peer_workflows}, HTTPStatus.OK + with db.session_scope() as session: + workflow = _get_workflow(workflow_id, project_id, session) + service = ParticipantService(session) + participants = service.get_platform_participants_by_project(workflow.project.id) + for participant in participants: + client = RpcClient.from_project_and_participant(workflow.project.name, workflow.project.token, + participant.domain_name) + resp = client.update_workflow(workflow.uuid, workflow.name, config) + if resp.status.code != common_pb2.STATUS_SUCCESS: + raise InternalException(resp.status.msg) + peer_workflows[participant.name] = MessageToDict(resp, + preserving_proto_field_name=True, + including_default_value_fields=True) + return make_flask_response(peer_workflows) + + +class WorkflowInvalidateApi(Resource): + + @credentials_required + @emits_event(resource_type=Event.ResourceType.WORKFLOW, op_type=Event.OperationType.INVALIDATE) + def post(self, project_id: int, workflow_id: int): + """Invalidates the workflow job. + --- + tags: + - workflow + description: Invalidates the workflow job. + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: workflow_id + schema: + type: integer + responses: + 200: + description: Invalidated workflow + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ + with db.session_scope() as session: + workflow = _get_workflow(workflow_id, project_id, session) + invalidate_workflow_job(session, workflow) + session.commit() + return make_flask_response(workflow.to_proto()) + + +class WorkflowStartApi(Resource): + + @credentials_required + @emits_event(resource_type=Event.ResourceType.WORKFLOW, op_type=Event.OperationType.UPDATE) + def post(self, project_id: int, workflow_id: int): + """Starts the workflow job. + --- + tags: + - workflow + description: Starts the workflow job. + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: workflow_id + schema: + type: integer + responses: + 200: + description: Started workflow + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ + start_workflow(workflow_id) + with db.session_scope() as session: + workflow = _get_workflow(workflow_id, project_id, session) + return make_flask_response(workflow.to_proto()) + + +class WorkflowStopApi(Resource): + + @credentials_required + @emits_event(resource_type=Event.ResourceType.WORKFLOW, op_type=Event.OperationType.UPDATE) + def post(self, project_id: int, workflow_id: int): + """Stops the workflow job. + --- + tags: + - workflow + description: Stops the workflow job. + parameters: + - in: path + name: project_id + schema: + type: integer + - in: path + name: workflow_id + schema: + type: integer + responses: + 200: + description: Stopped workflow + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowPb' + """ + stop_workflow(workflow_id) + with db.session_scope() as session: + workflow = _get_workflow(workflow_id, project_id, session) + return make_flask_response(workflow.to_proto()) def initialize_workflow_apis(api): - api.add_resource(WorkflowsApi, '/workflows') - api.add_resource(WorkflowApi, '/workflows/') - api.add_resource(PeerWorkflowsApi, - '/workflows//peer_workflows') + api.add_resource(WorkflowsApi, '/projects//workflows') + api.add_resource(WorkflowApi, '/projects//workflows/') + api.add_resource(PeerWorkflowsApi, '/projects//workflows//peer_workflows') + api.add_resource(WorkflowInvalidateApi, '/projects//workflows/:invalidate') + api.add_resource(WorkflowStartApi, '/projects//workflows/:start') + api.add_resource(WorkflowStopApi, '/projects//workflows/:stop') + + # if a schema is used, one has to append it to schema_manager so Swagger knows there is a schema available + schema_manager.append(PostWorkflowsParameter) + schema_manager.append(PutWorkflowParameter) + schema_manager.append(PatchWorkflowParameter) + schema_manager.append(PatchPeerWorkflowParameter) diff --git a/web_console_v2/api/fedlearner_webconsole/workflow/cronjob.py b/web_console_v2/api/fedlearner_webconsole/workflow/cronjob.py index 184393e32..58df1d82e 100644 --- a/web_console_v2/api/fedlearner_webconsole/workflow/cronjob.py +++ b/web_console_v2/api/fedlearner_webconsole/workflow/cronjob.py @@ -1,94 +1,46 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# # coding: utf-8 - +import logging from typing import Tuple -from time import sleep - -from fedlearner_webconsole.composer.interface import IItem, IRunner, ItemType -from fedlearner_webconsole.composer.models import Context, RunnerStatus -from fedlearner_webconsole.db import get_session -from fedlearner_webconsole.workflow.models import Workflow, WorkflowState - - -class WorkflowCronJobItem(IItem): - def __init__(self, task_id: int): - self.id = task_id - def type(self) -> ItemType: - return ItemType.WORKFLOW_CRON_JOB +from fedlearner_webconsole.composer.context import RunnerContext +from fedlearner_webconsole.composer.interface import IRunnerV2 +from fedlearner_webconsole.composer.models import RunnerStatus +from fedlearner_webconsole.db import db +from fedlearner_webconsole.proto.composer_pb2 import RunnerOutput, WorkflowCronJobOutput +from fedlearner_webconsole.workflow.models import Workflow, WorkflowExternalState +from fedlearner_webconsole.workflow.workflow_job_controller import start_workflow - def get_id(self) -> int: - return self.id - def __eq__(self, obj: IItem): - return self.id == obj.id and self.type() == obj.type() - - -class WorkflowCronJob(IRunner): - """ start workflow every intervals +class WorkflowCronJob(IRunnerV2): + """Starts workflow periodically. """ - def __init__(self, task_id: int): - self._workflow_id = task_id - self._msg = None - - def start(self, context: Context): - with get_session(context.db_engine) as session: - try: - workflow: Workflow = session.query(Workflow).filter_by( - id=self._workflow_id).one() - # TODO: This is a hack!!! Templatelly use this method - # cc @hangweiqiang: Transaction State Refactor - state = workflow.get_state_for_frontend() - if state in ('COMPLETED', 'FAILED', 'READY', 'STOPPED', 'NEW'): - if state in ('COMPLETED', 'FAILED'): - workflow.update_target_state( - target_state=WorkflowState.STOPPED) - session.commit() - # check workflow stopped - # TODO: use composer timeout cc @yurunyu - for _ in range(24): - # use session refresh to get the latest info - # otherwise it'll use the indentity map locally - session.refresh(workflow) - if workflow.state == WorkflowState.STOPPED: - break - sleep(5) - else: - self._msg = f'failed to stop \ - workflow[{self._workflow_id}]' - return - workflow.update_target_state( - target_state=WorkflowState.RUNNING) - session.commit() - self._msg = f'restarted workflow[{self._workflow_id}]' - elif state == 'RUNNING': - self._msg = f'skip restarting workflow[{self._workflow_id}]' - elif state == 'INVALID': - self._msg = f'current workflow[{self._workflow_id}] \ - is invalid' - else: - self._msg = f'workflow[{self._workflow_id}] \ - state is {state}, which is out of expection' - - except Exception as err: # pylint: disable=broad-except - self._msg = f'exception of workflow[{self._workflow_id}], \ - details is {err}' - - def result(self, context: Context) -> Tuple[RunnerStatus, dict]: - del context # unused by result - if self._msg is None: - return RunnerStatus.RUNNING, {} - output = {'msg': self._msg} - return RunnerStatus.DONE, output + def run(self, context: RunnerContext) -> Tuple[RunnerStatus, RunnerOutput]: + output = WorkflowCronJobOutput() + with db.session_scope() as session: + workflow_id = context.input.workflow_cron_job_input.workflow_id + workflow: Workflow = session.query(Workflow).get(workflow_id) + state = workflow.get_state_for_frontend() + logging.info(f'[WorkflowCronJob] Try to start workflow {workflow_id}, state: {state.name}') + if state in (WorkflowExternalState.READY_TO_RUN, WorkflowExternalState.COMPLETED, + WorkflowExternalState.FAILED, WorkflowExternalState.STOPPED): + start_workflow(workflow_id) + output.message = 'Restarted workflow' + else: + output.message = f'Skip starting workflow, state is {state.name}' + return RunnerStatus.DONE, RunnerOutput(workflow_cron_job_output=output) diff --git a/web_console_v2/api/fedlearner_webconsole/workflow/models.py b/web_console_v2/api/fedlearner_webconsole/workflow/models.py index f988f93db..33d226645 100644 --- a/web_console_v2/api/fedlearner_webconsole/workflow/models.py +++ b/web_console_v2/api/fedlearner_webconsole/workflow/models.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,23 +13,24 @@ # limitations under the License. # coding: utf-8 -# pylint: disable=broad-except -import json -import logging +# pylint: disable=use-a-generator import enum -from datetime import datetime +from typing import List, Optional + +from sqlalchemy.orm import deferred from sqlalchemy.sql import func from sqlalchemy import UniqueConstraint -from envs import Features -from fedlearner_webconsole.composer.models import SchedulerItem +from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition +from fedlearner_webconsole.proto.workflow_pb2 import WorkflowRef, WorkflowPb from fedlearner_webconsole.utils.mixins import to_dict_mixin from fedlearner_webconsole.db import db +from fedlearner_webconsole.project.models import Project from fedlearner_webconsole.proto import (common_pb2, workflow_definition_pb2) -from fedlearner_webconsole.job.models import (Job, JobState, JobType, - JobDependency) -from fedlearner_webconsole.rpc.client import RpcClient -from fedlearner_webconsole.mmgr.service import ModelService +from fedlearner_webconsole.job.models import JobState, Job +from fedlearner_webconsole.utils.pp_datetime import to_timestamp +from fedlearner_webconsole.workflow.utils import is_local +from fedlearner_webconsole.workflow_template.models import WorkflowTemplate, WorkflowTemplateRevision class WorkflowState(enum.Enum): @@ -38,20 +39,60 @@ class WorkflowState(enum.Enum): READY = 2 RUNNING = 3 STOPPED = 4 - - -class RecurType(enum.Enum): - NONE = 0 - ON_NEW_DATA = 1 - HOURLY = 2 - DAILY = 3 - WEEKLY = 4 - - -VALID_TRANSITIONS = [(WorkflowState.NEW, WorkflowState.READY), - (WorkflowState.READY, WorkflowState.RUNNING), - (WorkflowState.RUNNING, WorkflowState.STOPPED), - (WorkflowState.STOPPED, WorkflowState.RUNNING)] + COMPLETED = 5 + FAILED = 6 + + +class WorkflowExternalState(enum.Enum): + # state of workflow is unknown + UNKNOWN = 0 + # workflow is completed + COMPLETED = 1 + # workflow is failed + FAILED = 2 + # workflow is stopped + STOPPED = 3 + # workflow is running + RUNNING = 4 + # workflow is prepare to run + PREPARE_RUN = 5 + # workflow is prepare to stop + PREPARE_STOP = 6 + # workflow is warming up under the hood + WARMUP_UNDERHOOD = 7 + # workflow is pending participant accept + PENDING_ACCEPT = 8 + # workflow is ready to run + READY_TO_RUN = 9 + # workflow is waiting for participant configure + PARTICIPANT_CONFIGURING = 10 + # workflow is invalid + INVALID = 11 + + +# yapf: disable +VALID_TRANSITIONS = [ + (WorkflowState.NEW, WorkflowState.READY), + (WorkflowState.READY, WorkflowState.RUNNING), + (WorkflowState.READY, WorkflowState.STOPPED), + + (WorkflowState.RUNNING, WorkflowState.STOPPED), + # Transitions below are not used, because state controller treat COMPLETED and FAILED as STOPPED. + # (WorkflowState.RUNNING, WorkflowState.COMPLETED), + # (WorkflowState.RUNNING, WorkflowState.FAILED), + + + (WorkflowState.STOPPED, WorkflowState.RUNNING), + (WorkflowState.COMPLETED, WorkflowState.RUNNING), + (WorkflowState.FAILED, WorkflowState.RUNNING), + (WorkflowState.RUNNING, WorkflowState.RUNNING), + + # This is hack to make workflow_state_controller's committing stage idempotent. + (WorkflowState.STOPPED, WorkflowState.STOPPED), + (WorkflowState.COMPLETED, WorkflowState.STOPPED), + (WorkflowState.FAILED, WorkflowState.STOPPED) +] +# yapf: enable class TransactionState(enum.Enum): @@ -75,86 +116,69 @@ class TransactionState(enum.Enum): (TransactionState.READY, TransactionState.COORDINATOR_PREPARE), # (TransactionState.COORDINATOR_PREPARE, # TransactionState.COORDINATOR_COMMITTABLE), - (TransactionState.COORDINATOR_COMMITTABLE, - TransactionState.COORDINATOR_COMMITTING), + (TransactionState.COORDINATOR_COMMITTABLE, TransactionState.COORDINATOR_COMMITTING), # (TransactionState.COORDINATOR_PREPARE, # TransactionState.COORDINATOR_ABORTING), - (TransactionState.COORDINATOR_COMMITTABLE, - TransactionState.COORDINATOR_ABORTING), + (TransactionState.COORDINATOR_COMMITTABLE, TransactionState.COORDINATOR_ABORTING), (TransactionState.COORDINATOR_ABORTING, TransactionState.ABORTED), (TransactionState.READY, TransactionState.PARTICIPANT_PREPARE), # (TransactionState.PARTICIPANT_PREPARE, # TransactionState.PARTICIPANT_COMMITTABLE), - (TransactionState.PARTICIPANT_COMMITTABLE, - TransactionState.PARTICIPANT_COMMITTING), + (TransactionState.PARTICIPANT_COMMITTABLE, TransactionState.PARTICIPANT_COMMITTING), # (TransactionState.PARTICIPANT_PREPARE, # TransactionState.PARTICIPANT_ABORTING), - (TransactionState.PARTICIPANT_COMMITTABLE, - TransactionState.PARTICIPANT_ABORTING), + (TransactionState.PARTICIPANT_COMMITTABLE, TransactionState.PARTICIPANT_ABORTING), # (TransactionState.PARTICIPANT_ABORTING, # TransactionState.ABORTED), ] IGNORED_TRANSACTION_TRANSITIONS = [ - (TransactionState.PARTICIPANT_COMMITTABLE, - TransactionState.PARTICIPANT_PREPARE), + (TransactionState.PARTICIPANT_COMMITTABLE, TransactionState.PARTICIPANT_PREPARE), ] -def _merge_variables(base, new, access_mode): - new_dict = {i.name: i.value for i in new} - for var in base: - if var.access_mode in access_mode and var.name in new_dict: - # use json.dumps to escape " in peer's input, a"b ----> "a\"b" - # and use [1:-1] to remove ", "a\"b" ----> a\"b - var.value = json.dumps(new_dict[var.name])[1:-1] - +def compare_yaml_templates_in_wf(wf_a: workflow_definition_pb2.WorkflowDefinition, + wf_b: workflow_definition_pb2.WorkflowDefinition): + """"Compare two WorkflowDefinition's each template, + return True if any job different""" + if len(wf_a.job_definitions) != len(wf_b.job_definitions): + return False + job_defs_a = wf_a.job_definitions + job_defs_b = wf_b.job_definitions + return any([ + job_defs_a[i].yaml_template != job_defs_b[i].yaml_template or job_defs_a[i].name != job_defs_b[i].name + for i in range(len(job_defs_a)) + ]) -def _merge_workflow_config(base, new, access_mode): - _merge_variables(base.variables, new.variables, access_mode) - if not new.job_definitions: - return - assert len(base.job_definitions) == len(new.job_definitions) - for base_job, new_job in \ - zip(base.job_definitions, new.job_definitions): - _merge_variables(base_job.variables, new_job.variables, access_mode) - -@to_dict_mixin(ignores=['fork_proposal_config', 'config'], +@to_dict_mixin(ignores=['fork_proposal_config', 'config', 'editor_info'], extras={ 'job_ids': (lambda wf: wf.get_job_ids()), 'create_job_flags': (lambda wf: wf.get_create_job_flags()), - 'peer_create_job_flags': - (lambda wf: wf.get_peer_create_job_flags()), + 'peer_create_job_flags': (lambda wf: wf.get_peer_create_job_flags()), 'state': (lambda wf: wf.get_state_for_frontend()), - 'transaction_state': - (lambda wf: wf.get_transaction_state_for_frontend()), - 'batch_update_interval': - (lambda wf: wf.get_batch_update_interval()), + 'is_local': (lambda wf: wf.is_local()) }) class Workflow(db.Model): __tablename__ = 'workflow_v2' __table_args__ = (UniqueConstraint('uuid', name='uniq_uuid'), - UniqueConstraint('name', name='uniq_name'), { + UniqueConstraint('project_id', 'name', name='uniq_name_in_project'), { 'comment': 'workflow_v2', 'mysql_engine': 'innodb', 'mysql_charset': 'utf8mb4', }) - id = db.Column(db.Integer, primary_key=True, comment='id') + id = db.Column(db.Integer, primary_key=True, comment='id', autoincrement=True) uuid = db.Column(db.String(64), comment='uuid') name = db.Column(db.String(255), comment='name') project_id = db.Column(db.Integer, comment='project_id') + template_id = db.Column(db.Integer, comment='template_id', nullable=True) + template_revision_id = db.Column(db.Integer, comment='template_revision_id', nullable=True) + editor_info = deferred(db.Column(db.LargeBinary(16777215), comment='editor_info', default=b'', nullable=True)) # max store 16777215 bytes (16 MB) - config = db.Column(db.LargeBinary(16777215), comment='config') - comment = db.Column('cmt', - db.String(255), - key='comment', - comment='comment') - - metric_is_public = db.Column(db.Boolean(), - default=False, - nullable=False, - comment='metric_is_public') + config = deferred(db.Column(db.LargeBinary(16777215), comment='config')) + comment = db.Column('cmt', db.String(255), key='comment', comment='comment') + + metric_is_public = db.Column(db.Boolean(), default=False, nullable=False, comment='metric_is_public') create_job_flags = db.Column(db.TEXT(), comment='create_job_flags') job_ids = db.Column(db.TEXT(), comment='job_ids') @@ -162,31 +186,22 @@ class Workflow(db.Model): forkable = db.Column(db.Boolean, default=False, comment='forkable') forked_from = db.Column(db.Integer, default=None, comment='forked_from') # index in config.job_defs instead of job's id - peer_create_job_flags = db.Column(db.TEXT(), - comment='peer_create_job_flags') + peer_create_job_flags = db.Column(db.TEXT(), comment='peer_create_job_flags') # max store 16777215 bytes (16 MB) - fork_proposal_config = db.Column(db.LargeBinary(16777215), - comment='fork_proposal_config') - - recur_type = db.Column(db.Enum(RecurType, native_enum=False), - default=RecurType.NONE, - comment='recur_type') - recur_at = db.Column(db.Interval, comment='recur_at') + fork_proposal_config = db.Column(db.LargeBinary(16777215), comment='fork_proposal_config') trigger_dataset = db.Column(db.Integer, comment='trigger_dataset') - last_triggered_batch = db.Column(db.Integer, - comment='last_triggered_batch') + last_triggered_batch = db.Column(db.Integer, comment='last_triggered_batch') - state = db.Column(db.Enum(WorkflowState, - native_enum=False, - name='workflow_state'), + state = db.Column(db.Enum(WorkflowState, native_enum=False, create_constraint=False, name='workflow_state'), default=WorkflowState.INVALID, comment='state') target_state = db.Column(db.Enum(WorkflowState, native_enum=False, + create_constraint=False, name='workflow_target_state'), default=WorkflowState.INVALID, comment='target_state') - transaction_state = db.Column(db.Enum(TransactionState, native_enum=False), + transaction_state = db.Column(db.Enum(TransactionState, native_enum=False, create_constraint=False), default=TransactionState.READY, comment='transaction_state') transaction_err = db.Column(db.Text(), comment='transaction_err') @@ -194,56 +209,86 @@ class Workflow(db.Model): start_at = db.Column(db.Integer, comment='start_at') stop_at = db.Column(db.Integer, comment='stop_at') - created_at = db.Column(db.DateTime(timezone=True), - server_default=func.now(), - comment='created_at') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created_at') updated_at = db.Column(db.DateTime(timezone=True), onupdate=func.now(), server_default=func.now(), comment='update_at') - extra = db.Column(db.Text(), comment='extra') # json string + extra = db.Column(db.Text(), comment='json string that will be send to peer') # deprecated + local_extra = db.Column(db.Text(), comment='json string that will only be store locally') # deprecated + cron_config = db.Column('cronjob_config', db.Text(), key='cron_config', comment='cronjob json string') + + creator = db.Column(db.String(255), comment='the username of the creator') + favour = db.Column(db.Boolean, default=False, comment='favour') owned_jobs = db.relationship( - 'Job', primaryjoin='foreign(Job.workflow_id) == Workflow.id') - project = db.relationship( - 'Project', primaryjoin='Project.id == foreign(Workflow.project_id)') + 'Job', + primaryjoin='foreign(Job.workflow_id) == Workflow.id', + # To disable the warning of back_populates + overlaps='workflow') + project = db.relationship(Project.__name__, primaryjoin='Project.id == foreign(Workflow.project_id)') + template = db.relationship(WorkflowTemplate.__name__, + primaryjoin='WorkflowTemplate.id == foreign(Workflow.template_id)') + template_revision = db.relationship( + WorkflowTemplateRevision.__name__, + primaryjoin='WorkflowTemplateRevision.id == foreign(Workflow.template_revision_id)', + # To disable the warning of back_populates + overlaps='workflows') + + def is_finished(self) -> bool: + return all([job.is_disabled or job.state == JobState.COMPLETED for job in self.owned_jobs]) + + def is_failed(self) -> bool: + return any([job.state == JobState.FAILED for job in self.owned_jobs]) + + def get_state_for_frontend(self) -> WorkflowExternalState: + """Get workflow states that frontend need.""" + + # states in workflow creating stage. + if self.state == WorkflowState.NEW \ + and self.target_state == WorkflowState.READY: + if self.transaction_state in [ + TransactionState.PARTICIPANT_COMMITTABLE, TransactionState.PARTICIPANT_COMMITTING, + TransactionState.COORDINATOR_COMMITTING + ]: + return WorkflowExternalState.WARMUP_UNDERHOOD + if self.transaction_state == TransactionState.PARTICIPANT_PREPARE: + return WorkflowExternalState.PENDING_ACCEPT + if self.transaction_state in [ + TransactionState.READY, TransactionState.COORDINATOR_COMMITTABLE, + TransactionState.COORDINATOR_PREPARE + ]: + return WorkflowExternalState.PARTICIPANT_CONFIGURING + + # static state + if self.state == WorkflowState.READY: + return WorkflowExternalState.READY_TO_RUN - def get_state_for_frontend(self): if self.state == WorkflowState.RUNNING: - is_complete = all([job.is_disabled or - job.state == JobState.COMPLETED - for job in self.owned_jobs]) - if is_complete: - return 'COMPLETED' - is_failed = any([job.state == JobState.FAILED - for job in self.owned_jobs]) - if is_failed: - return 'FAILED' - return self.state.name - - def get_transaction_state_for_frontend(self): - # TODO(xiangyuxuan): remove this hack by redesign 2pc - if (self.transaction_state == TransactionState.PARTICIPANT_PREPARE - and self.config is not None): - return 'PARTICIPANT_COMMITTABLE' - return self.transaction_state.name - - def set_config(self, proto): + return WorkflowExternalState.RUNNING + + if self.state == WorkflowState.STOPPED: + return WorkflowExternalState.STOPPED + if self.state == WorkflowState.COMPLETED: + return WorkflowExternalState.COMPLETED + if self.state == WorkflowState.FAILED: + return WorkflowExternalState.FAILED + + if self.state == WorkflowState.INVALID: + return WorkflowExternalState.INVALID + + return WorkflowExternalState.UNKNOWN + + def set_config(self, proto: WorkflowDefinition): if proto is not None: self.config = proto.SerializeToString() - job_defs = {i.name: i for i in proto.job_definitions} - for job in self.owned_jobs: - name = job.get_config().name - assert name in job_defs, \ - f'Invalid workflow template: job {name} is missing' - job.set_config(job_defs[name]) else: self.config = None - def get_config(self): + def get_config(self) -> Optional[WorkflowDefinition]: if self.config is not None: - proto = workflow_definition_pb2.WorkflowDefinition() + proto = WorkflowDefinition() proto.ParseFromString(self.config) return proto return None @@ -269,8 +314,9 @@ def get_job_ids(self): return [] return [int(i) for i in self.job_ids.split(',')] - def get_jobs(self): - return [Job.query.get(i) for i in self.get_job_ids()] + def get_jobs(self, session) -> List[Job]: + job_ids = self.get_job_ids() + return session.query(Job).filter(Job.id.in_(job_ids)).all() def set_create_job_flags(self, create_job_flags): if not create_job_flags: @@ -306,256 +352,76 @@ def get_peer_create_job_flags(self): return None return [int(i) for i in self.peer_create_job_flags.split(',')] - def get_batch_update_interval(self): - item = SchedulerItem.query.filter_by( - name=f'workflow_cron_job_{self.id}').first() - if not item: - return -1 - return int(item.interval_time) / 60 + def to_workflow_ref(self) -> WorkflowRef: + return WorkflowRef(id=self.id, + name=self.name, + uuid=self.uuid, + project_id=self.project_id, + state=self.get_state_for_frontend().name, + created_at=to_timestamp(self.created_at), + forkable=self.forkable, + metric_is_public=self.metric_is_public, + favour=self.favour) + + def to_proto(self) -> WorkflowPb: + return WorkflowPb(id=self.id, + name=self.name, + uuid=self.uuid, + project_id=self.project_id, + state=self.get_state_for_frontend().name, + created_at=to_timestamp(self.created_at), + forkable=self.forkable, + metric_is_public=self.metric_is_public, + favour=self.favour, + template_revision_id=self.template_revision_id, + template_id=self.template_id, + config=self.get_config(), + editor_info=self.get_editor_info(), + comment=self.comment, + job_ids=self.get_job_ids(), + create_job_flags=self.get_create_job_flags(), + is_local=self.is_local(), + forked_from=self.forked_from, + peer_create_job_flags=self.get_peer_create_job_flags(), + start_at=self.start_at, + stop_at=self.stop_at, + updated_at=to_timestamp(self.updated_at), + cron_config=self.cron_config, + creator=self.creator, + template_info=self.get_template_info()) + + def is_local(self): + return is_local(self.get_config(), self.get_create_job_flags()) + + def get_template_info(self) -> WorkflowPb.TemplateInfo: + template_info = WorkflowPb.TemplateInfo(id=self.template_id, is_modified=True) + if self.template is not None: + template_info.name = self.template.name + template_info.is_modified = compare_yaml_templates_in_wf(self.get_config(), self.template.get_config()) + + if self.template_revision is not None: + template_info.is_modified = False + template_info.revision_index = self.template_revision.revision_index + return template_info + + def get_editor_info(self): + proto = workflow_definition_pb2.WorkflowTemplateEditorInfo() + if self.editor_info is not None: + proto.ParseFromString(self.editor_info) + return proto + + def is_invalid(self): + return self.state == WorkflowState.INVALID + + def can_transit_to(self, target_state: WorkflowState): + return (self.state, target_state) in VALID_TRANSITIONS def update_target_state(self, target_state): - if self.target_state != target_state \ - and self.target_state != WorkflowState.INVALID: - raise ValueError(f'Another transaction is in progress [{self.id}]') - if target_state not in [ - WorkflowState.READY, WorkflowState.RUNNING, - WorkflowState.STOPPED - ]: - raise ValueError(f'Invalid target_state {self.target_state}') + if self.target_state not in [target_state, WorkflowState.INVALID]: + raise ValueError(f'Another transaction is in progress ' f'[{self.id}]') + if target_state != WorkflowState.READY: + raise ValueError(f'Invalid target_state ' f'{self.target_state}') if (self.state, target_state) not in VALID_TRANSITIONS: - raise ValueError( - f'Invalid transition from {self.state} to {target_state}') + raise ValueError(f'Invalid transition from ' f'{self.state} to {target_state}') self.target_state = target_state - - def update_state(self, asserted_state, target_state, transaction_state): - assert asserted_state is None or self.state == asserted_state, \ - 'Cannot change current state directly' - - if transaction_state != self.transaction_state: - if (self.transaction_state, transaction_state) in \ - IGNORED_TRANSACTION_TRANSITIONS: - return self.transaction_state - assert (self.transaction_state, transaction_state) in \ - VALID_TRANSACTION_TRANSITIONS, \ - 'Invalid transaction transition from {} to {}'.format( - self.transaction_state, transaction_state) - self.transaction_state = transaction_state - - # coordinator prepare & rollback - if self.transaction_state == TransactionState.COORDINATOR_PREPARE: - self.prepare(target_state) - if self.transaction_state == TransactionState.COORDINATOR_ABORTING: - self.rollback() - - # participant prepare & rollback & commit - if self.transaction_state == TransactionState.PARTICIPANT_PREPARE: - self.prepare(target_state) - if self.transaction_state == TransactionState.PARTICIPANT_ABORTING: - self.rollback() - self.transaction_state = TransactionState.ABORTED - if self.transaction_state == TransactionState.PARTICIPANT_COMMITTING: - self.commit() - - return self.transaction_state - - def prepare(self, target_state): - assert self.transaction_state in [ - TransactionState.COORDINATOR_PREPARE, - TransactionState.PARTICIPANT_PREPARE], \ - 'Workflow not in prepare state' - - # TODO(tjulinfan): remove this - if target_state is None: - # No action - return - - # Validation - try: - self.update_target_state(target_state) - except ValueError as e: - logging.warning('Error during update target state in prepare: %s', - str(e)) - self.transaction_state = TransactionState.ABORTED - return - - success = True - if self.target_state == WorkflowState.READY: - success = self._prepare_for_ready() - - if success: - if self.transaction_state == TransactionState.COORDINATOR_PREPARE: - self.transaction_state = \ - TransactionState.COORDINATOR_COMMITTABLE - else: - self.transaction_state = \ - TransactionState.PARTICIPANT_COMMITTABLE - - def rollback(self): - self.target_state = WorkflowState.INVALID - - def start(self): - self.start_at = int(datetime.now().timestamp()) - for job in self.owned_jobs: - if not job.is_disabled: - job.schedule() - - def stop(self): - self.stop_at = int(datetime.now().timestamp()) - for job in self.owned_jobs: - job.stop() - - # TODO: separate this method to another module - def commit(self): - assert self.transaction_state in [ - TransactionState.COORDINATOR_COMMITTING, - TransactionState.PARTICIPANT_COMMITTING], \ - 'Workflow not in prepare state' - - if self.target_state == WorkflowState.STOPPED: - try: - self.stop() - except RuntimeError as e: - # errors from k8s - logging.error('Stop workflow %d has error msg: %s', - self.id, e.args) - return - elif self.target_state == WorkflowState.READY: - self._setup_jobs() - self.fork_proposal_config = None - elif self.target_state == WorkflowState.RUNNING: - self.start() - - self.state = self.target_state - self.target_state = WorkflowState.INVALID - self.transaction_state = TransactionState.READY - - def invalidate(self): - self.state = WorkflowState.INVALID - self.target_state = WorkflowState.INVALID - self.transaction_state = TransactionState.READY - for job in self.owned_jobs: - try: - job.stop() - except Exception as e: # pylint: disable=broad-except - logging.warning( - 'Error while stopping job %s during invalidation: %s', - job.name, repr(e)) - - def _setup_jobs(self): - if self.forked_from is not None: - trunk = Workflow.query.get(self.forked_from) - assert trunk is not None, \ - 'Source workflow %d not found' % self.forked_from - trunk_job_defs = trunk.get_config().job_definitions - trunk_name2index = { - job.name: i - for i, job in enumerate(trunk_job_defs) - } - - job_defs = self.get_config().job_definitions - flags = self.get_create_job_flags() - assert len(job_defs) == len(flags), \ - 'Number of job defs does not match number of create_job_flags ' \ - '%d vs %d'%(len(job_defs), len(flags)) - jobs = [] - for i, (job_def, flag) in enumerate(zip(job_defs, flags)): - if flag == common_pb2.CreateJobFlag.REUSE: - assert job_def.name in trunk_name2index, \ - f'Job {job_def.name} not found in base workflow' - j = trunk.get_job_ids()[trunk_name2index[job_def.name]] - job = Job.query.get(j) - assert job is not None, \ - 'Job %d not found' % j - # TODO: check forked jobs does not depend on non-forked jobs - else: - job = Job( - name=f'{self.uuid}-{job_def.name}', - job_type=JobType(job_def.job_type), - config=job_def.SerializeToString(), - workflow_id=self.id, - project_id=self.project_id, - state=JobState.NEW, - is_disabled=(flag == common_pb2.CreateJobFlag.DISABLED)) - db.session.add(job) - jobs.append(job) - db.session.flush() - name2index = {job.name: i for i, job in enumerate(job_defs)} - for i, (job, flag) in enumerate(zip(jobs, flags)): - if flag == common_pb2.CreateJobFlag.REUSE: - continue - for j, dep_def in enumerate(job.get_config().dependencies): - dep = JobDependency( - src_job_id=jobs[name2index[dep_def.source]].id, - dst_job_id=job.id, - dep_index=j) - db.session.add(dep) - - self.set_job_ids([job.id for job in jobs]) - if Features.FEATURE_MODEL_WORKFLOW_HOOK: - for job in jobs: - ModelService(db.session).workflow_hook(job) - - - def log_states(self): - logging.debug( - 'workflow %d updated to state=%s, target_state=%s, ' - 'transaction_state=%s', self.id, self.state.name, - self.target_state.name, self.transaction_state.name) - - def _get_peer_workflow(self): - project_config = self.project.get_config() - # TODO: find coordinator for multiparty - client = RpcClient(project_config, project_config.participants[0]) - return client.get_workflow(self.name) - - def _prepare_for_ready(self): - # This is a hack, if config is not set then - # no action needed - if self.transaction_state == TransactionState.COORDINATOR_PREPARE: - # TODO(tjulinfan): validate if the config is legal or not - return bool(self.config) - - if self.forked_from: - peer_workflow = self._get_peer_workflow() - base_workflow = Workflow.query.get(self.forked_from) - if base_workflow is None or not base_workflow.forkable: - return False - self.forked_from = base_workflow.id - self.forkable = base_workflow.forkable - self.set_create_job_flags(peer_workflow.peer_create_job_flags) - self.set_peer_create_job_flags(peer_workflow.create_job_flags) - config = base_workflow.get_config() - _merge_workflow_config(config, peer_workflow.fork_proposal_config, - [common_pb2.Variable.PEER_WRITABLE]) - self.set_config(config) - return True - - return bool(self.config) - - def is_local(self): - # since _setup_jobs has not been called, job_definitions is used - job_defs = self.get_config().job_definitions - flags = self.get_create_job_flags() - for i, (job_def, flag) in enumerate(zip(job_defs, flags)): - if flag != common_pb2.CreateJobFlag.REUSE and job_def.is_federated: - return False - return True - - def update_local_state(self): - if self.target_state == WorkflowState.INVALID: - return - if self.target_state == WorkflowState.READY: - self._setup_jobs() - elif self.target_state == WorkflowState.RUNNING: - self.start() - elif self.target_state == WorkflowState.STOPPED: - try: - self.stop() - except Exception as e: - # errors from k8s - logging.error('Stop workflow %d has error msg: %s', - self.id, e.args) - return - self.state = self.target_state - self.target_state = WorkflowState.INVALID diff --git a/web_console_v2/api/fedlearner_webconsole/workflow_template/apis.py b/web_console_v2/api/fedlearner_webconsole/workflow_template/apis.py index 791f2ba89..36f9cdc6e 100644 --- a/web_console_v2/api/fedlearner_webconsole/workflow_template/apis.py +++ b/web_console_v2/api/fedlearner_webconsole/workflow_template/apis.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,281 +13,504 @@ # limitations under the License. # coding: utf-8 -import io -import json -import re from http import HTTPStatus -import logging -import tarfile -from flask import send_file -from flask_restful import Resource, reqparse, request -from google.protobuf.json_format import ParseDict, ParseError - -from fedlearner_webconsole.utils.decorators import jwt_required -from fedlearner_webconsole.workflow_template.models import WorkflowTemplate, \ +import grpc +from flask_restful import Resource +from sqlalchemy.orm import undefer +from marshmallow import fields, Schema, post_load +from fedlearner_webconsole.audit.decorators import emits_event +from fedlearner_webconsole.participant.models import Participant +from fedlearner_webconsole.proto.workflow_template_pb2 import WorkflowTemplateRevisionJson +from fedlearner_webconsole.rpc.v2.project_service_client import ProjectServiceClient +from fedlearner_webconsole.swagger.models import schema_manager +from fedlearner_webconsole.utils.decorators.pp_flask import input_validator, use_args, use_kwargs +from fedlearner_webconsole.auth.third_party_sso import credentials_required +from fedlearner_webconsole.utils.flask_utils import download_json, make_flask_response, get_current_user, FilterExpField +from fedlearner_webconsole.utils.paginate import paginate +from fedlearner_webconsole.utils.proto import to_dict +from fedlearner_webconsole.workflow_template.models import WorkflowTemplate, WorkflowTemplateRevision, \ WorkflowTemplateKind -from fedlearner_webconsole.proto import workflow_definition_pb2 +from fedlearner_webconsole.workflow_template.service import (WorkflowTemplateService, _format_template_with_yaml_editor, + _check_config_and_editor_info, + WorkflowTemplateRevisionService) from fedlearner_webconsole.db import db -from fedlearner_webconsole.exceptions import (NotFoundException, - InvalidArgumentException, - ResourceConflictException) -from fedlearner_webconsole.workflow_template.slots_formatter import \ - generate_yaml_template -from fedlearner_webconsole.workflow_template.template_validaor\ - import check_workflow_definition - - -def _classify_variable(variable): - if variable.value_type == 'CODE': - try: - json.loads(variable.value) - except json.JSONDecodeError as e: - raise InvalidArgumentException(str(e)) - return variable - - -def dict_to_workflow_definition(config): - try: - template_proto = ParseDict( - config, workflow_definition_pb2.WorkflowDefinition()) - for variable in template_proto.variables: - _classify_variable(variable) - for job in template_proto.job_definitions: - for variable in job.variables: - _classify_variable(variable) - except ParseError as e: - raise InvalidArgumentException(details={'config': str(e)}) - return template_proto - - -def dict_to_editor_info(editor_info): - try: - editor_info_proto = ParseDict( - editor_info, workflow_definition_pb2.WorkflowTemplateEditorInfo()) - except ParseError as e: - raise InvalidArgumentException(details={'editor_info': str(e)}) - return editor_info_proto - - -def _dic_without_key(d, keys): - result = dict(d) - for key in keys: - del result[key] - return result +from fedlearner_webconsole.exceptions import NotFoundException, InvalidArgumentException, ResourceConflictException, \ + NetworkException +from fedlearner_webconsole.proto.workflow_template_pb2 import WorkflowTemplateJson + + +class PostWorkflowTemplatesParams(Schema): + config = fields.Dict(required=True) + editor_info = fields.Dict(required=False, load_default={}) + name = fields.String(required=True) + comment = fields.String(required=False, load_default=None) + kind = fields.Integer(required=False, load_default=0) + + @post_load() + def make(self, data, **kwargs): + data['config'], data['editor_info'] = _check_config_and_editor_info(data['config'], data['editor_info']) + return data + + +class PutWorkflowTemplatesParams(Schema): + config = fields.Dict(required=True) + editor_info = fields.Dict(required=False, load_default={}) + name = fields.String(required=True) + comment = fields.String(required=False, load_default=None) + + @post_load() + def make(self, data, **kwargs): + data['config'], data['editor_info'] = _check_config_and_editor_info(data['config'], data['editor_info']) + return data + + +class GetWorkflowTemplatesParams(Schema): + filter = FilterExpField(required=False, load_default=None) + page = fields.Integer(required=False, load_default=None) + page_size = fields.Integer(required=False, load_default=None) class WorkflowTemplatesApi(Resource): - @jwt_required() - def get(self): - preset_datajoin = request.args.get('from', '') == 'preset_datajoin' - templates = WorkflowTemplate.query - if 'group_alias' in request.args: - templates = templates.filter_by( - group_alias=request.args['group_alias']) - if 'is_left' in request.args: - is_left = request.args.get(key='is_left', type=int) - if is_left is None: - raise InvalidArgumentException('is_left must be 0 or 1') - templates = templates.filter_by(is_left=is_left) - if preset_datajoin: - templates = templates.filter_by( - kind=WorkflowTemplateKind.PRESET_DATAJOIN.value) - # remove config from dicts to reduce the size of the list - return { - 'data': [ - _dic_without_key(t.to_dict(), ['config', 'editor_info']) - for t in templates.all() - ] - }, HTTPStatus.OK - - @jwt_required() - def post(self): - parser = reqparse.RequestParser() - parser.add_argument('name', required=True, help='name is empty') - parser.add_argument('comment') - parser.add_argument('config', - type=dict, - required=True, - help='config is empty') - parser.add_argument('editor_info', type=dict, default={}) - parser.add_argument('kind', type=int, default=0) - data = parser.parse_args() - name = data['name'] - comment = data['comment'] - config = data['config'] - editor_info = data['editor_info'] - kind = data['kind'] - if WorkflowTemplate.query.filter_by(name=name).first() is not None: - raise ResourceConflictException( - 'Workflow template {} already exists'.format(name)) - template_proto, editor_info_proto = _check_config_and_editor_info( - config, editor_info) - template_proto = _format_template_with_yaml_editor( - template_proto, editor_info_proto) - template = WorkflowTemplate(name=name, - comment=comment, - group_alias=template_proto.group_alias, - is_left=template_proto.is_left, - kind=kind) - template.set_config(template_proto) - template.set_editor_info(editor_info_proto) - db.session.add(template) - db.session.commit() - logging.info('Inserted a workflow_template to db') - result = template.to_dict() - return {'data': result}, HTTPStatus.CREATED + + @credentials_required + @use_args(GetWorkflowTemplatesParams(), location='query') + def get(self, params: dict): + """Get templates. + --- + tags: + - workflow_template + description: Get templates list. + parameters: + - in: query + name: filter + schema: + type: string + required: true + - in: query + name: page + schema: + type: integer + - in: query + name: page_size + schema: + type: integer + responses: + 200: + description: list of workflow templates. + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowTemplateRef' + """ + with db.session_scope() as session: + try: + pagination = WorkflowTemplateService(session).list_workflow_templates( + filter_exp=params['filter'], + page=params['page'], + page_size=params['page_size'], + ) + except ValueError as e: + raise InvalidArgumentException(details=f'Invalid filter: {str(e)}') from e + data = [t.to_ref() for t in pagination.get_items()] + return make_flask_response(data=data, page_meta=pagination.get_metadata()) + + @input_validator + @credentials_required + @emits_event(audit_fields=['name']) + @use_args(PostWorkflowTemplatesParams(), location='json') + def post(self, params: dict): + """Create a workflow_template. + --- + tags: + - workflow_template + description: Create a template. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PostWorkflowTemplatesParams' + required: true + responses: + 201: + description: detail of workflow template. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowTemplatePb' + """ + with db.session_scope() as session: + template = WorkflowTemplateService(session).post_workflow_template( + name=params['name'], + comment=params['comment'], + config=params['config'], + editor_info=params['editor_info'], + kind=params['kind'], + creator_username=get_current_user().username) + session.commit() + return make_flask_response(data=template.to_proto(), status=HTTPStatus.CREATED) class WorkflowTemplateApi(Resource): - @jwt_required() - def get(self, template_id): - download = request.args.get('download', 'false') == 'true' - - template = WorkflowTemplate.query.filter_by(id=template_id).first() - if template is None: - raise NotFoundException(f'Failed to find template: {template_id}') - - result = template.to_dict() - if download: - in_memory_file = io.BytesIO() - in_memory_file.write(json.dumps(result).encode('utf-8')) - in_memory_file.seek(0) - return send_file(in_memory_file, - as_attachment=True, - attachment_filename=f'{template.name}.json', - mimetype='application/json; charset=UTF-8', - cache_timeout=0) - return {'data': result}, HTTPStatus.OK - - @jwt_required() + + @credentials_required + @use_args({'download': fields.Bool(required=False, load_default=False)}, location='query') + def get(self, params: dict, template_id: int): + """Get template by id. + --- + tags: + - workflow_template + description: Get a template. + parameters: + - in: path + name: template_id + schema: + type: integer + required: true + - in: query + name: download + schema: + type: boolean + responses: + 200: + description: detail of workflow template. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowTemplatePb' + """ + with db.session_scope() as session: + template = session.query(WorkflowTemplate).filter_by(id=template_id).first() + if template is None: + raise NotFoundException(f'Failed to find template: {template_id}') + template_proto = template.to_proto() + if params['download']: + # Note this is a workaround to removes some fields from the proto. + # WorkflowTemplateJson and WorkflowTemplatePb are compatible. + template_json_pb = WorkflowTemplateJson() + template_json_pb.ParseFromString(template_proto.SerializeToString()) + return download_json(content=to_dict(template_json_pb), filename=template.name) + return make_flask_response(template_proto) + + @credentials_required + @emits_event() def delete(self, template_id): - result = WorkflowTemplate.query.filter_by(id=template_id) - if result.first() is None: - raise NotFoundException(f'Failed to find template: {template_id}') - result.delete() - db.session.commit() - return {'data': {}}, HTTPStatus.OK - - @jwt_required() - def put(self, template_id): - parser = reqparse.RequestParser() - parser.add_argument('name', required=True, help='name is empty') - parser.add_argument('comment') - parser.add_argument('config', - type=dict, - required=True, - help='config is empty') - parser.add_argument('editor_info', type=dict, default={}) - parser.add_argument('kind', type=int, default=0) - data = parser.parse_args() - name = data['name'] - comment = data['comment'] - config = data['config'] - editor_info = data['editor_info'] - kind = data['kind'] - tmp = WorkflowTemplate.query.filter_by(name=name).first() - if tmp is not None and tmp.id != template_id: - raise ResourceConflictException( - 'Workflow template {} already exists'.format(name)) - template = WorkflowTemplate.query.filter_by(id=template_id).first() - if template is None: - raise NotFoundException(f'Failed to find template: {template_id}') - template_proto, editor_info_proto = _check_config_and_editor_info( - config, editor_info) - template_proto = _format_template_with_yaml_editor( - template_proto, editor_info_proto) - template.set_config(template_proto) - template.set_editor_info(editor_info_proto) - template.name = name - template.comment = comment - template.group_alias = template_proto.group_alias - template.is_left = template_proto.is_left - template.kind = kind - db.session.commit() - result = template.to_dict() - return {'data': result}, HTTPStatus.OK - - -def _format_template_with_yaml_editor(template_proto, editor_info_proto): - for job_def in template_proto.job_definitions: - # if job is in editor_info, than use meta_yaml format with - # slots instead of yaml_template - yaml_editor_infos = editor_info_proto.yaml_editor_infos - if not job_def.expert_mode and job_def.name in yaml_editor_infos: - yaml_editor_info = yaml_editor_infos[job_def.name] - job_def.yaml_template = generate_yaml_template( - yaml_editor_info.meta_yaml, - yaml_editor_info.slots) - try: - check_workflow_definition(template_proto) - except ValueError as e: - raise InvalidArgumentException( - details={'config.yaml_template': str(e)}) - return template_proto - - -def _check_config_and_editor_info(config, editor_info): - # TODO: needs tests - if 'group_alias' not in config: - raise InvalidArgumentException( - details={'config.group_alias': 'config.group_alias is required'}) - if 'is_left' not in config: - raise InvalidArgumentException( - details={'config.is_left': 'config.is_left is required'}) - - # form to proto buffer - editor_info_proto = dict_to_editor_info(editor_info) - template_proto = dict_to_workflow_definition(config) - for index, job_def in enumerate(template_proto.job_definitions): - # pod label name must be no more than 63 characters. - # workflow.uuid is 20 characters, pod name suffix such as - # '-follower-master-0' is less than 19 characters, so the - # job name must be no more than 24 - if len(job_def.name) > 24: - raise InvalidArgumentException( - details={ - f'config.job_definitions[{index}].job_name': - 'job_name must be no more than 24 characters' - }) - # limit from k8s - if not re.match('[a-z0-9-]*', job_def.name): - raise InvalidArgumentException( - details={ - f'config.job_definitions[{index}].job_name': - 'Only letters(a-z), numbers(0-9) ' - 'and dashes(-) are supported.' - }) - return template_proto, editor_info_proto - - -class CodeApi(Resource): - @jwt_required() - def get(self): - parser = reqparse.RequestParser() - parser.add_argument('code_path', - type=str, - location='args', - required=True, - help='code_path is required') - data = parser.parse_args() - code_path = data['code_path'] - try: - with tarfile.open(code_path) as tar: - code_dict = {} - for file in tar.getmembers(): - if tar.extractfile(file) is not None: - if '._' not in file.name and file.isfile(): - code_dict[file.name] = str( - tar.extractfile(file).read(), encoding='utf-8') - return {'data': code_dict}, HTTPStatus.OK - except Exception as e: - logging.error(f'Get code, code_path: {code_path}, exception: {e}') - raise InvalidArgumentException(details={'code_path': 'wrong path'}) + """delete template by id. + --- + tags: + - workflow_template + description: Delete a template. + parameters: + - in: path + name: template_id + schema: + type: integer + required: true + responses: + 204: + description: Successfully deleted. + """ + with db.session_scope() as session: + result = session.query(WorkflowTemplate).filter_by(id=template_id) + if result.first() is None: + raise NotFoundException(f'Failed to find template: {template_id}') + result.delete() + session.commit() + return make_flask_response(status=HTTPStatus.NO_CONTENT) + + @input_validator + @credentials_required + @emits_event(audit_fields=['name']) + @use_args(PutWorkflowTemplatesParams(), location='json') + def put(self, params: dict, template_id: int): + """Put a workflow_template. + --- + tags: + - workflow_template + description: edit a template. + parameters: + - in: path + name: template_id + schema: + type: integer + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PutWorkflowParams' + required: true + responses: + 200: + description: detail of workflow template. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowTemplatePb' + """ + with db.session_scope() as session: + tmp = session.query(WorkflowTemplate).filter_by(name=params['name']).first() + if tmp is not None and tmp.id != template_id: + raise ResourceConflictException(f'Workflow template {params["name"]} already exists') + template = session.query(WorkflowTemplate).filter_by(id=template_id).first() + if template is None: + raise NotFoundException(f'Failed to find template: {template_id}') + template_proto = _format_template_with_yaml_editor(params['config'], params['editor_info'], session) + template.set_config(template_proto) + template.set_editor_info(params['editor_info']) + template.name = params['name'] + template.comment = params['comment'] + template.group_alias = template_proto.group_alias + session.commit() + return make_flask_response(template.to_proto()) + + +class WorkflowTemplateRevisionsApi(Resource): + + @credentials_required + @use_args( + { + 'page': fields.Integer(required=False, load_default=None), + 'page_size': fields.Integer(required=False, load_default=None) + }, + location='query') + def get(self, params: dict, template_id: int): + """Get all template revisions for specific template. + --- + tags: + - workflow_template + description: Get all template revisions for specific template. + parameters: + - in: path + name: template_id + required: true + schema: + type: integer + description: The ID of the template + - in: query + name: page + schema: + type: integer + - in: query + name: page_size + schema: + type: integer + responses: + 200: + description: list of workflow template revisions. + content: + application/json: + schema: + type: array + items: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowTemplateRevisionRef' + """ + with db.session_scope() as session: + query = session.query(WorkflowTemplateRevision).filter_by(template_id=template_id) + query = query.order_by(WorkflowTemplateRevision.revision_index.desc()) + pagination = paginate(query, params['page'], params['page_size']) + data = [t.to_ref() for t in pagination.get_items()] + return make_flask_response(data=data, page_meta=pagination.get_metadata()) + + +class WorkflowTemplateRevisionsCreateApi(Resource): + + @credentials_required + def post(self, template_id: int): + """Create a new template revision for specific template if config has been changed. + --- + tags: + - workflow_template + description: Create a new template revision for specific template if config has been changed. + parameters: + - in: path + name: template_id + required: true + schema: + type: integer + description: The ID of the template + responses: + 200: + description: detail of workflow template revision. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowTemplateRevisionPb' + """ + with db.session_scope() as session: + revision = WorkflowTemplateRevisionService(session).create_new_revision_if_template_updated( + template_id=template_id) + session.commit() + return make_flask_response(data=revision.to_proto()) + + +class WorkflowTemplateRevisionApi(Resource): + + @credentials_required + @use_args({'download': fields.Boolean(required=False, load_default=None)}, location='query') + def get(self, params: dict, revision_id: int): + """Get template revision by id. + --- + tags: + - workflow_template + description: Get template revision. + parameters: + - in: path + name: revision_id + required: true + schema: + type: integer + description: The ID of the template revision + - in: query + name: download + schema: + type: boolean + responses: + 200: + description: detail of workflow template revision. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowTemplateRevisionPb' + """ + with db.session_scope() as session: + template_revision = session.query(WorkflowTemplateRevision).options( + undefer(WorkflowTemplateRevision.config), + undefer(WorkflowTemplateRevision.editor_info)).get(revision_id) + if template_revision is None: + raise NotFoundException(f'Cant not find template revision {revision_id}') + if params['download']: + # Note this is a workaround to removes some fields from the proto. + # WorkflowTemplateRevisionJson and WorkflowTemplateRevisionPb are compatible. + revision_proto = template_revision.to_proto() + revision_json_pb = WorkflowTemplateRevisionJson() + revision_json_pb.ParseFromString(revision_proto.SerializeToString()) + return download_json(content=to_dict(revision_json_pb), filename=template_revision.id) + return make_flask_response(data=template_revision.to_proto()) + + @credentials_required + def delete(self, revision_id: int): + """Delete template revision by id. + --- + tags: + - workflow_template + description: Delete template revision. + parameters: + - in: path + name: revision_id + required: true + schema: + type: integer + description: The ID of the template revision + responses: + 204: + description: No content. + """ + with db.session_scope() as session: + WorkflowTemplateRevisionService(session).delete_revision(revision_id=revision_id) + session.commit() + return make_flask_response(status=HTTPStatus.NO_CONTENT) + + @credentials_required + @use_args({'comment': fields.String(required=False, load_default=None)}) + def patch(self, params: dict, revision_id: int): + """Patch template revision by id. + --- + tags: + - workflow_template + description: Patch template revision. + parameters: + - in: path + name: revision_id + required: true + schema: + type: integer + description: The ID of the template revision + - in: body + name: comment + schema: + type: string + required: false + responses: + 200: + description: detail of workflow template revision. + content: + application/json: + schema: + $ref: '#/definitions/fedlearner_webconsole.proto.WorkflowTemplateRevisionPb' + """ + with db.session_scope() as session: + template_revision = session.query(WorkflowTemplateRevision).options( + undefer(WorkflowTemplateRevision.config), + undefer(WorkflowTemplateRevision.editor_info)).get(revision_id) + if template_revision is None: + raise NotFoundException(f'Cant not find template revision {revision_id}') + if params['comment']: + template_revision.comment = params['comment'] + session.commit() + return make_flask_response(data=template_revision.to_proto()) + + +class WorkflowTemplateRevisionSendApi(Resource): + + @use_kwargs({ + 'participant_id': fields.Integer(required=True), + }, location='query') + def post(self, revision_id: int, participant_id: int): + """Send a template revision to participant. + --- + tags: + - workflow_template + description: Send a template revision to participant. + parameters: + - in: path + name: revision_id + required: true + schema: + type: integer + description: The ID of the template revision + - in: query + name: participant_id + required: true + schema: + type: integer + description: The ID of the participant + responses: + 204: + description: No content. + """ + with db.session_scope() as session: + part: Participant = session.query(Participant).get(participant_id) + if part is None: + raise NotFoundException(f'participant {participant_id} is not exist') + revision: WorkflowTemplateRevision = session.query(WorkflowTemplateRevision).get(revision_id) + if revision is None: + raise NotFoundException(f'participant {revision_id} is not exist') + try: + ProjectServiceClient.from_participant(part.domain_name).send_template_revision( + config=revision.get_config(), + name=revision.template.name, + comment=revision.comment, + kind=WorkflowTemplateKind.PEER, + revision_index=revision.revision_index) + except grpc.RpcError as e: + raise NetworkException(str(e)) from e + + return make_flask_response(status=HTTPStatus.NO_CONTENT) def initialize_workflow_template_apis(api): api.add_resource(WorkflowTemplatesApi, '/workflow_templates') - api.add_resource(WorkflowTemplateApi, - '/workflow_templates/') - api.add_resource(CodeApi, '/codes') + api.add_resource(WorkflowTemplateApi, '/workflow_templates/') + api.add_resource(WorkflowTemplateRevisionsApi, '/workflow_templates//workflow_template_revisions') + api.add_resource(WorkflowTemplateRevisionsCreateApi, '/workflow_templates/:create_revision') + api.add_resource(WorkflowTemplateRevisionApi, '/workflow_template_revisions/') + api.add_resource(WorkflowTemplateRevisionSendApi, '/workflow_template_revisions/:send') + + # if a schema is used, one has to append it to schema_manager so Swagger knows there is a schema available + schema_manager.append(PostWorkflowTemplatesParams) + schema_manager.append(PutWorkflowTemplatesParams) diff --git a/web_console_v2/api/fedlearner_webconsole/workflow_template/models.py b/web_console_v2/api/fedlearner_webconsole/workflow_template/models.py index 70f2b211c..44b926466 100644 --- a/web_console_v2/api/fedlearner_webconsole/workflow_template/models.py +++ b/web_console_v2/api/fedlearner_webconsole/workflow_template/models.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,48 +15,55 @@ # coding: utf-8 import enum +from sqlalchemy import func +from sqlalchemy.orm import deferred from sqlalchemy.sql.schema import Index, UniqueConstraint + +from fedlearner_webconsole.proto.workflow_template_pb2 import WorkflowTemplateRevisionRef, \ + WorkflowTemplateRevisionPb, WorkflowTemplateRef, WorkflowTemplatePb +from fedlearner_webconsole.utils.pp_datetime import to_timestamp from fedlearner_webconsole.utils.mixins import to_dict_mixin from fedlearner_webconsole.db import db, default_table_args from fedlearner_webconsole.proto import workflow_definition_pb2 +from fedlearner_webconsole.workflow.utils import is_local class WorkflowTemplateKind(enum.Enum): DEFAULT = 0 - PRESET_DATAJOIN = 1 + PRESET = 1 + PEER = 2 @to_dict_mixin( extras={ + 'is_local': (lambda wt: wt.is_local()), 'config': (lambda wt: wt.get_config()), 'editor_info': (lambda wt: wt.get_editor_info()) }) class WorkflowTemplate(db.Model): __tablename__ = 'template_v2' - __table_args__ = (UniqueConstraint('name', name='uniq_name'), - Index('idx_group_alias', 'group_alias'), - default_table_args('workflow template')) - id = db.Column(db.Integer, primary_key=True, comment='id') - name = db.Column(db.String(255), comment='name') - comment = db.Column('cmt', - db.String(255), - key='comment', - comment='comment') - group_alias = db.Column(db.String(255), - nullable=False, - comment='group_alias') + __table_args__ = (UniqueConstraint('name', + name='uniq_name'), Index('idx_group_alias', + 'group_alias'), default_table_args('workflow template')) + id = db.Column(db.Integer, primary_key=True, comment='id', autoincrement=True) + name = db.Column(db.String(255), comment='name', default='') + comment = db.Column('cmt', db.String(255), key='comment', comment='comment') + group_alias = db.Column(db.String(255), nullable=False, comment='group_alias') # max store 16777215 bytes (16 MB) - config = db.Column(db.LargeBinary(16777215), - nullable=False, - comment='config') - is_left = db.Column(db.Boolean, comment='is_left') - editor_info = db.Column(db.LargeBinary(16777215), - comment='editor_info', - default=b'') - kind = db.Column(db.Integer, - comment='template kind') # WorkflowTemplateKind enum - - def set_config(self, proto): + config = deferred(db.Column(db.LargeBinary(16777215), nullable=False, comment='config')) + editor_info = deferred(db.Column(db.LargeBinary(16777215), comment='editor_info', default=b'')) + kind = db.Column(db.Integer, comment='template kind', default=0) # WorkflowTemplateKind enum + creator_username = db.Column(db.String(255), comment='the username of the creator') + coordinator_pure_domain_name = db.Column(db.String(255), comment='name of the coordinator') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created_at') + updated_at = db.Column(db.DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + comment='update_at') + template_revisions = db.relationship( + 'WorkflowTemplateRevision', primaryjoin='WorkflowTemplate.id == foreign(WorkflowTemplateRevision.template_id)') + + def set_config(self, proto: workflow_definition_pb2.WorkflowDefinition): self.config = proto.SerializeToString() def set_editor_info(self, proto): @@ -72,3 +79,84 @@ def get_editor_info(self): if self.editor_info is not None: proto.ParseFromString(self.editor_info) return proto + + def is_local(self): + job_defs = self.get_config().job_definitions + for job_def in job_defs: + if job_def.is_federated: + return False + return True + + def to_ref(self) -> WorkflowTemplateRef: + return WorkflowTemplateRef(id=self.id, + name=self.name, + comment=self.comment, + group_alias=self.group_alias, + kind=self.kind, + coordinator_pure_domain_name=self.coordinator_pure_domain_name) + + def to_proto(self) -> WorkflowTemplateRevisionPb: + return WorkflowTemplatePb(id=self.id, + comment=self.comment, + created_at=to_timestamp(self.created_at), + config=self.get_config(), + editor_info=self.get_editor_info(), + is_local=is_local(self.get_config()), + name=self.name, + group_alias=self.group_alias, + kind=self.kind, + creator_username=self.creator_username, + updated_at=to_timestamp(self.updated_at), + coordinator_pure_domain_name=self.coordinator_pure_domain_name) + + +class WorkflowTemplateRevision(db.Model): + __tablename__ = 'template_revisions_v2' + __table_args__ = (Index('idx_template_id', 'template_id'), + UniqueConstraint('template_id', 'revision_index', name='uniq_revision_index_in_template'), + default_table_args('workflow template revision')) + id = db.Column(db.Integer, primary_key=True, comment='id', autoincrement=True) + revision_index = db.Column(db.Integer, comment='index for the same template') + comment = db.Column('cmt', db.String(255), key='comment', comment='comment') + # max store 16777215 bytes (16 MB) + config = deferred(db.Column(db.LargeBinary(16777215), nullable=False, comment='config')) + editor_info = deferred(db.Column(db.LargeBinary(16777215), comment='editor_info', default=b'')) + template_id = db.Column(db.Integer, comment='template_id') + created_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), comment='created_at') + template = db.relationship( + 'WorkflowTemplate', + primaryjoin='WorkflowTemplate.id == foreign(WorkflowTemplateRevision.template_id)', + # To disable the warning of back_populates + overlaps='template_revisions') + + def set_config(self, proto: workflow_definition_pb2.WorkflowDefinition): + self.config = proto.SerializeToString() + + def get_config(self) -> workflow_definition_pb2.WorkflowDefinition: + proto = workflow_definition_pb2.WorkflowDefinition() + proto.ParseFromString(self.config) + return proto + + def get_editor_info(self) -> workflow_definition_pb2.WorkflowTemplateEditorInfo: + proto = workflow_definition_pb2.WorkflowTemplateEditorInfo() + if self.editor_info is not None: + proto.ParseFromString(self.editor_info) + return proto + + def to_ref(self) -> WorkflowTemplateRevisionRef: + return WorkflowTemplateRevisionRef(id=self.id, + revision_index=self.revision_index, + comment=self.comment, + template_id=self.template_id, + created_at=to_timestamp(self.created_at)) + + def to_proto(self) -> WorkflowTemplateRevisionPb: + return WorkflowTemplateRevisionPb(id=self.id, + revision_index=self.revision_index, + comment=self.comment, + template_id=self.template_id, + created_at=to_timestamp(self.created_at), + config=self.get_config(), + editor_info=self.get_editor_info(), + is_local=is_local(self.get_config()), + name=self.template and self.template.name) diff --git a/web_console_v2/api/fedlearner_webconsole/workflow_template/slots_formatter.py b/web_console_v2/api/fedlearner_webconsole/workflow_template/slots_formatter.py index 2923c8928..0aa8f5ce0 100644 --- a/web_console_v2/api/fedlearner_webconsole/workflow_template/slots_formatter.py +++ b/web_console_v2/api/fedlearner_webconsole/workflow_template/slots_formatter.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,21 +12,49 @@ # See the License for the specific language governing permissions and # limitations under the License. - # coding: utf-8 -from flatten_dict import flatten +from string import Template +from typing import Dict + +from fedlearner_webconsole.utils.pp_flatten_dict import flatten from fedlearner_webconsole.proto.workflow_definition_pb2 import Slot -from fedlearner_webconsole.workflow_template.template_validaor \ - import YamlTemplate +from fedlearner_webconsole.utils.proto import to_dict + + +class YamlTemplate(Template): + """This formatter is used to format placeholders + only can be observed in workflow_template module. + """ + delimiter = '$' + + # overwrite this func to escape the invalid placeholder such as ${UNKNOWN} + def _substitute(self, mapping, fixed_placeholder=None, ignore_invalid=False): + # Helper function for .sub() + def convert(mo): + # Check the most common path first. + named = mo.group('named') or mo.group('braced') + if named is not None: + if fixed_placeholder is not None: + return fixed_placeholder + return str(mapping[named]) + if mo.group('escaped') is not None: + return self.delimiter + if mo.group('invalid') is not None: + # overwrite to escape invalid placeholder + if ignore_invalid: + return mo.group() + self._invalid(mo) + raise ValueError('Unrecognized named group in pattern', self.pattern) + + return self.pattern.sub(convert, self.template) + class _YamlTemplate(YamlTemplate): # Which placeholders in the template should be interpreted idpattern = r'Slot_[a-z0-9_]*' def substitute(self, mapping): - return super()._substitute(mapping, - fixed_placeholder=None, - ignore_invalid=True) + return super()._substitute(mapping, fixed_placeholder=None, ignore_invalid=True) def format_yaml(yaml, **kwargs): @@ -38,26 +66,63 @@ def format_yaml(yaml, **kwargs): """ template = _YamlTemplate(yaml) try: - return template.substitute(flatten(kwargs or {}, - reducer='dot')) + return template.substitute(flatten(kwargs or {})) except KeyError as e: - raise RuntimeError( - 'Unknown placeholder: {}'.format(e.args[0])) from e + raise RuntimeError(f'Unknown placeholder: {e.args[0]}') from e -def generate_yaml_template(base_yaml, slots_proto): +def generate_yaml_template(base_yaml: str, slots_proto: Dict[str, Slot]): """ Args: base_yaml: A string representation of one type job's base yaml. slots_proto: A proto map object representation of modification - template's operable smallest units. + template's operable smallest units. Key is the slot name, and + the value is Slot proto object. Returns: string: A yaml_template """ + slots = _generate_slots_map(slots_proto) + return format_yaml(base_yaml, **slots) + + +def _generate_slots_map(slots_proto: dict) -> dict: slots = {} for key in slots_proto: - if slots_proto[key].reference_type == Slot.ReferenceType.DEFAULT: - slots[key] = slots_proto[key].default + slot = slots_proto[key] + if slot.reference_type == Slot.DEFAULT: + slots[key] = _generate_slot_default(slot) else: - slots[key] = f'${{{slots_proto[key].reference}}}' - return format_yaml(base_yaml, **slots) + slots[key] = _generate_slot_reference(slot) + return slots + + +def _generate_slot_default(slot: Slot): + default_value = to_dict(slot.default_value) + # add quotation for string value to make it be treated as string not a variable + if slot.value_type == Slot.STRING: + return f'"{default_value}"' + if slot.value_type == Slot.INT: + if default_value is None: + return default_value + try: + return int(default_value) + except Exception as e: + raise ValueError(f'default_value of Slot: {slot.label} must be an int.') from e + return default_value + + +def _generate_slot_reference(slot: Slot) -> str: + if slot.value_type == Slot.INT: + return f'int({slot.reference})' + if slot.value_type == Slot.NUMBER: + return f'float({slot.reference})' + if slot.value_type == Slot.BOOL: + return f'bool({slot.reference})' + if slot.value_type == Slot.OBJECT: + return f'dict({slot.reference})' + if slot.value_type == Slot.LIST: + return f'list({slot.reference})' + # Force transform to string, to void format errors. + if slot.value_type == Slot.STRING: + return f'str({slot.reference})' + return slot.reference diff --git a/web_console_v2/api/fedlearner_webconsole/workflow_template/template_validaor.py b/web_console_v2/api/fedlearner_webconsole/workflow_template/template_validaor.py index 2b6e668c7..1714022b5 100644 --- a/web_console_v2/api/fedlearner_webconsole/workflow_template/template_validaor.py +++ b/web_console_v2/api/fedlearner_webconsole/workflow_template/template_validaor.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,116 +13,44 @@ # limitations under the License. # coding: utf-8 -import json -from string import Template -from flatten_dict import flatten -from fedlearner_webconsole.job.yaml_formatter import make_variables_dict - -class YamlTemplate(Template): - """This formatter is used to format placeholders - only can be observed in workflow_template module. - """ - delimiter = '$' - - # overwrite this func to escape the invalid placeholder such as - def _substitute(self, mapping, fixed_placeholder=None, - ignore_invalid=False): - # Helper function for .sub() - def convert(mo): - # Check the most common path first. - named = mo.group('named') or mo.group('braced') - if named is not None: - if fixed_placeholder is not None: - return fixed_placeholder - return str(mapping[named]) - if mo.group('escaped') is not None: - return self.delimiter - if mo.group('invalid') is not None: - # overwrite to escape invalid placeholder - if ignore_invalid: - return mo.group() - self._invalid(mo) - raise ValueError('Unrecognized named group in pattern', - self.pattern) - - return self.pattern.sub(convert, self.template) - - -class _YamlTemplateOnlyFillWorkflow(YamlTemplate): - """This formatter is used to format placeholders - only can be observed in workflow_template module. - """ - # Which placeholders in the template should be interpreted - idpattern = r'(?:workflow\.jobs\.[a-zA-Z_\-0-9\[\]]+|workflow|self)' \ - r'\.variables\.[a-zA-Z_\-0-9\[\]]+' - - def substitute(self, mapping): - return super()._substitute(mapping, - fixed_placeholder=None, - ignore_invalid=True) - - -class _YamlTemplateFillAll(YamlTemplate): - """ - This formatter is used to format all valid placeholders with {} - """ - # Which placeholders in the template should be interpreted - idpattern = r'[a-zA-Z_\-\[0-9\]]+(\.[a-zA-Z_\-\[0-9\]]+)*' - def substitute(self, mapping): - return super()._substitute(mapping, - fixed_placeholder='{}', - ignore_invalid=False) - - -def format_yaml(yaml, **kwargs): - """Formats a yaml template. - - Example usage: - format_yaml('{"abc": ${x.y}}', x={'y': 123}) - output should be '{"abc": 123}' - """ - template = _YamlTemplateOnlyFillWorkflow(yaml) - try: - # checkout whether variables which can be observed at workflow_template - # module is consistent with placeholders in string - format_workflow = template.substitute(flatten(kwargs or {}, - reducer='dot')) - except KeyError as e: - raise ValueError( - f'Unknown placeholder: {e.args[0]}') from e - template = _YamlTemplateFillAll(format_workflow) - try: - # checkout whether other placeholders are valid and - # format them with {} in order to go ahead to next step, - # json format check - return template.substitute(flatten(kwargs or {}, - reducer='dot')) - except ValueError as e: - raise ValueError(f'Wrong placeholder: {str(e)} . ' - f'Origin yaml: {format_workflow}') - - -def check_workflow_definition(workflow_definition): - workflow = {'variables': make_variables_dict(workflow_definition.variables), - 'jobs': {}} +from fedlearner_webconsole.job.yaml_formatter import\ + make_variables_dict +from fedlearner_webconsole.utils.pp_yaml import compile_yaml_template, GenerateDictService + + +def check_workflow_definition(workflow_definition, session): + workflow = { + 'variables': make_variables_dict(workflow_definition.variables), + 'jobs': {}, + 'uuid': 'test', + 'name': 'test', + 'id': 1, + 'creator': 'test' + } + project_stub = { + 'variables': { + 'storage_root_path': '/data' + }, + 'participants': [{ + 'egress_domain': 'domain_name', + 'egress_host': 'client_auth' + }], + 'id': 1, + 'name': 'test' + } for job_def in workflow_definition.job_definitions: - j_dic = {'variables': make_variables_dict(job_def.variables)} + j_dic = {'variables': make_variables_dict(job_def.variables), 'name': 'other_job_name_stub'} workflow['jobs'][job_def.name] = j_dic - for job_def in workflow_definition.job_definitions: - self_dict = {'variables': make_variables_dict(job_def.variables)} - try: - # check placeholders - yaml = format_yaml(job_def.yaml_template, - workflow=workflow, - self=self_dict) - except ValueError as e: - raise ValueError(f'job_name: {job_def.name} ' - f'Invalid placeholder: {str(e)}') + # fake job name to pass the compiler_yaml_template + self_dict = {'name': ' job_name_stub', 'variables': make_variables_dict(job_def.variables)} try: - # check json format - loaded = json.loads(yaml) + # check the result format + compile_yaml_template(job_def.yaml_template, [], + workflow=workflow, + self=self_dict, + system=GenerateDictService(session).generate_system_dict(), + project=project_stub) except Exception as e: # pylint: disable=broad-except - raise ValueError(f'job_name: {job_def.name} Invalid ' - f'json {repr(e)}: {yaml}') + raise ValueError(f'job_name: {job_def.name} Invalid python {str(e)}') from e diff --git a/web_console_v2/api/gunicorn_config.py b/web_console_v2/api/gunicorn_config.py index 66fd81b48..590550fc1 100644 --- a/web_console_v2/api/gunicorn_config.py +++ b/web_console_v2/api/gunicorn_config.py @@ -1,29 +1,30 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# +""" + This file is used by gunicorn command line at runtime, so it's better away from other dependencies. -# coding: utf-8 -import os -from envs import Envs + Thus, `environ` is used instead of `envs.Envs` +""" +from os import environ -bind = ':1991' +bind = f':{environ.get("RESTFUL_LISTEN_PORT", 1991)}' +# For some hook which installing some dependencies at runtime, worker timeout should be longer. +timeout = 600 workers = 1 threads = 10 worker_class = 'gthread' -secure_scheme_headers = { - 'X-FORWARDED-PROTOCOL': 'https', - 'X-FORWARDED-PROTO': 'https', - 'X-FORWARDED-SSL': 'on' -} +secure_scheme_headers = {'X-FORWARDED-PROTOCOL': 'https', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'} -errorlog = f'{Envs.FEDLEARNER_WEBCONSOLE_LOG_DIR}/error.log' +errorlog = f'{environ.get("FEDLEARNER_WEBCONSOLE_LOG_DIR}", ".")}/error.log' diff --git a/web_console_v2/api/logging_config.py b/web_console_v2/api/logging_config.py index 208487c4a..271491da3 100644 --- a/web_console_v2/api/logging_config.py +++ b/web_console_v2/api/logging_config.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,34 +15,61 @@ # coding: utf-8 import os -import logging + from envs import Envs -LOGGING_CONFIG = { - 'version': 1, - 'disable_existing_loggers': False, - 'root': { - 'handlers': ['console', 'root_file'], - 'level': 'INFO' - }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', +root_file_path = os.path.join(Envs.FEDLEARNER_WEBCONSOLE_LOG_DIR, 'root.log') + +_extra_handlers = {} + + +def set_extra_handlers(handlers: dict): + """Sets extra handlers for logger. + + Incremental configurations are hard, so we keep LOGGING_CONFIG + as the source of truth and inject extra handlers.""" + global _extra_handlers # pylint:disable=global-statement + _extra_handlers = handlers + + +def get_logging_config(): + return { + 'version': + 1, + 'disable_existing_loggers': + False, + 'root': { + 'handlers': ['console', 'root_file'] + list(_extra_handlers.keys()), + 'level': Envs.LOG_LEVEL }, - 'root_file': { - 'class': 'logging.handlers.TimedRotatingFileHandler', - 'formatter': 'generic', - 'filename': os.path.join(Envs.FEDLEARNER_WEBCONSOLE_LOG_DIR, 'root.log'), - 'when': 'D', - 'interval': 1, - 'backupCount': 7 - } - }, - 'formatters': { - 'generic': { - 'format': '%(asctime)s [%(process)d] [%(levelname)s] %(message)s', - 'datefmt': '%Y-%m-%d %H:%M:%S', - 'class': 'logging.Formatter' + 'filters': { + 'requestIdFilter': { + '()': 'fedlearner_webconsole.middleware.log_filter.RequestIdLogFilter' + } + }, + 'handlers': + dict( + { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'generic', + 'filters': ['requestIdFilter'] + }, + 'root_file': { + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'formatter': 'generic', + 'filename': root_file_path, + 'when': 'D', + 'interval': 1, + 'backupCount': 7, + 'filters': ['requestIdFilter'] + } + }, **_extra_handlers), + 'formatters': { + 'generic': { + 'format': '%(asctime)s [%(process)d] [%(request_id)s] [%(levelname)s] %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + 'class': 'logging.Formatter' + } } } -} diff --git a/web_console_v2/api/migrations/README b/web_console_v2/api/migrations/README index 98e4f9c44..2500aa1bc 100644 --- a/web_console_v2/api/migrations/README +++ b/web_console_v2/api/migrations/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/web_console_v2/api/migrations/env.py b/web_console_v2/api/migrations/env.py index 1445b1e85..449d2baf2 100644 --- a/web_console_v2/api/migrations/env.py +++ b/web_console_v2/api/migrations/env.py @@ -1,3 +1,18 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + from __future__ import with_statement import logging @@ -20,9 +35,7 @@ # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -config.set_main_option( - 'sqlalchemy.url', - str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) +config.set_main_option('sqlalchemy.url', str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, @@ -30,7 +43,8 @@ # my_important_option = config.get_main_option("my_important_option") # ... etc. -BLOCK_AUTOGENERATE_LIST = ['models_v2'] +BLOCK_AUTOGENERATE_LIST = [] + def include_object(object, name, type_, reflected, compare_to): if type_ == 'table' and name in BLOCK_AUTOGENERATE_LIST: @@ -52,10 +66,7 @@ def run_migrations_offline(): """ url = config.get_main_option("sqlalchemy.url") - context.configure(url=url, - target_metadata=target_metadata, - literal_binds=True, - include_object=include_object) + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, include_object=include_object) with context.begin_transaction(): context.run_migrations() @@ -82,12 +93,11 @@ def process_revision_directives(context, revision, directives): connectable = current_app.extensions['migrate'].db.engine with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=target_metadata, - include_object=include_object, - process_revision_directives=process_revision_directives, - **current_app.extensions['migrate'].configure_args) + context.configure(connection=connection, + target_metadata=target_metadata, + include_object=include_object, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) with context.begin_transaction(): context.run_migrations() diff --git a/web_console_v2/api/migrations/versions/5a580877595d_user_add_role_and_state.py b/web_console_v2/api/migrations/versions/5a580877595d_user_add_role_and_state.py index 05b674135..22bd0eaae 100644 --- a/web_console_v2/api/migrations/versions/5a580877595d_user_add_role_and_state.py +++ b/web_console_v2/api/migrations/versions/5a580877595d_user_add_role_and_state.py @@ -20,23 +20,15 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('users_v2', sa.Column('email', sa.String(length=255), nullable=True, comment='email of user')) op.add_column('users_v2', sa.Column('name', sa.String(length=255), nullable=True, comment='name of user')) - op.add_column('users_v2', sa.Column('role', sa.Enum('USER', 'ADMIN', name='role', native_enum=False), nullable=True, comment='role of user')) - op.add_column('users_v2', sa.Column('state', sa.Enum('ACTIVE', 'DELETED', name='state', native_enum=False), nullable=True, comment='state of user')) - op.alter_column('users_v2', 'username', - existing_type=mysql.VARCHAR(length=255), - comment='unique name of user', - existing_comment='user name of user', - existing_nullable=True) + op.add_column('users_v2', sa.Column('role', sa.Enum('USER', 'ADMIN', name='role', native_enum=False, create_constraint=True, length=21), nullable=True, comment='role of user')) + op.add_column('users_v2', sa.Column('state', sa.Enum('ACTIVE', 'DELETED', name='state', native_enum=False, create_constraint=True, length=21), nullable=True, comment='state of user')) + op.alter_column('users_v2', 'username', existing_type=mysql.VARCHAR(length=255), comment='unique name of user', existing_comment='user name of user', existing_nullable=True) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('users_v2', 'username', - existing_type=mysql.VARCHAR(length=255), - comment='user name of user', - existing_comment='unique name of user', - existing_nullable=True) + op.alter_column('users_v2', 'username', existing_type=mysql.VARCHAR(length=255), comment='user name of user', existing_comment='unique name of user', existing_nullable=True) op.drop_column('users_v2', 'state') op.drop_column('users_v2', 'role') op.drop_column('users_v2', 'name') diff --git a/web_console_v2/api/migrations/versions/b3290c1bf67a_add_completed_failed_jobstate.py b/web_console_v2/api/migrations/versions/b3290c1bf67a_add_completed_failed_jobstate.py index 90f4614e0..5081e318f 100644 --- a/web_console_v2/api/migrations/versions/b3290c1bf67a_add_completed_failed_jobstate.py +++ b/web_console_v2/api/migrations/versions/b3290c1bf67a_add_completed_failed_jobstate.py @@ -23,16 +23,12 @@ def upgrade(): # 'drop check' is invalid if mysql version is less than 8 if version is not None and version.fetchall()[0][0] > '8.0.0': op.execute('ALTER TABLE job_v2 drop check jobstate') - op.alter_column('job_v2', 'state', nullable=False, comment='state', type_=sa.Enum('INVALID', 'STOPPED', 'WAITING', 'STARTED', 'COMPLETED', 'FAILED', name='jobstate', native_enum=False)) + op.alter_column('job_v2', 'state', nullable=False, comment='state', type_=sa.Enum('INVALID', 'STOPPED', 'WAITING', 'STARTED', 'NEW', 'COMPLETED', 'FAILED', name='jobstate', native_enum=False, create_constraint=False)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### bind = op.get_bind() - version = bind.execute('select version()') - # 'drop check' is invalid if mysql version is less than 8 - if version is not None and version.fetchall()[0][0] > '8.0.0': - op.execute('ALTER TABLE job_v2 drop check jobstate') - op.alter_column('job_v2', 'state', nullable=False, comment='state', type_=sa.Enum('INVALID', 'STOPPED', 'WAITING', 'STARTED', name='jobstate', native_enum=False)) + op.alter_column('job_v2', 'state', nullable=False, comment='state', type_=sa.Enum('INVALID', 'STOPPED', 'WAITING', 'STARTED', name='jobstate', native_enum=False, create_constraint=True)) # ### end Alembic commands ### diff --git a/web_console_v2/api/migrations/versions/b3512a6ce912_initial_comment.py b/web_console_v2/api/migrations/versions/b3512a6ce912_initial_comment.py index 0ed47c8e9..dbdfa0943 100644 --- a/web_console_v2/api/migrations/versions/b3512a6ce912_initial_comment.py +++ b/web_console_v2/api/migrations/versions/b3512a6ce912_initial_comment.py @@ -23,7 +23,7 @@ def upgrade(): sa.Column('event_time', sa.TIMESTAMP(timezone=True), nullable=False, comment='event_time'), sa.Column('dataset_id', sa.Integer(), nullable=False, comment='dataset_id'), sa.Column('path', sa.String(length=512), nullable=True, comment='path'), - sa.Column('state', sa.Enum('NEW', 'SUCCESS', 'FAILED', 'IMPORTING', name='batchstate', native_enum=False), nullable=True, comment='state'), + sa.Column('state', sa.Enum('NEW', 'SUCCESS', 'FAILED', 'IMPORTING', name='batchstate', native_enum=False, create_constraint=True), nullable=True, comment='state'), sa.Column('move', sa.Boolean(), nullable=True, comment='move'), sa.Column('details', sa.LargeBinary(), nullable=True, comment='details'), sa.Column('file_size', sa.Integer(), nullable=True, comment='file_size'), @@ -42,7 +42,7 @@ def upgrade(): op.create_table('datasets_v2', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='id'), sa.Column('name', sa.String(length=255), nullable=False, comment='dataset name'), - sa.Column('dataset_type', sa.Enum('PSI', 'STREAMING', name='datasettype', native_enum=False), nullable=False, comment='data type'), + sa.Column('dataset_type', sa.Enum('PSI', 'STREAMING', name='datasettype', native_enum=False, create_constraint=True), nullable=False, comment='data type'), sa.Column('path', sa.String(length=512), nullable=True, comment='dataset path'), sa.Column('cmt', sa.Text(), nullable=True, comment='comment of dataset'), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True, comment='created time'), @@ -68,8 +68,8 @@ def upgrade(): op.create_table('job_v2', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='id'), sa.Column('name', sa.String(length=255), nullable=True, comment='name'), - sa.Column('job_type', sa.Enum('UNSPECIFIED', 'RAW_DATA', 'DATA_JOIN', 'PSI_DATA_JOIN', 'NN_MODEL_TRANINING', 'TREE_MODEL_TRAINING', 'NN_MODEL_EVALUATION', 'TREE_MODEL_EVALUATION', name='jobtype', native_enum=False), nullable=False, comment='job type'), - sa.Column('state', sa.Enum('INVALID', 'STOPPED', 'WAITING', 'STARTED', name='jobstate', native_enum=False), nullable=False, comment='state'), + sa.Column('job_type', sa.Enum('UNSPECIFIED', 'RAW_DATA', 'DATA_JOIN', 'PSI_DATA_JOIN', 'NN_MODEL_TRANINING', 'TREE_MODEL_TRAINING', 'NN_MODEL_EVALUATION', 'TREE_MODEL_EVALUATION', name='jobtype', native_enum=False, create_constraint=True), nullable=False, comment='job type'), + sa.Column('state', sa.Enum('INVALID', 'STOPPED', 'WAITING', 'STARTED', name='jobstate', native_enum=False, create_constraint=True), nullable=False, comment='state'), sa.Column('yaml_template', sa.Text(), nullable=True, comment='yaml_template'), sa.Column('config', sa.LargeBinary(), nullable=True, comment='config'), sa.Column('is_disabled', sa.Boolean(), nullable=True, comment='is_disabled'), @@ -142,13 +142,13 @@ def upgrade(): sa.Column('forked_from', sa.Integer(), nullable=True, comment='forked_from'), sa.Column('peer_create_job_flags', sa.TEXT(), nullable=True, comment='peer_create_job_flags'), sa.Column('fork_proposal_config', sa.LargeBinary(), nullable=True, comment='fork_proposal_config'), - sa.Column('recur_type', sa.Enum('NONE', 'ON_NEW_DATA', 'HOURLY', 'DAILY', 'WEEKLY', name='recurtype', native_enum=False), nullable=True, comment='recur_type'), + sa.Column('recur_type', sa.Enum('NONE', 'ON_NEW_DATA', 'HOURLY', 'DAILY', 'WEEKLY', name='recurtype', native_enum=False, create_constraint=True), nullable=True, comment='recur_type'), sa.Column('recur_at', sa.Interval(), nullable=True, comment='recur_at'), sa.Column('trigger_dataset', sa.Integer(), nullable=True, comment='trigger_dataset'), sa.Column('last_triggered_batch', sa.Integer(), nullable=True, comment='last_triggered_batch'), - sa.Column('state', sa.Enum('INVALID', 'NEW', 'READY', 'RUNNING', 'STOPPED', name='workflow_state', native_enum=False), nullable=True, comment='state'), - sa.Column('target_state', sa.Enum('INVALID', 'NEW', 'READY', 'RUNNING', 'STOPPED', name='workflow_target_state', native_enum=False), nullable=True, comment='target_state'), - sa.Column('transaction_state', sa.Enum('READY', 'ABORTED', 'COORDINATOR_PREPARE', 'COORDINATOR_COMMITTABLE', 'COORDINATOR_COMMITTING', 'COORDINATOR_ABORTING', 'PARTICIPANT_PREPARE', 'PARTICIPANT_COMMITTABLE', 'PARTICIPANT_COMMITTING', 'PARTICIPANT_ABORTING', name='transactionstate', native_enum=False), nullable=True, comment='transaction_state'), + sa.Column('state', sa.Enum('INVALID', 'NEW', 'READY', 'RUNNING', 'STOPPED', name='workflow_state', native_enum=False, create_constraint=True), nullable=True, comment='state'), + sa.Column('target_state', sa.Enum('INVALID', 'NEW', 'READY', 'RUNNING', 'STOPPED', name='workflow_target_state', native_enum=False, create_constraint=True), nullable=True, comment='target_state'), + sa.Column('transaction_state', sa.Enum('READY', 'ABORTED', 'COORDINATOR_PREPARE', 'COORDINATOR_COMMITTABLE', 'COORDINATOR_COMMITTING', 'COORDINATOR_ABORTING', 'PARTICIPANT_PREPARE', 'PARTICIPANT_COMMITTABLE', 'PARTICIPANT_COMMITTING', 'PARTICIPANT_ABORTING', name='transactionstate', native_enum=False, create_constraint=True), nullable=True, comment='transaction_state'), sa.Column('transaction_err', sa.Text(), nullable=True, comment='transaction_err'), sa.Column('start_at', sa.Integer(), nullable=True, comment='start_at'), sa.Column('stop_at', sa.Integer(), nullable=True, comment='stop_at'), diff --git a/web_console_v2/api/protocols/fedlearner_webconsole/proto/common.proto b/web_console_v2/api/protocols/fedlearner_webconsole/proto/common.proto index 3182d796e..4527a4463 100644 --- a/web_console_v2/api/protocols/fedlearner_webconsole/proto/common.proto +++ b/web_console_v2/api/protocols/fedlearner_webconsole/proto/common.proto @@ -1,4 +1,4 @@ -/* Copyright 2021 The FedLearner Authors. All Rights Reserved. +/* Copyright 2023 The FedLearner Authors. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,8 @@ */ syntax = "proto3"; - +import "google/protobuf/struct.proto"; +import "fedlearner_webconsole/proto/algorithm.proto"; package fedlearner_webconsole.proto; enum StatusCode { @@ -40,18 +41,28 @@ message Variable { enum ValueType { STRING = 0; CODE = 1; + NUMBER = 2; + LIST = 3; + OBJECT = 4; + BOOL = 5; } string name = 1; - string value = 2; + string value = 2 [deprecated = true]; AccessMode access_mode = 3; string widget_schema = 4; ValueType value_type = 5; + + google.protobuf.Value typed_value = 6; + + // used for frontend to group variables + string tag = 7; + } message GrpcSpec { + reserved 2; string authority = 1; - map extra_headers = 2; } enum MethodType{ @@ -76,3 +87,48 @@ enum CreateJobFlag { REUSE = 2; DISABLED = 3; } + +// Message representing the set of files uploaded to fedlearner. +message UploadedFiles { + repeated UploadedFile uploaded_files = 1; +} + +// Message representing the file uploaded to fedlearner. +message UploadedFile { + // File display name with folder displayed in code editor. folders are + // included as part of file name. Examples: + // "test/test.py". + // "syslib.bin" + string display_file_name = 1; + + // Internal store location for uploaded file. + string internal_path = 2; + + // File content that will be visible and editable for users. + // Applicable only to human-readable text files. + string content = 3; + + // Internal store parent directory for upload file. + string internal_directory = 4; +} + +message ApplicationVersion { + // Release time, e.g. Fri Jul 16 12:23:19 CST 2021 + string pub_date = 1; + // Hash of the image, e.g. f09d681b4eda01f053cc1a645fa6fc0775852a48 + string revision = 2; + // Corresponding branch name on gitlab, e.g. release-2.0.1 + string branch_name = 3; + // Version number, e.g. 2.0.1.5 + string version = 4; +} + +enum PayloadType { + ALGORITHM = 0; +} + +message Payload { + oneof data { + AlgorithmData algorithm_data = 1; + } +} diff --git a/web_console_v2/api/protocols/fedlearner_webconsole/proto/dataset.proto b/web_console_v2/api/protocols/fedlearner_webconsole/proto/dataset.proto index 9ce8ec02f..a6c852d47 100644 --- a/web_console_v2/api/protocols/fedlearner_webconsole/proto/dataset.proto +++ b/web_console_v2/api/protocols/fedlearner_webconsole/proto/dataset.proto @@ -1,4 +1,4 @@ -/* Copyright 2021 The FedLearner Authors. All Rights Reserved. +/* Copyright 2023 The FedLearner Authors. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,25 +15,359 @@ syntax = "proto3"; +import "fedlearner_webconsole/proto/workflow_definition.proto"; +import "fedlearner_webconsole/proto/common.proto"; +import "fedlearner_webconsole/proto/bcs_transaction.proto"; +import "fedlearner_webconsole/proto/project.proto"; + package fedlearner_webconsole.proto; -message File { - enum State { - UNSPECIFIED = 0; - COMPLETED = 1; - FAILED = 2; - } - // Absolute path - string source_path = 1; - string destination_path = 2; - // If the file is moved/copied - State state = 3; - // File size in byte - int64 size = 4; - // This will be populated if the file is failed to move/copy - string error_message = 5; +enum CronType { + DAILY = 0; + HOURLY = 1; +} + +message DataSource { + string name = 1; + // ref: DataSourceType + string type = 2; + // url like: hdfs:///home/ + string url = 3; + int64 created_at = 4; + int64 project_id = 5; + int64 id = 6; + bool is_user_upload = 7; + bool is_user_export = 8; + string creator_username = 9; + // ref: DatasetFormat + string dataset_format = 10; + // ref: StoreFormat + string store_format = 11; + // ref: DatasetType + string dataset_type = 12; + string uuid = 13; + string comment = 14; +} + +message DatasetParameter { + string name = 1; + string type = 2; + string comment = 3; + int64 project_id = 4; + string path = 5; + string kind = 6; + string format = 7; + string uuid = 8; + bool is_published = 9; + // need publish after create raw_dataset + bool need_publish = 10; + // dataset value per use, unit point + int64 value = 11; + // ref: DatasetSchemaChecker + repeated string schema_checkers = 12; + // ref: StoreFormat + string store_format = 13; + // ref: ImportType + string import_type = 14; + // ref: AuthStatus + string auth_status = 15; + string creator_username = 16; +} + +message BatchParameter { + reserved 4, 5, 6; + int64 dataset_id = 1; + string comment = 2; + string path = 3; + string file_format = 7; + int64 data_source_id = 8; + int64 event_time = 9; + // used to decide batch folder name type is YYYYMMDD or YYYYMMDD-HH + CronType cron_type = 10; } message DataBatch { - repeated File files = 1; + reserved 13; + int64 id = 1; + int64 dataset_id = 2; + string path = 3; + int64 file_size = 4; + int64 num_example = 5; + int64 num_feature = 6; + string comment = 7; + // Timestamp in seconds + int64 created_at = 8; + // Timestamp in seconds + int64 updated_at = 9; + string name = 10; + // ref: ResourceState + string state = 11; + int64 event_time = 12; + int64 latest_parent_dataset_job_stage_id = 14; + int64 latest_analyzer_dataset_job_stage_id = 15; +} + +message DatasetRef { + reserved 5, 14, 17; + int64 id = 1; + int64 project_id = 2; + string name = 3; + // Timestamp in seconds + int64 created_at = 4; + int64 file_size = 6; + string path = 7; + string dataset_format = 8; + string comment = 9; + bool is_published = 10; + string state_frontend = 11; + int64 num_example = 12; + string uuid = 13; + string dataset_kind = 15; + string data_source = 16 [deprecated=true]; + // dataset total value, unit point + int64 total_value = 18; + string creator_username = 19; + // ref: StoreFormat + string store_format = 20; + // ref: DatasetType + string dataset_type = 21; + // ref: ImportType + string import_type = 22; + // ref: PublishFrontendState + string publish_frontend_state = 23; + // frontend auth status for all participants + // ref: AuthFrontendState + string auth_frontend_state = 24; + // auth status for local dataset + // ref: AuthStatus + string local_auth_status = 25; + // frontend auth status details + ParticipantsInfo participants_info = 26; +} + +// this is for comptiable reason +// TODO(wangsen.0914): refactor in the near future +message Dataset { + reserved 5, 7, 9, 10, 11, 19, 20; + int64 id = 1; + int64 project_id = 2; + string name = 3; + int64 workflow_id = 4; + string path = 6; + // Timestamp in seconds + int64 created_at = 8; + // data_source is userd for adapting fedlearner dataset_path + string data_source = 12 [deprecated=true]; + int64 file_size = 13; + int64 num_example = 14; + string comment = 15; + int64 num_feature = 16; + // Timestamp in seconds + int64 updated_at = 17; + // Timestamp in seconds + int64 deleted_at = 18; + // the dataset job that produced this dataset + int64 parent_dataset_job_id = 21; + string dataset_format = 22; + // ref: ResourceState + string state_frontend= 23; + string uuid = 24; + bool is_published = 25; + string dataset_kind = 26; + // dataset value per use, unit point + int64 value = 27; + // ref: DatasetSchemaChecker + repeated string schema_checkers = 28; + string creator_username = 29; + // ref: ImportType + string import_type = 30; + // ref: DatasetType + string dataset_type = 31; + // ref: StoreFormat + string store_format = 32; + int64 analyzer_dataset_job_id = 33; + // ref: PublishFrontendState + string publish_frontend_state = 34; + // frontend auth status for all participants + // ref: AuthFrontendState + string auth_frontend_state = 35; + // auth status for local dataset + // ref: AuthStatus + string local_auth_status = 36; + // frontend auth status details + ParticipantsInfo participants_info = 37; +} + +message DatasetLedger { + // dataset total value, unit point + int64 total_value = 1; + repeated Transaction transactions = 2; +} + +message DatasetMetaInfo { + reserved 1; + // for datasource + string datasource_type = 2; + // is_user_upload: True: datasource is created by system when user local upload + // False: not user local upload datasource + bool is_user_upload = 3; + // is_user_export: True: datasource is created by system when user export dataset + // False: not user export datasource + bool is_user_export = 4; + // dataset value per use, unit point + int64 value = 5; + // need publish after create raw_dataset + bool need_publish = 6; + // ref: DatasetSchemaChecker + repeated string schema_checkers = 7; +} + +message ParticipantDatasetRef { + // same between participants + string uuid = 1; + int64 project_id = 2; + string name = 3; + int64 participant_id = 4; + // choices: tabuler and image + string format = 5; + int64 file_size = 6; + // Timestamp in seconds + int64 updated_at = 7; + // dataset value per use, unit point + int64 value = 8; + // ref: DatasetKindV2 + string dataset_kind = 9; + // ref: DatasetType + string dataset_type = 10; + // ref: AuthStatus + string auth_status = 11; +} + +message DatasetJobConfig { + // Every dataset has uuid, but is_published is decided by other info. + string dataset_uuid = 1; + repeated Variable variables = 2; +} + +message DatasetJobGlobalConfigs { + // key: domain_name value: DatasetJobConfig + // If this job runs locally, there's only one pair inside the map + map global_configs = 1; +} + +// for datasetjob rpc response and datasetjob api response +message DatasetJob { + string uuid = 1; + int64 project_id = 2; + // ref: DatasetJobKind + string kind = 3; + DatasetJobGlobalConfigs global_configs = 4; + WorkflowDefinition workflow_definition = 5; + string result_dataset_uuid = 6; + string result_dataset_name = 7; + // whether participant dataset_job is ready to start + bool is_ready = 8; + int64 input_data_batch_num_example = 9; + int64 output_data_batch_num_example = 10; + int64 id = 11; + // ref: DatasetJobState + string state = 12; + int64 coordinator_id = 13; + int64 workflow_id = 14; + int64 created_at = 15; + int64 finished_at = 16; + int64 updated_at = 17; + int64 started_at = 18; + string name = 19; + // if a dataset_job support dataset_job_stage, has_stages = True + bool has_stages = 20; + string creator_username = 21; + // ref: DatasetJobSchedulerState + string scheduler_state = 22; + TimeRange time_range = 23; + string scheduler_message = 24; +} + +// for datasetjobs api response +message DatasetJobRef { + string uuid = 1; + int64 project_id = 2; + // ref: DatasetJobKind + string kind = 3; + int64 result_dataset_id = 4; + // ref: DatasetJobState + string state = 5; + int64 created_at = 6; + string result_dataset_name = 7; + int64 id = 8; + int64 coordinator_id = 9; + string name = 10; + // if a dataset_job support dataset_job_stage, has_stages = True + bool has_stages = 11; + string creator_username = 12; +} + +message DatasetJobContext { + // Name for batch stats scheduler item. + string batch_stats_item_name = 1 [deprecated=true]; + int64 input_data_batch_num_example = 2 [deprecated=true]; + int64 output_data_batch_num_example = 3 [deprecated=true]; + // if a dataset_job support dataset_job_stage, we set has_stages = True + bool has_stages = 4; + bool need_create_stage = 5; + string scheduler_message = 6; +} + +message DatasetJobStageContext { + // Name for batch stats scheduler item. + string batch_stats_item_name = 1; + int64 input_data_batch_num_example = 2; + int64 output_data_batch_num_example = 3; + string scheduler_message = 4; +} + +// for datasetjobstage rpc response and datasetjobstage api response +message DatasetJobStage { + int64 id = 1; + string name = 2; + string uuid = 3; + string dataset_job_uuid = 4; + int64 dataset_job_id = 5; + int64 output_data_batch_id = 6; + int64 workflow_id = 7; + int64 project_id = 8; + // ref: DatasetJobState + string state = 9; + int64 event_time = 10; + DatasetJobGlobalConfigs global_configs = 11; + int64 created_at = 12; + int64 updated_at = 13; + int64 started_at = 14; + int64 finished_at = 15; + bool is_ready = 16; + WorkflowDefinition workflow_definition = 17; + // ref: DatasetJobKind + string kind = 18; + int64 input_data_batch_num_example = 19; + int64 output_data_batch_num_example = 20; + string scheduler_message = 21; +} + +// for datasetjobstages api response +message DatasetJobStageRef { + int64 id = 1; + string name = 2; + int64 dataset_job_id = 3; + int64 output_data_batch_id = 4; + int64 project_id = 5; + // ref: DatasetJobState + string state = 6; + int64 created_at = 7; + // ref: DatasetJobKind + string kind = 8; +} + +message TimeRange { + int32 days = 1; + int32 hours = 2; } diff --git a/web_console_v2/api/protocols/fedlearner_webconsole/proto/project.proto b/web_console_v2/api/protocols/fedlearner_webconsole/proto/project.proto index 62574bf0f..3bf881027 100644 --- a/web_console_v2/api/protocols/fedlearner_webconsole/proto/project.proto +++ b/web_console_v2/api/protocols/fedlearner_webconsole/proto/project.proto @@ -1,4 +1,4 @@ -/* Copyright 2021 The FedLearner Authors. All Rights Reserved. +/* Copyright 2023 The FedLearner Authors. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,31 +16,105 @@ syntax = "proto3"; import "fedlearner_webconsole/proto/common.proto"; +import "fedlearner_webconsole/proto/participant.proto"; package fedlearner_webconsole.proto; -message CertificateStorage { - message Certificate { - // key is file name, e.g. client/client.pem - // value is the content of certificate - map certs = 1; - } - map domain_name_to_cert = 1; +message ProjectRef { + int64 id = 1; + string name = 2; + string participant_type = 3; + // Username of creator + string creator = 4; + // Timestamp in seconds + int64 created_at = 5; + // Related workflow count in the project + int64 num_workflow = 6; + // NOTE: this kind of proto should not be added into Ref proto, + // this is not a good practice, but we need this for compatible reason. + repeated Participant participants = 7; + ParticipantsInfo participants_info = 8; + // ref: ProjectRole + string role = 9; } -message Participant { - string name = 1; - string domain_name = 2; - // participant's address - // e.g. 127.0.0.1:32443, localhost:32443 - string url = 3; +message Project { + int64 id = 1; + string name = 2; + string token = 3; + string comment = 4; + string participant_type = 5; + // Username of creator + string creator = 6; + // Timestamp in seconds + int64 created_at = 7; + // Timestamp in seconds + int64 updated_at = 8; + repeated Variable variables = 9; + repeated Participant participants = 10; + ParticipantsInfo participants_info = 11; + ProjectConfig config = 12; + // ref: ProjectRole + string role = 13; +} + +message ProjectConfig { + enum ProjectAbilityType { + ID_ALIGNMENT = 0; + HORIZONTAL_FL = 1; + VERTICAL_FL = 3; + TEE = 4; + } + enum AuthorizationRule { + ALWAYS_ALLOW = 0; + ONCE = 1; + MANUAL = 2; + ALWAYS_REFUSE = 3; + } + reserved 1, 2, 3; repeated Variable variables = 4; - GrpcSpec grpc_spec = 5; + repeated ProjectAbilityType abilities = 5; + // key ref: Action + map action_rules = 6; + bool support_blockchain = 7; } -message Project { +message ParticipantsInfo { + // key is the pure domain name of participant + map participants_map = 1; +} + +message ParticipantInfo { string name = 1; - string token = 2; - repeated Participant participants = 3; - repeated Variable variables = 4; + // ref: PendingProjectState + string state = 2; + // ref: ProjectRole + string role = 3; + // Ref to enum ParticipantType + string type = 4; + // ref: AuthStatus + string auth_status = 5; +} + +message PendingProjectPb { + int64 id = 1; + string name = 2; + string uuid = 3; + ProjectConfig config = 4; + // ref: PendingProjectState + string state = 5; + ParticipantsInfo participants_info = 6; + // ref: ProjectRole + string role = 7; + string comment = 8; + // Username of creator + string creator_username = 9; + // Timestamp in seconds + int64 created_at = 10; + // Timestamp in seconds + int64 updated_at = 11; + // ref: TicketStatus + string ticket_status = 12; + string ticket_uuid = 13; + string participant_type = 14; } diff --git a/web_console_v2/api/protocols/fedlearner_webconsole/proto/service.proto b/web_console_v2/api/protocols/fedlearner_webconsole/proto/service.proto index 0a3f89350..ebb9251ff 100644 --- a/web_console_v2/api/protocols/fedlearner_webconsole/proto/service.proto +++ b/web_console_v2/api/protocols/fedlearner_webconsole/proto/service.proto @@ -1,4 +1,4 @@ -/* Copyright 2021 The FedLearner Authors. All Rights Reserved. +/* Copyright 2023 The FedLearner Authors. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,8 +15,14 @@ syntax = "proto3"; +import "google/protobuf/struct.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/wrappers.proto"; import "fedlearner_webconsole/proto/common.proto"; +import "fedlearner_webconsole/proto/two_pc.proto"; import "fedlearner_webconsole/proto/workflow_definition.proto"; +import "fedlearner_webconsole/proto/serving.proto"; +import "fedlearner_webconsole/proto/dataset.proto"; package fedlearner_webconsole.proto; @@ -25,10 +31,13 @@ message ProjAuthInfo { string target_domain = 2; string auth_token = 3; } + message JobDetail{ string name = 1; string state = 2; string pods = 3; + // Timestamp in second + int64 created_at = 4; } message CheckConnectionRequest { @@ -39,11 +48,11 @@ message CheckConnectionResponse { Status status = 1; } -message PingRequest {} +message CheckPeerConnectionRequest {} -message PingResponse { +message CheckPeerConnectionResponse { Status status = 1; - string msg = 2; + ApplicationVersion application_version = 2; } message UpdateWorkflowStateRequest { @@ -65,9 +74,20 @@ message UpdateWorkflowStateResponse { int64 transaction_state = 4; } +message InvalidateWorkflowRequest { + ProjAuthInfo auth_info = 1; + string workflow_uuid = 2; +} + +message InvalidateWorkflowResponse { + Status status = 1; + bool succeeded = 2; +} + message GetWorkflowRequest { ProjAuthInfo auth_info = 1; - string workflow_name = 2; + string workflow_name = 2 [deprecated=true]; + string workflow_uuid = 3; } message GetWorkflowResponse{ @@ -87,18 +107,22 @@ message GetWorkflowResponse{ WorkflowDefinition fork_proposal_config = 12; string uuid = 13; bool metric_is_public = 14; + // True, when all jobs of workflow are completed. + bool is_finished = 15; } message UpdateWorkflowRequest { ProjAuthInfo auth_info = 1; - string workflow_name = 2; + string workflow_name = 2 [deprecated=true]; WorkflowDefinition config = 3; + string workflow_uuid = 4; } message UpdateWorkflowResponse { Status status = 1; - string workflow_name = 2; + string workflow_name = 2 [deprecated=true]; WorkflowDefinition config = 3; + string workflow_uuid =4; } message GetJobMetricsRequest { @@ -147,14 +171,164 @@ message GetJobKibanaResponse { string metrics = 2; } +message TwoPcRequest { + ProjAuthInfo auth_info = 1; + string transaction_uuid = 2; + TwoPcType type = 3; + TwoPcAction action = 4; + TransactionData data = 5; +} + +message TwoPcResponse { + Status status = 1; + string transaction_uuid = 2; + TwoPcType type = 3; + TwoPcAction action = 4; + bool succeeded = 5; + string message = 6; +} + +message ServingServiceRequest { + ProjAuthInfo auth_info = 1; + ServingServiceType operation_type = 2; + string serving_model_uuid = 3; + // same uuid among participants + string model_uuid = 4; + string serving_model_name = 5; + bool is_auto_update = 6; + bool is_manual_triggered = 7; +} + +message ServingServiceResponse { + Status status = 1; + ServingServiceResultCode code = 2; + string msg = 3; +} + +message ServingServiceInferenceRequest { + ProjAuthInfo auth_info = 1; + string serving_model_uuid = 2; + string example_id = 3; + // e.g. "act1_f" + repeated string expected_output = 4; +} + +message ServingServiceInferenceResponse { + Status status = 1; + ServingServiceResultCode code = 2; + string msg = 3; + // data["act1_f"]: tensorflow_serving.apis.predict_pb2.PredictResponse + google.protobuf.Struct data = 4; +} + +message SendDataRequest { + ProjAuthInfo auth_info = 1; + PayloadType type = 2; + Payload data = 3; +} + +message SendDataResponse { + bool succeeded = 1; + string message = 2; +} + +message ClientHeartBeatRequest { + string domain_name = 1; + string message = 2; +} + +message ClientHeartBeatResponse { + bool succeeded = 1; +} + +message ListParticipantDatasetsRequest { + ProjAuthInfo auth_info = 1; + string kind = 2; + string uuid = 3; +} + +message ListParticipantDatasetsResponse { + repeated ParticipantDatasetRef participant_datasets = 1; +} + +message GetModelJobRequest { + ProjAuthInfo auth_info = 1; + string uuid = 2; + bool need_metrics = 3; +} + +message GetModelJobResponse { + string name = 1; + string uuid = 2; + string algorithm_type = 3; + string model_job_type = 4; + string state = 5; + string group_uuid = 6; + WorkflowDefinition config = 7; + string metrics = 8; + google.protobuf.BoolValue metric_is_public = 9; +} + +message GetModelJobGroupRequest { + ProjAuthInfo auth_info = 1; + string uuid = 2; +} + +message GetModelJobGroupResponse { + string name = 1; + string uuid = 2; + string role = 3; + bool authorized = 4; + string algorithm_type = 5; + WorkflowDefinition config = 6; +} + +message UpdateModelJobGroupRequest { + ProjAuthInfo auth_info = 1; + string uuid = 2; + WorkflowDefinition config = 3; +} + +message UpdateModelJobGroupResponse { + string uuid = 1; + WorkflowDefinition config = 2; +} + +message GetDatasetJobRequest { + ProjAuthInfo auth_info = 1; + string uuid = 2; +} + +message GetDatasetJobResponse { + DatasetJob dataset_job = 1; +} + +message CreateDatasetJobRequest { + ProjAuthInfo auth_info = 1; + DatasetJob dataset_job = 2; + string ticket_uuid = 3; + Dataset dataset = 4; +} + service WebConsoleV2Service { rpc CheckConnection (CheckConnectionRequest) returns (CheckConnectionResponse) {} - rpc Ping (PingRequest) returns (PingResponse) {} + rpc CheckPeerConnection (CheckPeerConnectionRequest) returns (CheckPeerConnectionResponse) {} rpc UpdateWorkflowState (UpdateWorkflowStateRequest) returns (UpdateWorkflowStateResponse) {} + rpc InvalidateWorkflow (InvalidateWorkflowRequest) returns (InvalidateWorkflowResponse) {} rpc GetWorkflow (GetWorkflowRequest) returns (GetWorkflowResponse) {} rpc UpdateWorkflow(UpdateWorkflowRequest) returns (UpdateWorkflowResponse) {} rpc GetJobMetrics (GetJobMetricsRequest) returns (GetJobMetricsResponse) {} rpc GetJobEvents (GetJobEventsRequest) returns (GetJobEventsResponse) {} rpc CheckJobReady (CheckJobReadyRequest) returns (CheckJobReadyResponse) {} rpc GetJobKibana (GetJobKibanaRequest) returns (GetJobKibanaResponse) {} + rpc Run2Pc (TwoPcRequest) returns (TwoPcResponse) {} + rpc ServingServiceManagement (ServingServiceRequest) returns (ServingServiceResponse) {} + rpc ServingServiceInference (ServingServiceInferenceRequest) returns (ServingServiceInferenceResponse) {} + rpc ClientHeartBeat (ClientHeartBeatRequest) returns (ClientHeartBeatResponse) {} + rpc ListParticipantDatasets (ListParticipantDatasetsRequest) returns (ListParticipantDatasetsResponse) {} + rpc GetModelJob(GetModelJobRequest) returns (GetModelJobResponse) {} + rpc GetModelJobGroup (GetModelJobGroupRequest) returns (GetModelJobGroupResponse) {} + rpc UpdateModelJobGroup (UpdateModelJobGroupRequest) returns (UpdateModelJobGroupResponse) {} + rpc GetDatasetJob(GetDatasetJobRequest) returns (GetDatasetJobResponse) {} + rpc CreateDatasetJob(CreateDatasetJobRequest) returns (google.protobuf.Empty) {} } diff --git a/web_console_v2/api/protocols/fedlearner_webconsole/proto/workflow_definition.proto b/web_console_v2/api/protocols/fedlearner_webconsole/proto/workflow_definition.proto index 27f58fab6..5895d314a 100644 --- a/web_console_v2/api/protocols/fedlearner_webconsole/proto/workflow_definition.proto +++ b/web_console_v2/api/protocols/fedlearner_webconsole/proto/workflow_definition.proto @@ -1,4 +1,4 @@ -/* Copyright 2021 The FedLearner Authors. All Rights Reserved. +/* Copyright 2023 The FedLearner Authors. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,8 @@ syntax = "proto3"; import "fedlearner_webconsole/proto/common.proto"; +import "google/protobuf/struct.proto"; + package fedlearner_webconsole.proto; @@ -36,6 +38,9 @@ message JobDefinition { TREE_MODEL_TRAINING = 5; NN_MODEL_EVALUATION = 6; TREE_MODEL_EVALUATION = 7; + TRANSFORMER = 8; + ANALYZER = 9; + CUSTOMIZED = 10; } string name = 1; @@ -44,7 +49,8 @@ message JobDefinition { repeated Variable variables = 4; repeated JobDependency dependencies = 5; string yaml_template = 6; - bool expert_mode = 7; + // If true, the job's latest edition used easy editing mode + bool easy_mode = 8; } message Slot { @@ -60,11 +66,24 @@ message Slot { JOB_PROPERTY = 6; } string reference = 1; - string default = 2; + + // will be delete in the future + string default = 2 [deprecated = true]; + string help = 3; ReferenceType reference_type = 4; string label = 5; + google.protobuf.Value default_value = 6; + enum ValueType{ + STRING = 0; + NUMBER = 1; + LIST = 2; + OBJECT = 3; + BOOL = 4; + INT = 5; + } + ValueType value_type = 7; } message YamlEditorInfo { @@ -79,8 +98,8 @@ message WorkflowTemplateEditorInfo { } message WorkflowDefinition { + reserved 2; string group_alias = 1; - bool is_left = 2; repeated Variable variables = 3; repeated JobDefinition job_definitions = 4; } diff --git a/web_console_v2/api/requirements.txt b/web_console_v2/api/requirements.txt index 28ee70aaf..2825712a9 100644 --- a/web_console_v2/api/requirements.txt +++ b/web_console_v2/api/requirements.txt @@ -1,9 +1,9 @@ Flask==1.1.2 -Flask-Migrate==2.7.0 +Flask-Migrate==3.1.0 Flask-HTTPAuth==4.2.0 flask-restful==0.3.8 passlib==1.7.4 -flask-jwt-extended>=4.0.0 +PyJWT~=2.0.1 Flask-Testing==0.8.1 gunicorn==20.0.4 # grpc-related stuff has be 1.32.0 to be compatible with tensorflow @@ -19,15 +19,35 @@ flatten-dict==0.3.0 pymysql==1.0.2 setuptools==41.0.0 tensorflow==1.15.2 -pyopenssl==20.0.1 +pyopenssl==22.0.0 +# matplotlib latest version 3.5.0 has some dependency issue, and mpld3 will install latest matplotlib. +matplotlib==3.3.4 mpld3==0.5.2 python-slugify==4.0.1 -SQLAlchemy==1.3.20 +SQLAlchemy==1.4.23 prison==0.1.3 tensorflow-io==0.8.1 -pyspark==3.1.1 -# Lint -pylint==2.4.4 -pylint-quotes==0.2.1 setproctitle==1.2.2 -mypy-protobuf==2.4 \ No newline at end of file +mypy-protobuf==2.4 +werkzeug==0.16.0 +croniter==1.0.15 +freezegun~=1.1.0 +# serving stuff +tensorflow-serving-api==1.15.0 +xmltodict==0.12.0 +simpleeval==0.9.10 +webargs==8.0.1 +marshmallow==3.13.0 +flasgger==0.9.5 +apispec_webframeworks==0.5.2 +pyparsing==3.0.7 +opentelemetry-api==1.10.0 +opentelemetry-sdk==1.10.0 +opentelemetry-instrumentation==0.29b0 +opentelemetry.instrumentation.flask==0.29b1 +opentelemetry-exporter-otlp==1.10.0 +fsspec==2022.1.0 +# pyarrow is required by fsspec to access hdfs file +pyarrow==6.0.0 +# supervisor is used to monitor multiple processes +supervisor==4.2.4 diff --git a/web_console_v2/api/run_coverage.sh b/web_console_v2/api/run_coverage.sh index ba66b6df6..909a504a6 100755 --- a/web_console_v2/api/run_coverage.sh +++ b/web_console_v2/api/run_coverage.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/web_console_v2/api/run_dev.sh b/web_console_v2/api/run_dev.sh deleted file mode 100755 index 6dd179682..000000000 --- a/web_console_v2/api/run_dev.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -# -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -e -export FLASK_ENV=development - -# Migrates DB schemas -FLASK_APP=command:app flask create-db -# Loads initial data -FLASK_APP=command:app flask create-initial-data -# Runs flask -FLASK_APP=server:app flask run --eager-loading --port=1991 --host=0.0.0.0 diff --git a/web_console_v2/api/run_prod.sh b/web_console_v2/api/run_prod.sh deleted file mode 100755 index d6a9e2157..000000000 --- a/web_console_v2/api/run_prod.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -# -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -e - -# Adds root directory to python path to make the modules findable. -ROOT_DIRECTORY=$(dirname "$0") -export PYTHONPATH=$PYTHONPATH:"$ROOT_DIRECTORY" -python3 es_configuration.py -# Iterates arguments -while test $# -gt 0 -do - case "$1" in - --migrate) - echo "Migrating DB" - # Migrates DB schemas - FLASK_APP=command:app flask db upgrade - ;; - esac - shift -done - -# Loads initial data -FLASK_APP=command:app flask create-initial-data - -export FEDLEARNER_WEBCONSOLE_LOG_DIR=/var/log/fedlearner_webconsole/ -mkdir -p $FEDLEARNER_WEBCONSOLE_LOG_DIR -gunicorn server:app \ - --config="$ROOT_DIRECTORY/gunicorn_config.py" diff --git a/web_console_v2/api/server.py b/web_console_v2/api/server.py index bdb8e9e81..19c7b80cf 100644 --- a/web_console_v2/api/server.py +++ b/web_console_v2/api/server.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,12 +13,18 @@ # limitations under the License. # coding: utf-8 +import logging + +from checks import validity_check from config import Config + from fedlearner_webconsole.app import create_app -from fedlearner_webconsole.utils import middlewares -from fedlearner_webconsole.utils.hooks import pre_start_hook +from fedlearner_webconsole.middleware.middlewares import wsgi_middlewares -pre_start_hook() +logging.info('Initializing WebConsole Api...') app = create_app(Config()) + # Middlewares -app = middlewares.init_app(app) +app = wsgi_middlewares.init_app(app) +validity_check() +logging.info('Initializing WebConsole Api... [DONE]') diff --git a/web_console_v2/api/test/__init__.py b/web_console_v2/api/test/__init__.py deleted file mode 100644 index 3e28547fe..000000000 --- a/web_console_v2/api/test/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 diff --git a/web_console_v2/api/test/auth_test.py b/web_console_v2/api/test/auth_test.py deleted file mode 100644 index 995a90458..000000000 --- a/web_console_v2/api/test/auth_test.py +++ /dev/null @@ -1,166 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import unittest -from http import HTTPStatus - -from fedlearner_webconsole.utils.base64 import base64encode -from testing.common import BaseTestCase -from fedlearner_webconsole.auth.models import State, User -from fedlearner_webconsole.db import db_handler as db - - -class AuthApiTest(BaseTestCase): - def test_get_all_users(self): - deleted_user = User(username='deleted_one', - email='who.knows@hhh.com', - state=State.DELETED) - with db.session_scope() as session: - session.add(deleted_user) - session.commit() - - resp = self.get_helper('/api/v2/auth/users') - self.assertEqual(resp.status_code, HTTPStatus.UNAUTHORIZED) - - self.signin_as_admin() - - resp = self.get_helper('/api/v2/auth/users') - self.assertEqual(resp.status_code, HTTPStatus.OK) - self.assertEqual(len(self.get_response_data(resp)), 2) - - def test_partial_update_user_info(self): - self.signin_as_admin() - resp = self.get_helper('/api/v2/auth/users') - resp_data = self.get_response_data(resp) - user_id = resp_data[0]['id'] - admin_id = resp_data[1]['id'] - - self.signin_helper() - resp = self.patch_helper('/api/v2/auth/users/10', data={}) - self.assertEqual(resp.status_code, HTTPStatus.FORBIDDEN) - - resp = self.patch_helper(f'/api/v2/auth/users/{user_id}', - data={ - 'email': 'a_new_email@bytedance.com', - }) - self.assertEqual(resp.status_code, HTTPStatus.OK) - self.assertEqual( - self.get_response_data(resp).get('email'), - 'a_new_email@bytedance.com') - - resp = self.patch_helper(f'/api/v2/auth/users/{admin_id}', - data={ - 'name': 'cannot_modify', - }) - self.assertEqual(resp.status_code, HTTPStatus.FORBIDDEN) - - # now we are signing in as admin - self.signin_as_admin() - resp = self.patch_helper(f'/api/v2/auth/users/{user_id}', - data={ - 'role': 'ADMIN', - }) - self.assertEqual(resp.status_code, HTTPStatus.OK) - self.assertEqual(self.get_response_data(resp).get('role'), 'ADMIN') - - resp = self.patch_helper(f'/api/v2/auth/users/{user_id}', - data={ - 'password': base64encode('fl@1234.'), - }) - self.assertEqual(resp.status_code, HTTPStatus.OK) - - def test_create_new_user(self): - new_user = { - 'username': 'fedlearner', - 'password': 'fedlearner', - 'email': 'hello@bytedance.com', - 'role': 'USER', - 'name': 'codemonkey', - } - resp = self.post_helper('/api/v2/auth/users', data=new_user) - self.assertEqual(resp.status_code, HTTPStatus.UNAUTHORIZED) - - self.signin_as_admin() - illegal_cases = ['aaaaaaaa', '11111111', '!@#$%^[]', - 'aaaA1111', 'AAAa!@#$', '1111!@#-', - 'aa11!@', 'fl@123.', - 'fl@1234567890abcdefg.'] - legal_case = 'fl@1234.' - - for case in illegal_cases: - new_user['password'] = base64encode(case) - resp = self.post_helper(f'/api/v2/auth/users', data=new_user) - self.assertEqual(resp.status_code, HTTPStatus.BAD_REQUEST) - - new_user['password'] = base64encode(legal_case) - resp = self.post_helper(f'/api/v2/auth/users', data=new_user) - self.assertEqual(resp.status_code, HTTPStatus.CREATED) - self.assertEqual( - self.get_response_data(resp).get('username'), 'fedlearner') - - # test_repeat_create - resp = self.post_helper(f'/api/v2/auth/users', data=new_user) - self.assertEqual(resp.status_code, HTTPStatus.CONFLICT) - - def test_delete_user(self): - self.signin_as_admin() - resp = self.get_helper('/api/v2/auth/users') - resp_data = self.get_response_data(resp) - user_id = resp_data[0]['id'] - admin_id = resp_data[1]['id'] - - self.signin_helper() - resp = self.delete_helper(url=f'/api/v2/auth/users/{user_id}') - self.assertEqual(resp.status_code, HTTPStatus.UNAUTHORIZED) - - self.signin_as_admin() - - resp = self.delete_helper(url=f'/api/v2/auth/users/{admin_id}') - self.assertEqual(resp.status_code, HTTPStatus.BAD_REQUEST) - - resp = self.delete_helper(url=f'/api/v2/auth/users/{user_id}') - self.assertEqual(resp.status_code, HTTPStatus.OK) - self.assertEqual(self.get_response_data(resp).get('username'), 'ada') - - def test_get_specific_user(self): - resp = self.get_helper(url='/api/v2/auth/users/10086') - self.assertEqual(resp.status_code, HTTPStatus.FORBIDDEN) - - resp = self.get_helper(url='/api/v2/auth/users/1') - self.assertEqual(resp.status_code, HTTPStatus.OK) - self.assertEqual(self.get_response_data(resp).get('username'), 'ada') - - self.signin_as_admin() - - resp = self.get_helper(url='/api/v2/auth/users/1') - self.assertEqual(resp.status_code, HTTPStatus.OK) - self.assertEqual(self.get_response_data(resp).get('username'), 'ada') - - resp = self.get_helper(url='/api/v2/auth/users/10086') - self.assertEqual(resp.status_code, HTTPStatus.NOT_FOUND) - - def test_signout(self): - self.signin_helper() - - resp = self.delete_helper(url='/api/v2/auth/signin') - self.assertEqual(resp.status_code, HTTPStatus.OK, resp.json) - - resp = self.get_helper(url='/api/v2/auth/users/1') - self.assertEqual(resp.status_code, HTTPStatus.UNAUTHORIZED) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/app_test.py b/web_console_v2/api/test/fedlearner_webconsole/app_test.py deleted file mode 100644 index 2a04ec419..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/app_test.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest -from http import HTTPStatus - -from testing.common import BaseTestCase - - -class ExceptionHandlersTest(BaseTestCase): - def test_not_found(self): - response = self.get_helper('/api/v2/not_found', - use_auth=False) - self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/composer/common.py b/web_console_v2/api/test/fedlearner_webconsole/composer/common.py deleted file mode 100644 index f189f23ce..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/composer/common.py +++ /dev/null @@ -1,129 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import logging -import time -from typing import Tuple - -from fedlearner_webconsole.composer.interface import IItem, ItemType, IRunner -from fedlearner_webconsole.composer.models import RunnerStatus, Context - - -class Task(IItem): - def __init__(self, task_id: int): - self.id = task_id - - def type(self) -> ItemType: - return ItemType.TASK - - def get_id(self) -> int: - return self.id - - -# used in lambda -def _raise(ex): - raise ex - - -def sleep_and_log(id: int, sec: int): - time.sleep(sec) - logging.info(f'id-{id}, sleep {sec}') - - -RunnerCases = [ - # normal: 1, 2, 3 - { - 'id': 1, - 'start': (lambda _: True), - 'result': (lambda _: sleep_and_log(1, 1) or (RunnerStatus.DONE, {})), - }, - { - 'id': 2, - 'start': (lambda _: True), - 'result': (lambda _: sleep_and_log(2, 1) or (RunnerStatus.DONE, {})), - }, - { - 'id': 3, - 'start': (lambda _: True), - 'result': (lambda _: sleep_and_log(3, 1) or (RunnerStatus.DONE, {})), - }, - # failed: 4, 5, 6 - { - 'id': 4, - 'start': (lambda _: sleep_and_log(4, 5) and False), - 'result': (lambda _: (RunnerStatus.FAILED, {})), - }, - { - 'id': 5, - 'start': (lambda _: _raise(TimeoutError)), - 'result': (lambda _: (RunnerStatus.FAILED, {})), - }, - { - 'id': 6, - 'start': (lambda _: sleep_and_log(6, 10) and False), - 'result': (lambda _: (RunnerStatus.FAILED, {})), - }, - # busy: 7, 8, 9 - { - 'id': 7, - 'start': (lambda _: True), - 'result': (lambda _: sleep_and_log(7, 15) or (RunnerStatus.DONE, {})), - }, - { - 'id': 8, - 'start': (lambda _: True), - 'result': (lambda _: sleep_and_log(8, 15) or (RunnerStatus.DONE, {})), - }, - { - 'id': 9, - 'start': (lambda _: True), - 'result': (lambda _: sleep_and_log(9, 15) or (RunnerStatus.DONE, {})), - }, -] - - -class TaskRunner(IRunner): - def __init__(self, task_id: int): - self.task_id = task_id - - def start(self, context: Context): - logging.info( - f"[mock_task_runner] {self.task_id} started, ctx: {context}") - RunnerCases[self.task_id - 1]['start'](context) - - def result(self, context: Context) -> Tuple[RunnerStatus, dict]: - result = RunnerCases[self.task_id - 1]['result'](context) - logging.info(f"[mock_task_runner] {self.task_id} done result {result}") - return result - - -class InputDirTaskRunner(IRunner): - def __init__(self, task_id: int): - self.task_id = task_id - self.input_dir = '' - - def start(self, context: Context): - self.input_dir = context.data.get(str(self.task_id), - {}).get('input_dir', '') - logging.info( - f'[mock_inputdir_task_runner] start, input_dir: {self.input_dir}') - - def result(self, context: Context) -> Tuple[RunnerStatus, dict]: - s = { - 1: RunnerStatus.RUNNING, - 2: RunnerStatus.DONE, - 3: RunnerStatus.FAILED, - } - return s.get(self.task_id, RunnerStatus.RUNNING), {} diff --git a/web_console_v2/api/test/fedlearner_webconsole/composer/composer_test.py b/web_console_v2/api/test/fedlearner_webconsole/composer/composer_test.py deleted file mode 100644 index b377d9b6d..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/composer/composer_test.py +++ /dev/null @@ -1,225 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import logging -import pprint -import sys -import threading -import time -import unittest - -from testing.common import BaseTestCase -from test.fedlearner_webconsole.composer.common import TaskRunner, Task, InputDirTaskRunner - -from fedlearner_webconsole.db import db -from fedlearner_webconsole.composer.composer import Composer, ComposerConfig -from fedlearner_webconsole.composer.models import ItemStatus, RunnerStatus, SchedulerItem, \ - SchedulerRunner -from fedlearner_webconsole.composer.interface import ItemType - - -class ComposerTest(BaseTestCase): - runner_fn = { - ItemType.TASK.value: TaskRunner, - } - - class Config(BaseTestCase.Config): - STORAGE_ROOT = '/tmp' - START_SCHEDULER = False - START_GRPC_SERVER = False - - def test_normal_items(self): - logging.info('+++++++++++++++++++++++++++ test normal items') - cfg = ComposerConfig(runner_fn=self.runner_fn, - name='scheduler for normal items') - composer = Composer(config=cfg) - composer.run(db_engine=db.engine) - normal_items = [Task(1), Task(2), Task(3)] - name = 'normal items' - composer.collect(name, normal_items, {}) - self.assertEqual(1, len(db.session.query(SchedulerItem).all()), - 'incorrect items') - # test unique item name - composer.collect(name, normal_items, {}) - self.assertEqual(1, len(db.session.query(SchedulerItem).all()), - 'incorrect items') - time.sleep(20) - self.assertEqual(1, len(db.session.query(SchedulerRunner).all()), - 'incorrect runners') - self.assertEqual(RunnerStatus.DONE.value, - composer.get_recent_runners(name)[-1].status, - 'should finish runner') - # finish item - composer.finish(name) - self.assertEqual(ItemStatus.OFF, composer.get_item_status(name), - 'should finish item') - composer.stop() - - def test_failed_items(self): - logging.info('+++++++++++++++++++++++++++ test failed items') - cfg = ComposerConfig(runner_fn=self.runner_fn, - name='scheduler for failed items') - composer = Composer(config=cfg) - composer.run(db_engine=db.engine) - failed_items = [Task(4), Task(5), Task(6)] - name = 'failed items' - composer.collect(name, failed_items, {}) - self.assertEqual(1, len(db.session.query(SchedulerItem).all()), - 'incorrect failed items') - time.sleep(30) - self.assertEqual(1, len(db.session.query(SchedulerRunner).all()), - 'incorrect runners') - self.assertEqual(RunnerStatus.FAILED.value, - composer.get_recent_runners(name)[-1].status, - 'should finish it') - composer.stop() - - def test_busy_items(self): - logging.info('+++++++++++++++++++++++++++ test busy items') - cfg = ComposerConfig(runner_fn=self.runner_fn, - name='scheduler for busy items', - worker_num=1) - composer = Composer(config=cfg) - composer.run(db_engine=db.engine) - busy_items = [Task(7), Task(8), Task(9)] - name = 'busy items' - composer.collect(name, busy_items, {}) - self.assertEqual(1, len(db.session.query(SchedulerItem).all()), - 'incorrect busy items') - time.sleep(20) - self.assertEqual(1, len(db.session.query(SchedulerRunner).all()), - 'incorrect runners') - self.assertEqual(RunnerStatus.RUNNING.value, - composer.get_recent_runners(name)[-1].status, - 'should finish it') - composer.stop() - time.sleep(5) - - def test_interval_items(self): - logging.info( - '+++++++++++++++++++++++++++ test finishing interval items') - cfg = ComposerConfig(runner_fn=self.runner_fn, - name='finish normal items') - composer = Composer(config=cfg) - composer.run(db_engine=db.engine) - name = 'cronjob' - # test invalid interval - self.assertRaises(ValueError, - composer.collect, - name, [Task(1)], {}, - interval=9) - - composer.collect(name, [Task(1)], {}, interval=10) - self.assertEqual(1, len(db.session.query(SchedulerItem).all()), - 'incorrect items') - time.sleep(20) - self.assertEqual(2, len(db.session.query(SchedulerRunner).all()), - 'incorrect runners') - self.assertEqual(RunnerStatus.DONE.value, - composer.get_recent_runners(name)[-1].status, - 'should finish runner') - composer.finish(name) - self.assertEqual(ItemStatus.OFF, composer.get_item_status(name), - 'should finish item') - composer.stop() - - def test_multiple_composers(self): - logging.info('+++++++++++++++++++++++++++ test multiple composers') - cfg = ComposerConfig(runner_fn=self.runner_fn, - name='scheduler for normal items') - composer1 = Composer(cfg) - composer2 = Composer(cfg) - c1 = threading.Thread(target=composer1.run, args=[db.engine]) - c1.start() - c2 = threading.Thread(target=composer2.run, args=[db.engine]) - c2.start() - time.sleep(15) - composer1.stop() - composer2.stop() - - def test_runner_cache(self): - logging.info('+++++++++++++++++++++++++++ test runner cache') - composer = Composer( - config=ComposerConfig(runner_fn={ - ItemType.TASK.value: InputDirTaskRunner, - }, - name='runner cache')) - composer.run(db_engine=db.engine) - composer.collect('item1', [Task(1)], { - 1: { - 'input_dir': 'item1_input_dir', - }, - }) - composer.collect('item2', [Task(1)], { - 1: { - 'input_dir': 'item2_input_dir', - }, - }) - time.sleep(15) - self.assertEqual(2, len(db.session.query(SchedulerItem).all()), - 'incorrect items') - self.assertEqual(2, len(composer.runner_cache.data), - 'should be equal runner number') - pprint.pprint(composer.runner_cache) - self.assertEqual( - 'item1_input_dir', - composer.runner_cache.find_runner(1, 'task_1').input_dir, - 'should be item1_input_dir') - self.assertEqual( - 'item2_input_dir', - composer.runner_cache.find_runner(2, 'task_1').input_dir, - 'should be item2_input_dir') - # test delete cache item - composer.collect( - 'item3', [Task(2), Task(3)], { - 2: { - 'input_dir': 'item3_input_dir_2', - }, - 3: { - 'input_dir': 'item3_input_dir_3', - } - }) - time.sleep(15) - self.assertEqual(2, len(composer.runner_cache.data), - 'should be equal runner number') - composer.stop() - - def test_patch_item_attr(self): - test_name = 'test' - - config = ComposerConfig( - runner_fn={ItemType.TASK.value: InputDirTaskRunner}, - name='test_cronjob') - with self.composer_scope(config=config) as composer: - composer.collect(test_name, [Task(1)], { - 1: { - 'input_dir': 'item1_input_dir', - }, - }, interval=60) - composer.patch_item_attr(name=test_name, key='interval_time', value=30) - item = db.session.query(SchedulerItem).filter( - SchedulerItem.name == test_name).one() - self.assertEqual(item.interval_time, 30) - - with self.assertRaises(ValueError): - composer.patch_item_attr(name=test_name, - key='create_at', - value='2021-04-01 00:00:00') - - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr, level=logging.INFO) - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/composer/op_locker_test.py b/web_console_v2/api/test/fedlearner_webconsole/composer/op_locker_test.py deleted file mode 100644 index ff5dbf624..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/composer/op_locker_test.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import logging -import sys -import unittest - -from testing.common import BaseTestCase, OptimisticLock -from fedlearner_webconsole.db import db -from fedlearner_webconsole.composer.op_locker import OpLocker - - -class OpLockTest(BaseTestCase): - class Config(BaseTestCase.Config): - STORAGE_ROOT = '/tmp' - START_SCHEDULER = False - START_GRPC_SERVER = False - START_COMPOSER = False - - def setUp(self): - super().setUp() - - def test_lock(self): - lock = OpLocker('test', db.engine).try_lock() - self.assertEqual(True, lock.is_latest_version(), - 'should be latest version') - - # update database version - new_lock = db.session.query(OptimisticLock).filter_by( - name=lock.name).first() - new_lock.version = new_lock.version + 1 - db.session.commit() - self.assertEqual(False, lock.is_latest_version(), - 'should not be latest version') - - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr, level=logging.INFO) - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/composer/runner_cache_test.py b/web_console_v2/api/test/fedlearner_webconsole/composer/runner_cache_test.py deleted file mode 100644 index a648a4710..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/composer/runner_cache_test.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import unittest - -from test.fedlearner_webconsole.composer.common import TaskRunner -from testing.common import BaseTestCase - -from fedlearner_webconsole.composer.interface import ItemType -from fedlearner_webconsole.composer.runner_cache import RunnerCache - - -class RunnerCacheTest(BaseTestCase): - class Config(BaseTestCase.Config): - STORAGE_ROOT = '/tmp' - START_SCHEDULER = False - START_GRPC_SERVER = False - - def test_runner(self): - c = RunnerCache(runner_fn={ - ItemType.TASK.value: TaskRunner, - }) - runners = [ - (1, 'task_1'), - (2, 'task_2'), - (3, 'task_3'), - ] - for runner in runners: - rid, name = runner - c.find_runner(rid, name) - self.assertEqual(len(runners), len(c.data), - 'should be equal runners number') - - for runner in runners: - rid, name = runner - c.del_runner(rid, name) - self.assertEqual(0, len(c.data), 'should be equal 0') - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/composer/thread_reaper_test.py b/web_console_v2/api/test/fedlearner_webconsole/composer/thread_reaper_test.py deleted file mode 100644 index 53ead076d..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/composer/thread_reaper_test.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import logging -import sys -import time -import unittest -from typing import Tuple - -from testing.common import BaseTestCase -from fedlearner_webconsole.composer.models import Context, RunnerStatus -from fedlearner_webconsole.db import db -from fedlearner_webconsole.composer.interface import IRunner -from fedlearner_webconsole.composer.thread_reaper import ThreadReaper - - -class TaskRunner(IRunner): - def __init__(self, task_id: int): - self.task_id = task_id - - def start(self, context: Context): - logging.info( - f"[mock_task_runner] {self.task_id} started, ctx: {context}") - time.sleep(5) - - def result(self, context: Context) -> Tuple[RunnerStatus, dict]: - time.sleep(3) - return RunnerStatus.DONE, {} - - -class ThreadReaperTest(BaseTestCase): - class Config(BaseTestCase.Config): - STORAGE_ROOT = '/tmp' - START_SCHEDULER = False - START_GRPC_SERVER = False - - def setUp(self): - super().setUp() - - def test_thread_reaper(self): - tr = ThreadReaper(worker_num=1) - - runner = TaskRunner(1) - tr.enqueue('1', runner, - Context(data={}, internal={}, db_engine=db.engine)) - self.assertEqual(True, tr.is_full(), 'should be full') - ok = tr.enqueue('2', runner, - Context(data={}, internal={}, db_engine=db.engine)) - self.assertEqual(False, ok, 'should not be enqueued') - time.sleep(10) - self.assertEqual(False, tr.is_full(), 'should not be full') - ok = tr.enqueue('3', runner, - Context(data={}, internal={}, db_engine=db.engine)) - self.assertEqual(True, ok, 'should be enqueued') - tr.stop(wait=True) - - -if __name__ == '__main__': - logging.basicConfig(stream=sys.stderr, level=logging.INFO) - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/dataset/apis_test.py b/web_console_v2/api/test/fedlearner_webconsole/dataset/apis_test.py deleted file mode 100644 index 23e2b4bed..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/dataset/apis_test.py +++ /dev/null @@ -1,349 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import json -import time -import os -import shutil -import tempfile -import unittest -from datetime import datetime, timezone -from http import HTTPStatus -from pathlib import Path -from unittest import mock -from unittest.mock import patch, MagicMock - -from collections import namedtuple -from testing.common import BaseTestCase -from fedlearner_webconsole.db import db_handler as db -from fedlearner_webconsole.dataset.models import (Dataset, DatasetType) -from tensorflow.io import gfile - -FakeFileStatistics = namedtuple('FakeFileStatistics', ['length', 'mtime_nsec']) - - -class DatasetApiTest(BaseTestCase): - class Config(BaseTestCase.Config): - STORAGE_ROOT = tempfile.gettempdir() - - def setUp(self): - super().setUp() - with db.session_scope() as session: - self.default_dataset1 = Dataset( - name='default dataset1', - dataset_type=DatasetType.STREAMING, - comment='test comment1', - path='/data/dataset/123', - project_id=1, - ) - session.add(self.default_dataset1) - session.commit() - time.sleep(1) - with db.session_scope() as session: - self.default_dataset2 = Dataset( - name='default dataset2', - dataset_type=DatasetType.STREAMING, - comment='test comment2', - path=os.path.join(tempfile.gettempdir(), 'dataset/123'), - project_id=2, - ) - session.add(self.default_dataset2) - session.commit() - - def test_get_dataset(self): - get_response = self.get_helper( - f'/api/v2/datasets/{self.default_dataset1.id}') - self.assertEqual(get_response.status_code, HTTPStatus.OK) - dataset = self.get_response_data(get_response) - self.assertEqual( - { - 'id': 1, - 'name': 'default dataset1', - 'dataset_type': 'STREAMING', - 'comment': 'test comment1', - 'path': '/data/dataset/123', - 'created_at': mock.ANY, - 'updated_at': mock.ANY, - 'deleted_at': None, - 'data_batches': [], - 'project_id': 1, - }, dataset) - - def test_get_dataset_not_found(self): - get_response = self.get_helper('/api/v2/datasets/10086') - self.assertEqual(get_response.status_code, HTTPStatus.NOT_FOUND) - - def test_get_datasets(self): - get_response = self.get_helper('/api/v2/datasets') - self.assertEqual(get_response.status_code, HTTPStatus.OK) - datasets = self.get_response_data(get_response) - self.assertEqual(len(datasets), 2) - self.assertEqual(datasets[0]['name'], 'default dataset2') - self.assertEqual(datasets[1]['name'], 'default dataset1') - - def test_get_datasets_with_project_id(self): - get_response = self.get_helper('/api/v2/datasets?project=1') - self.assertEqual(get_response.status_code, HTTPStatus.OK) - datasets = self.get_response_data(get_response) - self.assertEqual(len(datasets), 1) - self.assertEqual(datasets[0]['name'], 'default dataset1') - - def test_preview_dataset_and_feature_metrics(self): - # write data - gfile.makedirs(self.default_dataset2.path) - meta_path = os.path.join(self.default_dataset2.path, '_META') - meta_data = { - 'dtypes': { - 'f01': 'bigint' - }, - 'samples': [ - [1], - [0], - ], - } - with gfile.GFile(meta_path, 'w') as f: - f.write(json.dumps(meta_data)) - - features_path = os.path.join(self.default_dataset2.path, '_FEATURES') - features_data = { - 'f01': { - 'count': '2', - 'mean': '0.0015716767309123998', - 'stddev': '0.03961485047808605', - 'min': '0', - 'max': '1', - 'missing_count': '0' - } - } - with gfile.GFile(features_path, 'w') as f: - f.write(json.dumps(features_data)) - - hist_path = os.path.join(self.default_dataset2.path, '_HIST') - hist_data = { - "f01": { - "x": [ - 0.0, 0.1, 0.2, 0.30000000000000004, 0.4, 0.5, - 0.6000000000000001, 0.7000000000000001, 0.8, 0.9, 1 - ], - "y": [12070, 0, 0, 0, 0, 0, 0, 0, 0, 19] - } - } - with gfile.GFile(hist_path, 'w') as f: - f.write(json.dumps(hist_data)) - - response = self.client.get('/api/v2/datasets/2/preview') - self.assertEqual(response.status_code, 200) - preview_data = self.get_response_data(response) - meta_data['metrics'] = features_data - self.assertEqual(preview_data, meta_data, 'should has preview data') - - feat_name = 'f01' - feature_response = self.client.get( - f'/api/v2/datasets/2/feature_metrics?name={feat_name}') - self.assertEqual(response.status_code, 200) - feature_data = self.get_response_data(feature_response) - self.assertEqual( - feature_data, { - 'name': feat_name, - 'metrics': features_data.get(feat_name, {}), - 'hist': hist_data.get(feat_name, {}) - }, 'should has feature data') - - @patch('fedlearner_webconsole.dataset.apis.datetime') - def test_post_datasets(self, mock_datetime): - mock_datetime.now = MagicMock( - return_value=datetime(2020, 6, 8, 6, 6, 6)) - name = 'test post dataset' - dataset_type = DatasetType.STREAMING.value - comment = 'test comment' - create_response = self.post_helper('/api/v2/datasets', - data={ - 'name': name, - 'dataset_type': dataset_type, - 'comment': comment, - 'project_id': 1, - }) - self.assertEqual(create_response.status_code, HTTPStatus.OK) - created_dataset = self.get_response_data(create_response) - - dataset_path = os.path.join( - tempfile.gettempdir(), 'dataset/20200608_060606_test-post-dataset') - self.assertEqual( - { - 'id': 3, - 'name': 'test post dataset', - 'dataset_type': dataset_type, - 'comment': comment, - 'path': dataset_path, - 'created_at': mock.ANY, - 'updated_at': mock.ANY, - 'deleted_at': None, - 'data_batches': [], - 'project_id': 1, - }, created_dataset) - # patch datasets - updated_comment = 'updated comment' - put_response = self.patch_helper('/api/v2/datasets/3', - data={'comment': updated_comment}) - updated_dataset = self.get_response_data(put_response) - self.assertEqual( - { - 'id': 3, - 'name': 'test post dataset', - 'dataset_type': dataset_type, - 'comment': updated_comment, - 'path': dataset_path, - 'created_at': mock.ANY, - 'updated_at': mock.ANY, - 'deleted_at': None, - 'data_batches': [], - 'project_id': 1, - }, updated_dataset) - - @patch('fedlearner_webconsole.dataset.apis.scheduler.wakeup') - def test_post_batches(self, mock_wakeup): - dataset_id = self.default_dataset1.id - event_time = int( - datetime(2020, 6, 8, 6, 8, 8, tzinfo=timezone.utc).timestamp()) - files = ['/data/upload/1.csv', '/data/upload/2.csv'] - move = False - comment = 'test post comment' - create_response = self.post_helper( - f'/api/v2/datasets/{dataset_id}/batches', - data={ - 'event_time': event_time, - 'files': files, - 'move': move, - 'comment': comment - }) - self.assertEqual(create_response.status_code, HTTPStatus.OK) - created_data_batch = self.get_response_data(create_response) - - self.maxDiff = None - self.assertEqual( - { - 'id': 1, - 'dataset_id': 1, - 'comment': comment, - 'event_time': event_time, - 'created_at': mock.ANY, - 'updated_at': mock.ANY, - 'deleted_at': None, - 'file_size': 0, - 'move': False, - 'num_file': 2, - 'num_imported_file': 0, - 'path': '/data/dataset/123/batch/20200608_060808', - 'state': 'NEW', - 'details': { - 'files': [{ - 'destination_path': - '/data/dataset/123/batch/20200608_060808/1.csv', - 'error_message': '', - 'size': '0', - 'source_path': '/data/upload/1.csv', - 'state': 'UNSPECIFIED' - }, { - 'destination_path': - '/data/dataset/123/batch/20200608_060808/2.csv', - 'error_message': '', - 'size': '0', - 'source_path': '/data/upload/2.csv', - 'state': 'UNSPECIFIED' - }] - } - }, created_data_batch) - mock_wakeup.assert_called_once_with( - data_batch_ids=[created_data_batch['id']]) - - -class FilesApiTest(BaseTestCase): - class Config(BaseTestCase.Config): - STORAGE_ROOT = tempfile.gettempdir() - - def setUp(self): - super().setUp() - # Create a temporary directory - self._tempdir = os.path.join(tempfile.gettempdir(), 'upload') - os.makedirs(self._tempdir, exist_ok=True) - subdir = Path(self._tempdir).joinpath('s') - subdir.mkdir() - Path(self._tempdir).joinpath('f1.txt').write_text('f1') - Path(self._tempdir).joinpath('f2.txt').write_text('f2f2') - subdir.joinpath('s3.txt').write_text('s3s3s3') - - # Mocks os.stat - self._orig_os_stat = os.stat - - def fake_stat(path, *arg, **kwargs): - return self._get_file_stat(self._orig_os_stat, path) - - gfile.stat = fake_stat - - def tearDown(self): - os.stat = self._orig_os_stat - # Remove the directory after the test - shutil.rmtree(self._tempdir) - super().tearDown() - - def _get_temp_path(self, file_path: str = None) -> str: - return str(Path(self._tempdir, file_path or '').absolute()) - - def _get_file_stat(self, orig_os_stat, path): - if path == self._get_temp_path('f1.txt') or \ - path == self._get_temp_path('f2.txt') or \ - path == self._get_temp_path('s/s3.txt'): - return FakeFileStatistics(2, 1613982390 * 1e9) - else: - return orig_os_stat(path) - - def test_get_default_storage_root(self): - get_response = self.get_helper('/api/v2/files') - self.assertEqual(get_response.status_code, HTTPStatus.OK) - files = self.get_response_data(get_response) - self.assertEqual(sorted(files, key=lambda f: f['path']), [ - { - 'path': self._get_temp_path('f1.txt'), - 'size': 2, - 'mtime': 1613982390 - }, - { - 'path': self._get_temp_path('f2.txt'), - 'size': 2, - 'mtime': 1613982390 - }, - { - 'path': self._get_temp_path('s/s3.txt'), - 'size': 2, - 'mtime': 1613982390 - }, - ]) - - def test_get_specified_directory(self): - dir = self._get_temp_path('s') - get_response = self.get_helper(f'/api/v2/files?directory={dir}') - self.assertEqual(get_response.status_code, HTTPStatus.OK) - files = self.get_response_data(get_response) - self.assertEqual(files, [ - { - 'path': self._get_temp_path('s/s3.txt'), - 'size': 2, - 'mtime': 1613982390 - }, - ]) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/db_test.py b/web_console_v2/api/test/fedlearner_webconsole/db_test.py deleted file mode 100644 index d49a1983a..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/db_test.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import unittest - -from fedlearner_webconsole.db import get_database_uri, _turn_db_timezone_to_utc -from fedlearner_webconsole.proto import common_pb2 - - -class EngineSessionTest(unittest.TestCase): - def test_turn_db_timezone_to_utc(self): - sqlite_uri = 'sqlite:///app.db' - self.assertEqual(_turn_db_timezone_to_utc(sqlite_uri), - 'sqlite:///app.db') - - mysql_uri_naive = 'mysql+pymysql://root:root@localhost:33600/fedlearner' - self.assertEqual( - _turn_db_timezone_to_utc(mysql_uri_naive), - 'mysql+pymysql://root:root@localhost:33600/fedlearner?init_command=SET SESSION time_zone=\'%2B00:00\'' - ) - - mysql_uri_with_init_command = 'mysql+pymysql://root:root@localhost:33600/fedlearner?init_command=HELLO' - self.assertEqual( - _turn_db_timezone_to_utc(mysql_uri_with_init_command), - 'mysql+pymysql://root:root@localhost:33600/fedlearner?init_command=SET SESSION time_zone=\'%2B00:00\';HELLO' - ) - - mysql_uri_with_other_args = 'mysql+pymysql://root:root@localhost:33600/fedlearner?charset=utf8mb4' - self.assertEqual( - _turn_db_timezone_to_utc(mysql_uri_with_other_args), - 'mysql+pymysql://root:root@localhost:33600/fedlearner?init_command=SET SESSION time_zone=\'%2B00:00\'&&charset=utf8mb4' - ) - - mysql_uri_with_set_time_zone = 'mysql+pymysql://root:root@localhost:33600/fedlearner?init_command=SET SESSION time_zone=\'%2B08:00\'' - self.assertEqual( - _turn_db_timezone_to_utc(mysql_uri_with_set_time_zone), - 'mysql+pymysql://root:root@localhost:33600/fedlearner?init_command=SET SESSION time_zone=\'%2B00:00\'' - ) - - def test_get_database_uri(self): - # test with environmental variable - os.environ[ - 'SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:root@localhost:33600/fedlearner' - self.assertTrue(get_database_uri().startswith( - 'mysql+pymysql://root:root@localhost:33600/fedlearner')) - - # test with fallback options - os.environ.pop('SQLALCHEMY_DATABASE_URI') - self.assertTrue(get_database_uri().startswith('sqlite:///')) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/exceptions_test.py b/web_console_v2/api/test/fedlearner_webconsole/exceptions_test.py deleted file mode 100644 index 6e7af1a3c..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/exceptions_test.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest - -from http import HTTPStatus -from fedlearner_webconsole.exceptions import (InvalidArgumentException, - NotFoundException) - - -class ExceptionsTest(unittest.TestCase): - - def test_invalid_argument_exception(self): - """Checks if the information of the exception is correct.""" - exception = InvalidArgumentException(['123', 'df']) - self.assertEqual(exception.status_code, HTTPStatus.BAD_REQUEST) - self.assertEqual( - exception.to_dict(), { - 'code': 400, - 'message': 'Invalid argument or payload.', - 'details': [ - '123', - 'df', - ] - }) - - def test_not_found_exception(self): - exception1 = NotFoundException('User A not found.') - self.assertEqual(exception1.status_code, HTTPStatus.NOT_FOUND) - self.assertEqual( - exception1.to_dict(), { - 'code': 404, - 'message': 'User A not found.', - }) - exception2 = NotFoundException() - self.assertEqual(exception2.status_code, HTTPStatus.NOT_FOUND) - self.assertEqual( - exception2.to_dict(), { - 'code': 404, - 'message': 'Resource not found.', - }) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/job/metrics_test.py b/web_console_v2/api/test/fedlearner_webconsole/job/metrics_test.py deleted file mode 100644 index 6aeff6379..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/job/metrics_test.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import time -import unittest -from http import HTTPStatus - -from testing.common import BaseTestCase, TestAppProcess - -from fedlearner_webconsole.proto import workflow_definition_pb2 -from fedlearner_webconsole.db import db -from fedlearner_webconsole.workflow.models import Workflow -from fedlearner_webconsole.job.models import Job, JobType -from fedlearner_webconsole.job.metrics import JobMetricsBuilder - - -class JobMetricsBuilderTest(BaseTestCase): - class Config(BaseTestCase.Config): - ES_HOST = '' - ES_PORT = 80 - - class FollowerConfig(Config): - GRPC_LISTEN_PORT = 4990 - - def test_data_join_metrics(self): - job = Job( - name='multi-indices-test27', - job_type=JobType.DATA_JOIN) - import json - print(json.dumps(JobMetricsBuilder(job).plot_metrics())) - - def test_nn_metrics(self): - job = Job( - name='automl-2782410011', - job_type=JobType.NN_MODEL_TRANINING) - print(JobMetricsBuilder(job).plot_metrics()) - - def test_peer_metrics(self): - proc = TestAppProcess( - JobMetricsBuilderTest, - 'follower_test_peer_metrics', - JobMetricsBuilderTest.FollowerConfig) - proc.start() - self.leader_test_peer_metrics() - proc.terminate() - - def leader_test_peer_metrics(self): - self.setup_project( - 'leader', - JobMetricsBuilderTest.FollowerConfig.GRPC_LISTEN_PORT) - workflow = Workflow( - name='test-workflow', - project_id=1) - db.session.add(workflow) - db.session.commit() - - while True: - resp = self.get_helper( - '/api/v2/workflows/1/peer_workflows' - '/0/jobs/test-job/metrics') - if resp.status_code == HTTPStatus.OK: - break - time.sleep(1) - - def follower_test_peer_metrics(self): - self.setup_project( - 'follower', - JobMetricsBuilderTest.Config.GRPC_LISTEN_PORT) - workflow = Workflow( - name='test-workflow', - project_id=1, - metric_is_public=True) - workflow.set_job_ids([1]) - db.session.add(workflow) - job = Job( - name='automl-2782410011', - job_type=JobType.NN_MODEL_TRANINING, - workflow_id=1, - project_id=1, - config=workflow_definition_pb2.JobDefinition( - name='test-job' - ).SerializeToString()) - db.session.add(job) - db.session.commit() - - while True: - time.sleep(1) - - -if __name__ == '__main__': - # no es in test env skip this test - # unittest.main() - pass diff --git a/web_console_v2/api/test/fedlearner_webconsole/job/service_test.py b/web_console_v2/api/test/fedlearner_webconsole/job/service_test.py deleted file mode 100644 index 8161ea56c..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/job/service_test.py +++ /dev/null @@ -1,112 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -from unittest.mock import patch -from testing.common import BaseTestCase - -from fedlearner_webconsole.proto import workflow_definition_pb2 -from fedlearner_webconsole.db import db -from fedlearner_webconsole.workflow.models import Workflow -from fedlearner_webconsole.job.models import Job, JobDependency, JobType, JobState -from fedlearner_webconsole.job.service import JobService - - -class JobServiceTest(BaseTestCase): - - def setUp(self): - super().setUp() - workflow_0 = Workflow(id=0, name='test-workflow-0', project_id=0) - workflow_1 = Workflow(id=1, name='test-workflow-1', project_id=0) - db.session.add_all([workflow_0, workflow_1]) - - config = workflow_definition_pb2.JobDefinition( - name='test-job').SerializeToString() - job_0 = Job(id=0, - name='raw_data_0', - job_type=JobType.RAW_DATA, - state=JobState.STARTED, - workflow_id=0, - project_id=0, - config=config) - job_1 = Job(id=1, - name='raw_data_1', - job_type=JobType.RAW_DATA, - state=JobState.COMPLETED, - workflow_id=0, - project_id=0, - config=config) - job_2 = Job(id=2, - name='data_join_0', - job_type=JobType.DATA_JOIN, - state=JobState.WAITING, - workflow_id=0, - project_id=0, - config=config) - job_3 = Job(id=3, - name='data_join_1', - job_type=JobType.DATA_JOIN, - state=JobState.COMPLETED, - workflow_id=1, - project_id=0, - config=config) - job_4 = Job(id=4, - name='train_job_0', - job_type=JobType.NN_MODEL_TRANINING, - state=JobState.WAITING, - workflow_id=1, - project_id=0, - config=config) - db.session.add_all([job_0, job_1, job_2, job_3, job_4]) - - job_dep_0 = JobDependency(src_job_id=job_0.id, - dst_job_id=job_2.id, - dep_index=0) - job_dep_1 = JobDependency(src_job_id=job_1.id, - dst_job_id=job_2.id, - dep_index=1) - job_dep_2 = JobDependency(src_job_id=job_3.id, - dst_job_id=job_4.id, - dep_index=0) - - db.session.add_all([job_dep_0, job_dep_1, job_dep_2]) - db.session.commit() - - def test_is_ready(self): - job_0 = db.session.query(Job).get(0) - job_2 = db.session.query(Job).get(2) - job_4 = db.session.query(Job).get(4) - job_service = JobService(db.session) - self.assertTrue(job_service.is_ready(job_0)) - self.assertFalse(job_service.is_ready(job_2)) - self.assertTrue(job_service.is_ready(job_4)) - - @patch('fedlearner_webconsole.job.models.Job.is_flapp_failed') - @patch('fedlearner_webconsole.job.models.Job.is_flapp_complete') - def test_update_running_state(self, mock_is_complete, mock_is_failed): - job_0 = db.session.query(Job).get(0) - job_2 = db.session.query(Job).get(2) - mock_is_complete.return_value = True - job_service = JobService(db.session) - job_service.update_running_state(job_0.name) - self.assertEqual(job_0.state, JobState.COMPLETED) - self.assertTrue(job_service.is_ready(job_2)) - job_0.state = JobState.STARTED - mock_is_complete.return_value = False - mock_is_failed = True - job_service.update_running_state(job_0.name) - self.assertEqual(job_0.state, JobState.FAILED) - - diff --git a/web_console_v2/api/test/fedlearner_webconsole/job/yaml_formatter_test.py b/web_console_v2/api/test/fedlearner_webconsole/job/yaml_formatter_test.py deleted file mode 100644 index 8ad1e6269..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/job/yaml_formatter_test.py +++ /dev/null @@ -1,152 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest -import tarfile -import base64 -from io import BytesIO -from google.protobuf.json_format import ParseDict -from fedlearner_webconsole.job.yaml_formatter import format_yaml, code_dict_encode, generate_self_dict -from fedlearner_webconsole.job.models import Job, JobState -from fedlearner_webconsole.proto.workflow_definition_pb2 import JobDefinition -from testing.common import BaseTestCase - - -class YamlFormatterTest(BaseTestCase): - def test_format_with_phs(self): - project = { - 'variables[0]': - {'storage_root_dir': 'root_dir'} - - } - workflow = { - 'jobs': { - 'raw_data_job': {'name': 'raw_data123'} - } - } - yaml = format_yaml(""" - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables[0].storage_root_dir}/raw_data/${workflow.jobs.raw_data_job.name}" - } - """, project=project, workflow=workflow) - self.assertEqual(yaml, """ - { - "name": "OUTPUT_BASE_DIR", - "value": "root_dir/raw_data/raw_data123" - } - """) - - self.assertEqual(format_yaml('$project.variables[0].storage_root_dir', - project=project), - project['variables[0]']['storage_root_dir']) - - def test_format_with_no_ph(self): - self.assertEqual(format_yaml('{a: 123, b: 234}'), - '{a: 123, b: 234}') - - def test_format_yaml_unknown_ph(self): - x = { - 'y': 123 - } - with self.assertRaises(RuntimeError) as cm: - format_yaml('$x.y is $i.j.k', x=x) - self.assertEqual(str(cm.exception), 'Unknown placeholder: i.j.k') - with self.assertRaises(RuntimeError) as cm: - format_yaml('$x.y is ${i.j}', x=x) - self.assertEqual(str(cm.exception), 'Unknown placeholder: i.j') - - def test_encode_code(self): - test_data = {'test/a.py': 'awefawefawefawefwaef', - 'test1/b.py': 'asdfasd', - 'c.py': '', - 'test/d.py': 'asdf'} - code_base64 = code_dict_encode(test_data) - code_dict = {} - if code_base64.startswith('base64://'): - tar_binary = BytesIO(base64.b64decode(code_base64[9:])) - with tarfile.open(fileobj=tar_binary) as tar: - for file in tar.getmembers(): - code_dict[file.name] = str(tar.extractfile(file).read(), - encoding='utf-8') - self.assertEqual(code_dict, test_data) - - def test_generate_self_dict(self): - config = { - 'variables': [ - { - 'name': 'namespace', - 'value': 'leader' - }, - { - 'name': 'basic_envs', - 'value': '{}' - }, - { - 'name': 'storage_root_dir', - 'value': '/' - }, - { - 'name': 'EGRESS_URL', - 'value': '127.0.0.1:1991' - } - ] - } - job = Job(name='aa', project_id=1, workflow_id=1, state=JobState.NEW) - job.set_config(ParseDict(config, JobDefinition())) - self.assertEqual(generate_self_dict(job), - {'id': None, 'name': 'aa', - 'job_type': None, 'state': 'NEW', 'config': - {'expert_mode': False, - 'variables': [ - { - 'name': 'namespace', - 'value': 'leader', - 'access_mode': 'UNSPECIFIED', - 'widget_schema': '', - 'value_type': 'STRING'}, - { - 'name': 'basic_envs', - 'value': '{}', - 'access_mode': 'UNSPECIFIED', - 'widget_schema': '', - 'value_type': 'STRING'}, - { - 'name': 'storage_root_dir', - 'value': '/', - 'access_mode': 'UNSPECIFIED', - 'widget_schema': '', - 'value_type': 'STRING'}, - { - 'name': 'EGRESS_URL', - 'value': '127.0.0.1:1991', - 'access_mode': 'UNSPECIFIED', - 'widget_schema': '', - 'value_type': 'STRING'}], - 'name': '', - 'job_type': 'UNSPECIFIED', - 'is_federated': False, - 'dependencies': [], - 'yaml_template': ''}, - 'is_disabled': None, 'workflow_id': 1, 'project_id': 1, 'flapp_snapshot': None, - 'pods_snapshot': None, 'error_message': None, 'created_at': None, 'updated_at': None, - 'deleted_at': None, 'pods': [], 'complete_at': None, - 'variables': {'namespace': 'leader', 'basic_envs': '{}', 'storage_root_dir': '/', - 'EGRESS_URL': '127.0.0.1:1991'}} - ) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/k8s/models_test.py b/web_console_v2/api/test/fedlearner_webconsole/k8s/models_test.py deleted file mode 100644 index 8086a1488..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/k8s/models_test.py +++ /dev/null @@ -1,203 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest -from datetime import datetime, timezone - -from fedlearner_webconsole.k8s.models import PodType, PodState, ContainerState, PodCondition, Pod, FlAppState, FlApp - - -class PodTypeTest(unittest.TestCase): - def test_from_string(self): - self.assertEqual(PodType.from_value('master'), PodType.MASTER) - self.assertEqual(PodType.from_value('Ps'), PodType.PS) - self.assertEqual(PodType.from_value('WORKER'), - PodType.WORKER) - - def test_from_unknown(self): - self.assertEqual(PodType.from_value('hhhhhhhh'), - PodType.UNKNOWN) - self.assertEqual(PodType.from_value(1), - PodType.UNKNOWN) - - -class PodStateTest(unittest.TestCase): - def test_from_string(self): - self.assertEqual(PodState.from_value('Running'), PodState.RUNNING) - self.assertEqual(PodState.from_value('Unknown'), PodState.UNKNOWN) - - def test_from_unknown(self): - self.assertEqual(PodState.from_value('hhhhhhhh'), - PodState.UNKNOWN) - - -class ContainerStateTest(unittest.TestCase): - def test_get_message(self): - state = ContainerState(state='haha', - message='test message', - reason='test reason') - self.assertEqual(state.get_message(), 'haha:test reason') - self.assertEqual(state.get_message(private=True), 'haha:test message') - state.message = None - self.assertEqual(state.get_message(), 'haha:test reason') - self.assertEqual(state.get_message(private=True), 'haha:test reason') - - -class PodConditionTest(unittest.TestCase): - def test_get_message(self): - cond = PodCondition(cond_type='t1', - message='test message', - reason='test reason') - self.assertEqual(cond.get_message(), 't1:test reason') - self.assertEqual(cond.get_message(private=True), 't1:test message') - cond.message = None - self.assertEqual(cond.get_message(), 't1:test reason') - self.assertEqual(cond.get_message(private=True), 't1:test reason') - - -class PodTest(unittest.TestCase): - def test_to_dict(self): - pod = Pod(name='this-is-a-pod', - state=PodState.RUNNING, - pod_type=PodType.WORKER, - pod_ip='172.10.0.20', - container_states=[ContainerState( - state='h1', - message='test message' - )], - pod_conditions=[PodCondition( - cond_type='h2', - reason='test reason' - )]) - self.assertEqual(pod.to_dict(include_private_info=True), - { - 'name': 'this-is-a-pod', - 'pod_type': 'WORKER', - 'state': 'RUNNING', - 'pod_ip': '172.10.0.20', - 'message': 'h1:test message, h2:test reason' - }) - - def test_from_json(self): - json = { - 'metadata': { - 'name': 'test-pod', - 'labels': { - 'app-name': 'u244777dac51949c5b2b-data-join-job', - 'fl-replica-type': 'master' - }, - }, - 'status': { - 'pod_ip': '172.10.0.20', - 'phase': 'Running', - 'conditions': [ - { - 'type': 'Failed', - 'reason': 'Test reason' - } - ], - 'containerStatuses': [ - { - 'containerID': 'docker://034eaf58d4e24581232832661636da9949b6e2fb056398939fc2c0f2809d4c64', - 'image': 'artifact.bytedance.com/fedlearner/fedlearner:438d603', - 'state': { - 'running': { - 'message': 'Test message' - } - } - } - ] - } - } - expected_pod = Pod( - name='test-pod', - state=PodState.RUNNING, - pod_type=PodType.MASTER, - pod_ip='172.10.0.20', - container_states=[ - ContainerState( - state='running', - message='Test message' - ) - ], - pod_conditions=[ - PodCondition( - cond_type='Failed', - reason='Test reason' - ) - ] - ) - self.assertEqual(Pod.from_json(json), expected_pod) - - -class FlAppStateTest(unittest.TestCase): - def test_from_string(self): - self.assertEqual(FlAppState.from_value('FLStateComplete'), - FlAppState.COMPLETED) - self.assertEqual(FlAppState.from_value('Unknown'), FlAppState.UNKNOWN) - - def test_from_unknown(self): - self.assertEqual(FlAppState.from_value('hhh123hhh'), - FlAppState.UNKNOWN) - - -class FlAppTest(unittest.TestCase): - def test_from_json(self): - json = { - 'status': { - 'appState': 'FLStateComplete', - 'completionTime': '2021-04-26T08:33:45Z', - 'flReplicaStatus': { - 'Master': { - 'failed': { - 'test-pod1': {} - } - }, - 'Worker': { - 'succeeded': { - 'test-pod2': {}, - 'test-pod3': {} - } - } - } - } - } - completed_at = int(datetime(2021, 4, 26, 8, 33, 45, tzinfo=timezone.utc).timestamp()) - expected_flapp = FlApp( - state=FlAppState.COMPLETED, - completed_at=completed_at, - pods=[ - Pod( - name='test-pod1', - state=PodState.FAILED_AND_FREED, - pod_type=PodType.MASTER - ), - Pod( - name='test-pod2', - state=PodState.SUCCEEDED_AND_FREED, - pod_type=PodType.WORKER - ), - Pod( - name='test-pod3', - state=PodState.SUCCEEDED_AND_FREED, - pod_type=PodType.WORKER - ) - ] - ) - self.assertEqual(FlApp.from_json(json), expected_flapp) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/mmgr/model_test.py b/web_console_v2/api/test/fedlearner_webconsole/mmgr/model_test.py deleted file mode 100644 index 84c8644e2..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/mmgr/model_test.py +++ /dev/null @@ -1,163 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import unittest -from unittest.mock import MagicMock, patch - -from testing.common import BaseTestCase -from fedlearner_webconsole.db import db, get_session -from fedlearner_webconsole.mmgr.models import Model -from fedlearner_webconsole.mmgr.models import ModelState -from fedlearner_webconsole.mmgr.service import ModelService -from fedlearner_webconsole.job.models import Job, JobType, JobState -from fedlearner_webconsole.utils.k8s_cache import Event, EventType, ObjectType - - -class ModelTest(BaseTestCase): - @patch( - 'fedlearner_webconsole.mmgr.service.ModelService.get_checkpoint_path') - def setUp(self, mock_get_checkpoint_path): - super().setUp() - self.model_service = ModelService(db.session) - self.train_job = Job(name='train-job', - job_type=JobType.NN_MODEL_TRANINING, - workflow_id=1, - project_id=1) - self.eval_job = Job(name='eval-job', - job_type=JobType.NN_MODEL_EVALUATION, - workflow_id=1, - project_id=1) - mock_get_checkpoint_path.return_value = 'output' - self.model_service.create(job=self.train_job, parent_job_name=None) - model = db.session.query(Model).filter_by( - job_name=self.train_job.name).one() - self.model_service.create(job=self.eval_job, - parent_job_name=model.job_name) - db.session.add(self.train_job) - db.session.add(self.eval_job) - db.session.commit() - - @patch('fedlearner_webconsole.mmgr.service.ModelService.plot_metrics') - def test_on_job_update(self, mock_plot_metrics: MagicMock): - mock_plot_metrics.return_value = 'plot metrics return' - - # TODO: change get_session to db.session_scope - with get_session(db.engine) as session: - model = session.query(Model).filter_by( - job_name=self.train_job.name).one() - self.assertEqual(model.state, ModelState.COMMITTED.value) - - train_job = session.query(Job).filter_by(name='train-job').one() - train_job.state = JobState.STARTED - session.commit() - - # TODO: change get_session to db.session_scope - with get_session(db.engine) as session: - train_job = session.query(Job).filter_by(name='train-job').one() - train_job.state = JobState.STARTED - model = session.query(Model).filter_by( - job_name=self.train_job.name).one() - model_service = ModelService(session) - - model_service.on_job_update(train_job) - self.assertEqual(model.state, ModelState.RUNNING.value) - session.commit() - - # TODO: change get_session to db.session_scope - with get_session(db.engine) as session: - train_job = session.query(Job).filter_by(name='train-job').one() - train_job.state = JobState.COMPLETED - model = session.query(Model).filter_by( - job_name=self.train_job.name).one() - model_service = ModelService(session) - - model_service.on_job_update(train_job) - self.assertEqual(model.state, ModelState.SUCCEEDED.value) - session.commit() - - # TODO: change get_session to db.session_scope - with get_session(db.engine) as session: - train_job = session.query(Job).filter_by(name='train-job').one() - train_job.state = JobState.FAILED - model = session.query(Model).filter_by( - job_name=self.train_job.name).one() - model_service = ModelService(session) - - model_service.on_job_update(train_job) - self.assertEqual(model.state, ModelState.FAILED.value) - session.commit() - - def test_hook(self): - train_job = Job(id=0, - state=JobState.STARTED, - name='nn-train', - job_type=JobType.NN_MODEL_TRANINING, - workflow_id=0, - project_id=0) - db.session.add(train_job) - db.session.commit() - event = Event(flapp_name='nn-train', - event_type=EventType.ADDED, - obj_type=ObjectType.FLAPP, - obj_dict={}) - self.model_service.workflow_hook(train_job) - model = Model.query.filter_by(job_name='nn-train').one() - self.assertEqual(model.state, ModelState.COMMITTED.value) - - event.event_type = EventType.MODIFIED - train_job.state = JobState.STARTED - self.model_service.k8s_watcher_hook(event) - self.assertEqual(model.state, ModelState.RUNNING.value) - - train_job.state = JobState.COMPLETED - self.model_service.k8s_watcher_hook(event) - self.assertEqual(model.state, ModelState.SUCCEEDED.value) - - train_job.state = JobState.STARTED - self.model_service.k8s_watcher_hook(event) - self.assertEqual(model.state, ModelState.RUNNING.value) - self.assertEqual(model.version, 2) - - train_job.state = JobState.STOPPED - self.model_service.k8s_watcher_hook(event) - self.assertEqual(model.state, ModelState.PAUSED.value) - db.session.rollback() - - def test_api(self): - resp = self.get_helper('/api/v2/models/1') - data = self.get_response_data(resp) - self.assertEqual(data.get('id'), 1) - - resp = self.get_helper('/api/v2/models') - model_list = self.get_response_data(resp) - self.assertEqual(len(model_list), 1) - - model = Model.query.first() - model.state = ModelState.FAILED.value - db.session.add(model) - db.session.commit() - self.delete_helper('/api/v2/models/1') - resp = self.get_helper('/api/v2/models/1') - data = self.get_response_data(resp) - self.assertEqual(data.get('state'), ModelState.DROPPED.value) - - def test_get_eval(self): - model = Model.query.filter_by(job_name=self.train_job.name).one() - self.assertEqual(len(model.get_eval_model()), 1) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/project/add_on_test.py b/web_console_v2/api/test/fedlearner_webconsole/project/add_on_test.py deleted file mode 100644 index 56f3e9cfa..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/project/add_on_test.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import os -import unittest -from base64 import b64decode, b64encode -from fedlearner_webconsole.project.add_on import parse_certificates - - -class AddOnTest(unittest.TestCase): - - def test_parse_certificates(self): - file_names = [ - 'client/client.pem', 'client/client.key', 'client/intermediate.pem', 'client/root.pem', - 'server/server.pem', 'server/server.key', 'server/intermediate.pem', 'server/root.pem' - ] - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), - 'test.tar.gz'), 'rb') as file: - certificates = parse_certificates(b64encode(file.read())) - for file_name in file_names: - self.assertEqual(str(b64decode(certificates.get(file_name)), encoding='utf-8'), - 'test {}'.format(file_name)) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/project/apis_test.py b/web_console_v2/api/test/fedlearner_webconsole/project/apis_test.py deleted file mode 100644 index c91401d73..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/project/apis_test.py +++ /dev/null @@ -1,183 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import json -import unittest - -from base64 import b64encode -from http import HTTPStatus -from google.protobuf.json_format import ParseDict -from unittest.mock import patch, MagicMock - -from testing.common import BaseTestCase -from fedlearner_webconsole.db import db -from fedlearner_webconsole.project.models import Project -from fedlearner_webconsole.project.add_on import parse_certificates, verify_certificates -from fedlearner_webconsole.proto.project_pb2 import Project as ProjectProto, \ - CertificateStorage -from fedlearner_webconsole.workflow.models import Workflow - - -class ProjectApiTest(BaseTestCase): - - def setUp(self): - super().setUp() - with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), - 'test.tar.gz'), 'rb') as file: - self.TEST_CERTIFICATES = str(b64encode(file.read()), encoding='utf-8') - self.default_project = Project() - self.default_project.name = 'test-self.default_project' - self.default_project.set_config(ParseDict({ - 'participants': [ - { - 'name': 'test-participant', - 'domain_name': 'fl-test.com', - 'url': '127.0.0.1:32443' - } - ], - 'variables': [ - { - 'name': 'test', - 'value': 'test' - } - ] - }, ProjectProto())) - self.default_project.set_certificate(ParseDict({ - 'domain_name_to_cert': {'fl-test.com': - {'certs': - parse_certificates(self.TEST_CERTIFICATES)}} - }, CertificateStorage())) - self.default_project.comment = 'test comment' - db.session.add(self.default_project) - workflow = Workflow(name='workflow_key_get1', - project_id=1) - db.session.add(workflow) - db.session.commit() - - def test_get_project(self): - get_response = self.get_helper( - '/api/v2/projects/{}'.format(1) - ) - self.assertEqual(get_response.status_code, HTTPStatus.OK) - queried_project = json.loads(get_response.data).get('data') - self.assertEqual(queried_project, self.default_project.to_dict()) - - def test_get_not_found_project(self): - get_response = self.get_helper( - '/api/v2/projects/{}'.format(1000) - ) - self.assertEqual(get_response.status_code, HTTPStatus.NOT_FOUND) - - @patch('fedlearner_webconsole.project.apis.verify_certificates') - def test_post_project(self, mock_verify_certificates): - mock_verify_certificates.return_value = (True, '') - name = 'test-post-project' - config = { - 'participants': [ - { - 'name': 'test-post-participant', - 'domain_name': 'fl-test-post.com', - 'url': '127.0.0.1:32443', - 'certificates': self.TEST_CERTIFICATES - } - ], - 'variables': [ - { - 'name': 'test-post', - 'value': 'test' - } - ] - } - comment = 'test post project' - create_response = self.post_helper( - '/api/v2/projects', - data={ - 'name': name, - 'config': config, - 'comment': comment - }) - self.assertEqual(create_response.status_code, HTTPStatus.OK) - created_project = json.loads(create_response.data).get('data') - - queried_project = Project.query.filter_by(name=name).first() - self.assertEqual(created_project, queried_project.to_dict()) - - mock_verify_certificates.assert_called_once_with( - parse_certificates(self.TEST_CERTIFICATES)) - - def test_post_conflict_name_project(self): - config = { - 'participants': { - 'fl-test-post.com': { - 'name': 'test-post-participant', - 'url': '127.0.0.1:32443', - 'certificates': self.TEST_CERTIFICATES - } - }, - 'variables': [ - { - 'name': 'test-post', - 'value': 'test' - } - ] - } - create_response = self.post_helper( - '/api/v2/projects', - data={ - 'name': self.default_project.name, - 'config': config, - 'comment': '' - }) - self.assertEqual(create_response.status_code, HTTPStatus.BAD_REQUEST) - - def test_list_project(self): - list_response = self.get_helper('/api/v2/projects') - project_list = json.loads(list_response.data).get('data') - self.assertEqual(len(project_list), 1) - for project in project_list: - queried_project = Project.query.filter_by( - name=project['name']).first() - result = queried_project.to_dict() - result['num_workflow'] = 1 - self.assertEqual(project, result) - - def test_update_project(self): - updated_name = 'updated name' - updated_comment = 'updated comment' - update_response = self.patch_helper( - '/api/v2/projects/{}'.format(1), - data={ - 'participant_name': updated_name, - 'comment': updated_comment - }) - self.assertEqual(update_response.status_code, HTTPStatus.OK) - queried_project = Project.query.filter_by(id=1).first() - participant = queried_project.get_config().participants[0] - self.assertEqual(participant.name, updated_name) - self.assertEqual(queried_project.comment, updated_comment) - - def test_update_not_found_project(self): - updated_comment = 'updated comment' - update_response = self.patch_helper( - '/api/v2/projects/{}'.format(1000), - data={ - 'comment': updated_comment - }) - self.assertEqual(update_response.status_code, HTTPStatus.NOT_FOUND) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/project/models_test.py b/web_console_v2/api/test/fedlearner_webconsole/project/models_test.py deleted file mode 100644 index f6e13658a..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/project/models_test.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest - -from fedlearner_webconsole.project.models import Project -from fedlearner_webconsole.proto import common_pb2, project_pb2 -from testing.common import BaseTestCase - - -class ProjectTest(BaseTestCase): - def test_get_namespace_fallback(self): - project = Project() - self.assertEqual(project.get_namespace(), 'default') - - project.set_config(project_pb2.Project( - variables=[ - common_pb2.Variable( - name='test_name', - value='test_value' - ) - ] - )) - self.assertEqual(project.get_namespace(), 'default') - - def test_get_namespace_from_variables(self): - project = Project() - project.set_config(project_pb2.Project( - variables=[ - common_pb2.Variable( - name='namespace', - value='haha' - ) - ] - )) - - self.assertEqual(project.get_namespace(), 'haha') - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/project/test.tar.gz b/web_console_v2/api/test/fedlearner_webconsole/project/test.tar.gz deleted file mode 100644 index 4558fd7fa6e6a17af8fd3df5f607dac5df5e55e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 384 zcmV-`0e}7^L0gUli!bR+l;4^ zzx2Dcqx6CM38A?<&z{86v8|5dF2l#c5^N&kyT`_KCC{w&P@w|)Q9>p1^Q_J505nE!eI e0{{R3000000000000000RO%DZdKRSsPyhf29@n4% diff --git a/web_console_v2/api/test/fedlearner_webconsole/rpc/client_test.py b/web_console_v2/api/test/fedlearner_webconsole/rpc/client_test.py deleted file mode 100644 index b2200e4ea..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/rpc/client_test.py +++ /dev/null @@ -1,146 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest -from unittest.mock import patch - -import grpc_testing -from grpc import StatusCode -from grpc.framework.foundation import logging_pool - -from testing.common import NoWebServerTestCase - -from fedlearner_webconsole.proto.service_pb2 import DESCRIPTOR -from fedlearner_webconsole.rpc.client import RpcClient -from fedlearner_webconsole.project.models import Project as ProjectModel -from fedlearner_webconsole.job.models import Job -from fedlearner_webconsole.proto.common_pb2 import (GrpcSpec, Status, - StatusCode as - FedLearnerStatusCode) -from fedlearner_webconsole.proto.project_pb2 import Project, Participant -from fedlearner_webconsole.proto.service_pb2 import (CheckConnectionRequest, - ProjAuthInfo) -from fedlearner_webconsole.proto.service_pb2 import CheckConnectionResponse, \ - CheckJobReadyResponse, CheckJobReadyRequest - -TARGET_SERVICE = DESCRIPTOR.services_by_name['WebConsoleV2Service'] - - -class RpcClientTest(NoWebServerTestCase): - _TEST_PROJECT_NAME = 'test-project' - _TEST_RECEIVER_NAME = 'test-receiver' - _TEST_URL = 'localhost:123' - _TEST_AUTHORITY = 'test-authority' - _X_HOST_HEADER_KEY = 'x-host' - _TEST_X_HOST = 'default.fedlearner.webconsole' - _TEST_SELF_DOMAIN_NAME = 'fl-test-self.com' - - @classmethod - def setUpClass(cls): - - grpc_spec = GrpcSpec( - authority=cls._TEST_AUTHORITY, - extra_headers={cls._X_HOST_HEADER_KEY: cls._TEST_X_HOST}) - participant = Participant(name=cls._TEST_RECEIVER_NAME, - domain_name='fl-test.com', - grpc_spec=grpc_spec) - project_config = Project(name=cls._TEST_PROJECT_NAME, - token='test-auth-token', - participants=[participant], - variables=[{ - 'name': 'EGRESS_URL', - 'value': cls._TEST_URL - }]) - job = Job(name='test-job') - - cls._participant = participant - cls._project_config = project_config - cls._project = ProjectModel(name=cls._TEST_PROJECT_NAME) - cls._project.set_config(project_config) - cls._job = job - - def setUp(self): - self._client_execution_thread_pool = logging_pool.pool(1) - - # Builds a testing channel - self._fake_channel = grpc_testing.channel( - DESCRIPTOR.services_by_name.values(), - grpc_testing.strict_real_time()) - self._build_channel_patcher = patch( - 'fedlearner_webconsole.rpc.client._build_channel') - self._mock_build_channel = self._build_channel_patcher.start() - self._mock_build_channel.return_value = self._fake_channel - self._client = RpcClient(self._project_config, self._participant) - - self._mock_build_channel.assert_called_once_with( - self._TEST_URL, self._TEST_AUTHORITY) - - def tearDown(self): - self._build_channel_patcher.stop() - self._client_execution_thread_pool.shutdown(wait=False) - - def test_check_connection(self): - call = self._client_execution_thread_pool.submit( - self._client.check_connection) - - invocation_metadata, request, rpc = self._fake_channel.take_unary_unary( - TARGET_SERVICE.methods_by_name['CheckConnection']) - - self.assertIn((self._X_HOST_HEADER_KEY, self._TEST_X_HOST), - invocation_metadata) - self.assertEqual( - request, - CheckConnectionRequest(auth_info=ProjAuthInfo( - project_name=self._project_config.name, - target_domain=self._participant.domain_name, - auth_token=self._project_config.token))) - - expected_status = Status(code=FedLearnerStatusCode.STATUS_SUCCESS, - msg='test') - rpc.terminate(response=CheckConnectionResponse(status=expected_status), - code=StatusCode.OK, - trailing_metadata=(), - details=None) - self.assertEqual(call.result().status, expected_status) - - def test_check_job_ready(self): - call = self._client_execution_thread_pool.submit( - self._client.check_job_ready, self._job.name) - - invocation_metadata, request, rpc = self._fake_channel.take_unary_unary( - TARGET_SERVICE.methods_by_name['CheckJobReady']) - - self.assertIn((self._X_HOST_HEADER_KEY, self._TEST_X_HOST), - invocation_metadata) - self.assertEqual( - request, - CheckJobReadyRequest( - job_name=self._job.name, - auth_info=ProjAuthInfo( - project_name=self._project_config.name, - target_domain=self._participant.domain_name, - auth_token=self._project_config.token))) - - expected_status = Status(code=FedLearnerStatusCode.STATUS_SUCCESS, - msg='test') - rpc.terminate(response=CheckJobReadyResponse(status=expected_status), - code=StatusCode.OK, - trailing_metadata=(), - details=None) - self.assertEqual(call.result().status, expected_status) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/scheduler/scheduler_func_test.py b/web_console_v2/api/test/fedlearner_webconsole/scheduler/scheduler_func_test.py deleted file mode 100644 index a9c0d1d6a..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/scheduler/scheduler_func_test.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import unittest -from testing.common import BaseTestCase -from fedlearner_webconsole.db import db -from fedlearner_webconsole.job.models import JobState, Job, JobType -from fedlearner_webconsole.scheduler.scheduler import _get_waiting_jobs - - -class SchedulerFuncTestCase(BaseTestCase): - def test_get_waiting_jobs(self): - db.session.add(Job(name='testtes', state=JobState.STOPPED, - job_type=JobType.DATA_JOIN, - workflow_id=1, - project_id=1)) - db.session.add(Job(name='testtest', state=JobState.WAITING, - job_type=JobType.DATA_JOIN, - workflow_id=1, - project_id=1)) - db.session.commit() - self.assertEqual(_get_waiting_jobs(), [2]) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/scheduler/scheduler_test.py b/web_console_v2/api/test/fedlearner_webconsole/scheduler/scheduler_test.py deleted file mode 100644 index 95caafac4..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/scheduler/scheduler_test.py +++ /dev/null @@ -1,240 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import os -import time -import copy -import unittest -import secrets -import logging -from http import HTTPStatus - -from envs import Envs -from testing.common import BaseTestCase -from fedlearner_webconsole.proto.common_pb2 import CreateJobFlag -from fedlearner_webconsole.job.models import Job - -from testing.common import multi_process_test - -ROLE = os.environ.get('TEST_ROLE', 'leader') - - -class LeaderConfig(object): - SQLALCHEMY_DATABASE_URI = f'sqlite:///{Envs.BASE_DIR}/leader.db' - SQLALCHEMY_TRACK_MODIFICATIONS = False - JWT_SECRET_KEY = secrets.token_urlsafe(64) - PROPAGATE_EXCEPTIONS = True - LOGGING_LEVEL = logging.DEBUG - GRPC_LISTEN_PORT = 3990 - START_COMPOSER = False - - -class FollowerConfig(object): - SQLALCHEMY_DATABASE_URI = f'sqlite:///{Envs.BASE_DIR}/follower.db' - SQLALCHEMY_TRACK_MODIFICATIONS = False - JWT_SECRET_KEY = secrets.token_urlsafe(64) - PROPAGATE_EXCEPTIONS = True - LOGGING_LEVEL = logging.DEBUG - GRPC_LISTEN_PORT = 4990 - START_COMPOSER = False - - -class WorkflowTest(BaseTestCase): - class Config(LeaderConfig): - pass - - @classmethod - def setUpClass(self): - os.environ['FEDLEARNER_WEBCONSOLE_POLLING_INTERVAL'] = '1' - - def setUp(self): - super().setUp() - self._wf_template = { - 'group_alias': - 'test-template', - 'job_definitions': [{ - 'is_federated': True, - 'name': - 'job1', - 'variables': [{ - 'name': 'x', - 'value': '1', - 'access_mode': 3 - }] - }, { - 'is_federated': True, - 'name': - 'job2', - 'variables': [{ - 'name': 'y', - 'value': '2', - 'access_mode': 2 - }] - }] - } - - def leader_test_workflow(self): - self.setup_project('leader', FollowerConfig.GRPC_LISTEN_PORT) - cwf_resp = self.post_helper('/api/v2/workflows', - data={ - 'name': 'test-workflow', - 'project_id': 1, - 'forkable': True, - 'config': self._wf_template, - }) - self.assertEqual(cwf_resp.status_code, HTTPStatus.CREATED) - - self._check_workflow_state(1, 'READY', 'INVALID', 'READY') - - # test update - patch_config = copy.deepcopy(self._wf_template) - patch_config['job_definitions'][1]['variables'][0]['value'] = '4' - resp = self.patch_helper('/api/v2/workflows/1', - data={ - 'config': patch_config, - }) - self.assertEqual(resp.status_code, HTTPStatus.OK) - - resp = self.get_helper('/api/v2/workflows/1') - self.assertEqual(resp.status_code, HTTPStatus.OK) - ret_wf = resp.json['data']['config'] - self.assertEqual(ret_wf['job_definitions'][1]['variables'][0]['value'], - '4') - - # test update remote - patch_config['job_definitions'][0]['variables'][0]['value'] = '5' - resp = self.patch_helper('/api/v2/workflows/1/peer_workflows', - data={ - 'config': patch_config, - }) - self.assertEqual(resp.status_code, HTTPStatus.OK) - - resp = self.get_helper('/api/v2/workflows/1/peer_workflows') - self.assertEqual(resp.status_code, HTTPStatus.OK) - ret_wf = list(resp.json['data'].values())[0]['config'] - self.assertEqual(ret_wf['job_definitions'][0]['variables'][0]['value'], - '5') - - # test fork - cwf_resp = self.post_helper('/api/v2/workflows', - data={ - 'name': - 'test-workflow2', - 'project_id': - 1, - 'forkable': - True, - 'forked_from': - 1, - 'create_job_flags': [ - CreateJobFlag.REUSE, - CreateJobFlag.NEW, - ], - 'peer_create_job_flags': [ - CreateJobFlag.NEW, - CreateJobFlag.REUSE, - ], - 'config': - self._wf_template, - 'fork_proposal_config': { - 'job_definitions': [{ - 'variables': [{ - 'name': 'x', - 'value': '2' - }] - }, { - 'variables': [{ - 'name': 'y', - 'value': '3' - }] - }] - } - }) - - self.assertEqual(cwf_resp.status_code, HTTPStatus.CREATED) - self._check_workflow_state(2, 'READY', 'INVALID', 'READY') - - resp = self.patch_helper('/api/v2/workflows/2', - data={ - 'state': 'INVALID', - }) - self._check_workflow_state(2, 'INVALID', 'INVALID', 'READY') - - def follower_test_workflow(self): - self.setup_project('follower', LeaderConfig.GRPC_LISTEN_PORT) - self._check_workflow_state(1, 'NEW', 'READY', 'PARTICIPANT_PREPARE') - - self.put_helper('/api/v2/workflows/1', - data={ - 'forkable': True, - 'config': self._wf_template, - }) - self._check_workflow_state(1, 'READY', 'INVALID', 'READY') - self.assertEqual(len(Job.query.filter(Job.workflow_id == 1).all()), 2) - - # test fork - json = self._check_workflow_state(2, 'READY', 'INVALID', 'READY') - self.assertEqual(len(Job.query.all()), 3) - self.assertEqual(json['data']['create_job_flags'], [ - CreateJobFlag.NEW, - CreateJobFlag.REUSE, - ]) - self.assertEqual(json['data']['peer_create_job_flags'], [ - CreateJobFlag.REUSE, - CreateJobFlag.NEW, - ]) - jobs = json['data']['config']['job_definitions'] - self.assertEqual(jobs[0]['variables'][0]['value'], '2') - self.assertEqual(jobs[1]['variables'][0]['value'], '2') - - resp = self.patch_helper('/api/v2/workflows/2', - data={ - 'state': 'INVALID', - }) - self._check_workflow_state(2, 'INVALID', 'INVALID', 'READY') - - def _check_workflow_state(self, - workflow_id, - state, - target_state, - transaction_state, - max_retries=10): - cnt = 0 - while True: - time.sleep(1) - cnt = cnt + 1 - if cnt > max_retries: - self.fail(f'workflow [{workflow_id}] state is unexpected') - resp = self.get_helper(f'/api/v2/workflows/{workflow_id}') - if resp.status_code != HTTPStatus.OK: - continue - if resp.json['data']['state'] == state and \ - resp.json['data']['target_state'] == target_state and \ - resp.json['data']['transaction_state'] == transaction_state: - return resp.json - - -if __name__ == '__main__': - multi_process_test([{ - 'class': WorkflowTest, - 'method': 'leader_test_workflow', - 'config': LeaderConfig - }, { - 'class': WorkflowTest, - 'method': 'follower_test_workflow', - 'config': FollowerConfig - }]) - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/scheduler/workflow_commit_test.py b/web_console_v2/api/test/fedlearner_webconsole/scheduler/workflow_commit_test.py deleted file mode 100644 index b94468c09..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/scheduler/workflow_commit_test.py +++ /dev/null @@ -1,139 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import time -import unittest -from google.protobuf.json_format import ParseDict -from unittest.mock import patch -from testing.common import BaseTestCase -from fedlearner_webconsole.db import db -from fedlearner_webconsole.job.models import JobState -from fedlearner_webconsole.project.models import Project -from fedlearner_webconsole.workflow.models import Workflow, WorkflowState -from fedlearner_webconsole.scheduler.transaction import TransactionState -from fedlearner_webconsole.scheduler.scheduler import \ - scheduler -from fedlearner_webconsole.proto import project_pb2 -from workflow_template_test import make_workflow_template - - -class WorkflowsCommitTest(BaseTestCase): - class Config(BaseTestCase.Config): - START_GRPC_SERVER = False - START_SCHEDULER = True - - @classmethod - def setUpClass(self): - os.environ['FEDLEARNER_WEBCONSOLE_POLLING_INTERVAL'] = '1' - - def setUp(self): - super().setUp() - # Inserts project - config = { - 'participants': [{ - 'name': 'party_leader', - 'url': '127.0.0.1:5000', - 'domain_name': 'fl-leader.com', - 'grpc_spec': { - 'authority': 'fl-leader.com' - } - }], - 'variables': [{ - 'name': 'namespace', - 'value': 'leader' - }, { - 'name': 'basic_envs', - 'value': '{}' - }, { - 'name': 'storage_root_dir', - 'value': '/' - }, { - 'name': 'EGRESS_URL', - 'value': '127.0.0.1:1991' - }] - } - project = Project( - name='test', - config=ParseDict(config, - project_pb2.Project()).SerializeToString()) - db.session.add(project) - db.session.commit() - - @staticmethod - def _wait_until(cond, retry_times: int = 5): - for _ in range(retry_times): - time.sleep(5) - db.session.expire_all() - if cond(): - return - - def test_workflow_commit(self): - # test the committing stage for workflow creating - workflow_def = make_workflow_template() - workflow = Workflow( - id=20, - name='job_test1', - comment='这是一个测试工作流', - config=workflow_def.SerializeToString(), - project_id=1, - forkable=True, - state=WorkflowState.NEW, - target_state=WorkflowState.READY, - transaction_state=TransactionState.PARTICIPANT_COMMITTING) - db.session.add(workflow) - db.session.commit() - scheduler.wakeup(20) - self._wait_until( - lambda: Workflow.query.get(20).state == WorkflowState.READY) - workflow = Workflow.query.get(20) - self.assertEqual(len(workflow.get_jobs()), 2) - self.assertEqual(workflow.get_jobs()[0].state, JobState.NEW) - self.assertEqual(workflow.get_jobs()[1].state, JobState.NEW) - - # test the committing stage for workflow running - workflow.target_state = WorkflowState.RUNNING - workflow.transaction_state = TransactionState.PARTICIPANT_COMMITTING - db.session.commit() - scheduler.wakeup(20) - self._wait_until( - lambda: Workflow.query.get(20).state == WorkflowState.RUNNING) - workflow = Workflow.query.get(20) - self._wait_until( - lambda: workflow.get_jobs()[0].state == JobState.STARTED) - self.assertEqual(workflow.get_jobs()[1].state, JobState.WAITING) - workflow = Workflow.query.get(20) - for job in workflow.owned_jobs: - job.state = JobState.COMPLETED - self.assertEqual(workflow.to_dict()['state'], 'COMPLETED') - workflow.get_jobs()[0].state = JobState.FAILED - self.assertEqual(workflow.to_dict()['state'], 'FAILED') - # test the committing stage for workflow stopping - workflow.target_state = WorkflowState.STOPPED - workflow.transaction_state = TransactionState.PARTICIPANT_COMMITTING - for job in workflow.owned_jobs: - job.state = JobState.STARTED - db.session.commit() - scheduler.wakeup(20) - self._wait_until( - lambda: Workflow.query.get(20).state == WorkflowState.STOPPED) - workflow = Workflow.query.get(20) - self._wait_until( - lambda: workflow.get_jobs()[0].state == JobState.STOPPED) - self.assertEqual(workflow.get_jobs()[1].state, JobState.STOPPED) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/scheduler/workflow_template_test.py b/web_console_v2/api/test/fedlearner_webconsole/scheduler/workflow_template_test.py deleted file mode 100644 index 7b041d3a7..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/scheduler/workflow_template_test.py +++ /dev/null @@ -1,738 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -from google.protobuf.json_format import MessageToDict -from fedlearner_webconsole.proto.workflow_definition_pb2 import ( - WorkflowDefinition, JobDefinition, JobDependency -) -from fedlearner_webconsole.proto.common_pb2 import ( - Variable -) - - -def make_workflow_template(): - workflow = WorkflowDefinition( - group_alias='test_template', - is_left=True, - variables=[ - Variable( - name='image_version', - value='v1.5-rc3', - access_mode=Variable.PEER_READABLE), - Variable( - name='num_partitions', - value='4', - access_mode=Variable.PEER_WRITABLE), - ], - job_definitions=[ - JobDefinition( - name='raw_data_job', - job_type=JobDefinition.RAW_DATA, - is_federated=False, - variables=[ - Variable( - name='input_dir', - value='/app/deploy/integrated_test/tfrecord_raw_data', - access_mode=Variable.PRIVATE), - Variable( - name='file_wildcard', - value='*.rd', - access_mode=Variable.PRIVATE), - Variable( - name='batch_size', - value='1024', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='input_format', - value='TF_RECORD', - access_mode=Variable.PRIVATE), - Variable( - name='output_format', - value='TF_RECORD', - access_mode=Variable.PRIVATE), - Variable( - name='master_cpu', - value='2', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='master_mem', - value='3Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_cpu', - value='2', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='3Gi', - access_mode=Variable.PEER_WRITABLE), - ], - yaml_template='''{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.raw_data_job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "cleanPodPolicy": "All", - "flReplicaSpecs": { - "Master": { - "pair": false, - "replicas": 1, - "template": { - "spec": { - "containers": [ - { - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_master.sh" - ], - "env": [ - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - ${system.basic_envs}, - ${project.variables.basic_envs}, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw_data_job.name}" - }, - { - "name": "DATA_PORTAL_NAME", - "value": "${workflow.jobs.raw_data_job.name}" - }, - { - "name": "OUTPUT_PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "INPUT_BASE_DIR", - "value": "${workflow.jobs.raw_data_job.variables.input_dir}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/raw_data/${workflow.jobs.raw_data_job.name}" - }, - { - "name": "RAW_DATA_PUBLISH_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw_data_job.name}" - }, - { - "name": "DATA_PORTAL_TYPE", - "value": "Streaming" - }, - { - "name": "FILE_WILDCARD", - "value": "${workflow.jobs.raw_data_job.variables.file_wildcard}" - } - ], - "image": "hub.docker.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.raw_data_job.variables.master_cpu}", - "memory": "${workflow.jobs.raw_data_job.variables.master_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.raw_data_job.variables.master_cpu}", - "memory": "${workflow.jobs.raw_data_job.variables.master_mem}" - } - }, - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ] - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "restartPolicy": "Never", - "volumes": [ - { - "name": "data", - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - } - } - ] - } - } - }, - "Worker": { - "pair": false, - "replicas": ${workflow.variables.num_partitions}, - "template": { - "metadata": { - "creationTimestamp": null - }, - "spec": { - "containers": [ - { - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_worker.sh" - ], - "env": [ - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - ${system.basic_envs}, - ${project.variables.basic_envs}, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "limits.memory" - } - } - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw_data_job.name}" - }, - { - "name": "BATCH_SIZE", - "value": "${workflow.jobs.raw_data_job.variables.batch_size}" - }, - { - "name": "INPUT_DATA_FORMAT", - "value": "${workflow.jobs.raw_data_job.variables.input_format}" - }, - { - "name": "COMPRESSED_TYPE" - }, - { - "name": "OUTPUT_DATA_FORMAT", - "value": "${workflow.jobs.raw_data_job.variables.output_format}" - } - ], - "image": "hub.docker.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "resources": { - "limits": { - "cpu": "${workflow.jobs.raw_data_job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw_data_job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.raw_data_job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw_data_job.variables.worker_mem}" - } - }, - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ] - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "restartPolicy": "Never", - "volumes": [ - { - "name": "data", - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - } - } - ] - } - } - } - }, - "peerSpecs": { - "Leader": { - "peerURL": "" - } - }, - "role": "Follower" - } -} - ''' - ), - JobDefinition( - name='data_join_job', - job_type=JobDefinition.DATA_JOIN, - is_federated=True, - variables=[ - Variable( - name='master_cpu', - value='2', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='master_mem', - value='3Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_cpu', - value='2', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='3Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='role', - value='Follower', - access_mode=Variable.PEER_WRITABLE), - ], - dependencies=[ - JobDependency(source='raw_data_job') - ], - yaml_template=''' -{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.data_join_job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "cleanPodPolicy": "All", - "flReplicaSpecs": { - "Master": { - "pair": true, - "replicas": 1, - "template": { - "metadata": { - "creationTimestamp": null - }, - "spec": { - "containers": [ - { - "args": [ - "/app/deploy/scripts/data_join/run_data_join_master.sh" - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "env": [ - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - ${system.basic_envs}, - ${project.variables.basic_envs}, - { - "name": "ROLE", - "value": "${workflow.jobs.data_join_job.variables.role}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data_join_job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data_join_job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "limits.memory" - } - } - }, - { - "name": "BATCH_MODE", - "value": "--batch_mode" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.jobs.raw_data_job.variables.num_partitions}" - }, - { - "name": "START_TIME", - "value": "0" - }, - { - "name": "END_TIME", - "value": "999999999999" - }, - { - "name": "NEGATIVE_SAMPLING_RATE", - "value": "1.0" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.data_join_job.name}" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.data_join_job.name}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.jobs.raw_data_job.variables.num_partitions}" - } - ], - "image": "hub.docker.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.data_join_job.variables.master_cpu}", - "memory": "${workflow.jobs.data_join_job.variables.master_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.data_join_job.variables.master_cpu}", - "memory": "${workflow.jobs.data_join_job.variables.master_mem}" - } - }, - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ] - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "restartPolicy": "Never", - "volumes": [ - { - "name": "data", - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - } - } - ] - } - } - }, - "Worker": { - "pair": true, - "replicas": ${workflow.jobs.raw_data_job.variables.num_partitions}, - "template": { - "metadata": { - "creationTimestamp": null - }, - "spec": { - "containers": [ - { - "args": [ - "/app/deploy/scripts/data_join/run_data_join_worker.sh" - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "env": [ - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - ${system.basic_envs}, - ${project.variables.basic_envs}, - { - "name": "ROLE", - "value": "${workflow.jobs.data_join_job.variables.role}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data_join_job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data_join_job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "divisor": "0", - "resource": "limits.memory" - } - } - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.jobs.raw_data_job.variables.num_partitions}" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.data_join_job.name}" - }, - { - "name": "DATA_BLOCK_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "DATA_BLOCK_DUMP_THRESHOLD", - "value": "65536" - }, - { - "name": "EXAMPLE_ID_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "EXAMPLE_ID_DUMP_THRESHOLD", - "value": "65536" - }, - { - "name": "EXAMPLE_ID_BATCH_SIZE", - "value": "4096" - }, - { - "name": "MAX_FLYING_EXAMPLE_ID", - "value": "307152" - }, - { - "name": "MIN_MATCHING_WINDOW", - "value": "2048" - }, - { - "name": "MAX_MATCHING_WINDOW", - "value": "8192" - }, - { - "name": "RAW_DATA_ITER", - "value": "${workflow.jobs.raw_data_job.variables.output_format}" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw_data_job.name}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.jobs.raw_data_job.variables.num_partitions}" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:5b499dd", - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.data_join_job.variables.master_cpu}", - "memory": "${workflow.jobs.data_join_job.variables.master_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.data_join_job.variables.master_cpu}", - "memory": "${workflow.jobs.data_join_job.variables.master_mem}" - } - }, - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ] - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "restartPolicy": "Never", - "volumes": [ - { - "name": "data", - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - } - } - ] - } - } - } - }, - "peerSpecs": { - "Follower": { - "authority": "external.name", - "extraHeaders": { - "x-host": "leader.flapp.operator" - }, - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - } - }, - "role": "Leader" - } -} - ''' - ) - ]) - - return workflow -import json -if __name__ == '__main__': - print(json.dumps(MessageToDict( - make_workflow_template(), - preserving_proto_field_name=True, - including_default_value_fields=True))) diff --git a/web_console_v2/api/test/fedlearner_webconsole/setting/apis_test.py b/web_console_v2/api/test/fedlearner_webconsole/setting/apis_test.py deleted file mode 100644 index 8b1ce5654..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/setting/apis_test.py +++ /dev/null @@ -1,135 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import logging -import unittest -from http import HTTPStatus -from types import SimpleNamespace -from unittest.mock import patch, MagicMock - -from testing.common import BaseTestCase -from fedlearner_webconsole.setting.apis import _POD_NAMESPACE - - -class SettingsApiTest(BaseTestCase): - class Config(BaseTestCase.Config): - START_GRPC_SERVER = False - START_SCHEDULER = False - - def setUp(self): - super().setUp() - self._deployment = SimpleNamespace( - **{ - 'metadata': - SimpleNamespace(**{ - 'name': 'fedlearner-web-console-v2', - 'namespace': 'testns' - }), - 'spec': - SimpleNamespace( - **{ - 'template': - SimpleNamespace( - **{ - 'spec': - SimpleNamespace( - **{ - 'containers': [ - SimpleNamespace( - **{'image': 'fedlearner:test'}) - ] - }) - }) - }) - }) - self._system_pods = SimpleNamespace( - **{ - 'items': [ - SimpleNamespace( - **{ - 'metadata': - SimpleNamespace( - **{'name': 'fake-fedlearner-web-console-v2-1'}) - }), - SimpleNamespace( - **{ - 'metadata': - SimpleNamespace( - **{'name': 'fake-fedlearner-web-console-v2-2'}) - }), - ] - }) - self._system_pod_log = 'log1\nlog2' - self._mock_k8s_client = MagicMock() - self._mock_k8s_client.get_deployment = MagicMock( - return_value=self._deployment) - self._mock_k8s_client.get_pods = MagicMock( - return_value=self._system_pods) - self._mock_k8s_client.get_pod_log = MagicMock( - return_value=self._system_pod_log) - self.signin_as_admin() - - @patch('fedlearner_webconsole.setting.apis._POD_NAMESPACE', 'testns') - def test_get_settings(self): - with patch('fedlearner_webconsole.setting.apis.k8s_client', - self._mock_k8s_client): - response_data = self.get_response_data( - self.get_helper('/api/v2/settings')) - self.assertEqual(response_data, - {'webconsole_image': 'fedlearner:test'}) - self._mock_k8s_client.get_deployment.assert_called_with( - name='fedlearner-web-console-v2', namespace='testns') - - def test_update_image(self): - self._mock_k8s_client.create_or_update_deployment = MagicMock() - with patch('fedlearner_webconsole.setting.apis.k8s_client', - self._mock_k8s_client): - resp = self.patch_helper( - '/api/v2/settings', - data={'webconsole_image': 'test-new-image'}) - self.assertEqual(resp.status_code, HTTPStatus.OK) - _, kwargs = self._mock_k8s_client.create_or_update_deployment.call_args - self.assertEqual(kwargs['spec'].template.spec.containers[0].image, - 'test-new-image') - self.assertEqual(kwargs['name'], self._deployment.metadata.name) - self.assertEqual(kwargs['namespace'], - self._deployment.metadata.namespace) - - def test_get_system_pods(self): - with patch('fedlearner_webconsole.setting.apis.k8s_client', - self._mock_k8s_client): - resp = self.get_helper('/api/v2/system_pods/name') - self.assertEqual(resp.status_code, HTTPStatus.OK) - self.assertEqual(self.get_response_data(resp), [ - 'fake-fedlearner-web-console-v2-1', - 'fake-fedlearner-web-console-v2-2' - ]) - - def test_get_system_pods_log(self): - fake_pod_name = 'fake-fedlearner-web-console-v2-1' - with patch('fedlearner_webconsole.setting.apis.k8s_client', - self._mock_k8s_client): - resp = self.get_helper( - '/api/v2/system_pods/{}/logs?tail_lines={}'.format( - fake_pod_name, 100)) - self.assertEqual(resp.status_code, HTTPStatus.OK) - self.assertEqual(self.get_response_data(resp), ['log1', 'log2']) - self._mock_k8s_client.get_pod_log.assert_called_with( - name=fake_pod_name, namespace=_POD_NAMESPACE, tail_lines=100) - - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG) - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/sparkapp/__init__.py b/web_console_v2/api/test/fedlearner_webconsole/sparkapp/__init__.py deleted file mode 100644 index 3e28547fe..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/sparkapp/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 diff --git a/web_console_v2/api/test/fedlearner_webconsole/sparkapp/apis_test.py b/web_console_v2/api/test/fedlearner_webconsole/sparkapp/apis_test.py deleted file mode 100644 index 711425562..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/sparkapp/apis_test.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import unittest -import base64 -from unittest import mock - -from unittest.mock import MagicMock, patch -from os.path import dirname -from fedlearner_webconsole.sparkapp.schema import SparkAppInfo - -from testing.common import BaseTestCase -from envs import Envs - -BASE_DIR = Envs.BASE_DIR - - -class SparkAppApiTest(BaseTestCase): - def setUp(self): - super().setUp() - self._upload_path = os.path.join(BASE_DIR, 'test') - self._upload_path_patcher = patch( - 'fedlearner_webconsole.sparkapp.service.UPLOAD_PATH', - self._upload_path) - self._upload_path_patcher.start() - - def tearDown(self): - self._upload_path_patcher.stop() - super().tearDown() - - @patch( - 'fedlearner_webconsole.sparkapp.service.SparkAppService.submit_sparkapp' - ) - def test_submit_sparkapp(self, mock_submit_sparkapp: MagicMock): - mock_submit_sparkapp.return_value = SparkAppInfo() - tarball_file_path = os.path.join( - BASE_DIR, 'test/fedlearner_webconsole/test_data/sparkapp.tar') - with open(tarball_file_path, 'rb') as f: - files_bin = f.read() - - self.post_helper( - '/api/v2/sparkapps', { - 'name': 'fl-transformer-yaml', - 'files': base64.b64encode(files_bin).decode(), - 'image_url': 'dockerhub.com', - 'driver_config': { - 'cores': 1, - 'memory': '200m', - 'core_limit': '4000m', - }, - 'executor_config': { - 'cores': 1, - 'memory': '200m', - 'instances': 5, - }, - 'command': ['data.csv', 'data.rd'], - 'main_application': '${prefix}/convertor.py' - }).json - - mock_submit_sparkapp.assert_called_once() - _, kwargs = mock_submit_sparkapp.call_args - self.assertTrue(kwargs['config'].name, 'fl-transformer-yaml') - - @patch( - 'fedlearner_webconsole.sparkapp.service.SparkAppService.get_sparkapp_info' - ) - def test_get_sparkapp_info(self, mock_get_sparkapp: MagicMock): - mock_get_sparkapp.return_value = SparkAppInfo() - - self.get_helper('/api/v2/sparkapps/fl-transformer-yaml').json - - mock_get_sparkapp.assert_called_once_with('fl-transformer-yaml') - - @patch( - 'fedlearner_webconsole.sparkapp.service.SparkAppService.delete_sparkapp' - ) - def test_delete_sparkapp(self, mock_delete_sparkapp: MagicMock): - mock_delete_sparkapp.return_value = SparkAppInfo() - resp = self.delete_helper('/api/v2/sparkapps/fl-transformer-yaml').json - mock_delete_sparkapp.assert_called_once_with('fl-transformer-yaml') - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/sparkapp/schema_test.py b/web_console_v2/api/test/fedlearner_webconsole/sparkapp/schema_test.py deleted file mode 100644 index 21a0f35ca..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/sparkapp/schema_test.py +++ /dev/null @@ -1,153 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest - -from fedlearner_webconsole.sparkapp.schema import SparkAppConfig, SparkAppInfo, SparkPodConfig - - -class SparkAppSchemaTest(unittest.TestCase): - def test_spark_pod_config(self): - inputs = { - 'cores': 1, - 'memory': '200m', - 'core_limit': '4000m', - 'envs': { - 'HELLO': '1' - } - } - spark_pod_config: SparkPodConfig = SparkPodConfig.from_dict(inputs) - config = spark_pod_config.build_config() - self.assertDictEqual( - config, { - 'cores': 1, - 'memory': '200m', - 'coreLimit': '4000m', - 'env': [{ - 'name': 'HELLO', - 'value': '1' - }] - }) - - def test_sparkapp_config(self): - inputs = { - 'name': 'test', - 'files': bytes(100), - 'image_url': 'dockerhub.com', - 'driver_config': { - 'cores': 1, - 'memory': '200m', - 'core_limit': '4000m', - 'envs': { - 'HELLO': '1' - } - }, - 'executor_config': { - 'cores': 1, - 'memory': '200m', - 'instances': 5, - 'envs': { - 'HELLO': '1' - } - }, - 'command': ['hhh', 'another'], - 'main_application': '${prefix}/main.py' - } - sparkapp_config: SparkAppConfig = SparkAppConfig.from_dict(inputs) - config = sparkapp_config.build_config('./test') - self.assertEqual(config['spec']['mainApplicationFile'], - './test/main.py') - self.assertNotIn('instances', config['spec']['driver']) - - def test_sparkapp_info(self): - resp = { - 'apiVersion': 'sparkoperator.k8s.io/v1beta2', - 'kind': 'SparkApplication', - 'metadata': { - 'creationTimestamp': '2021-05-18T08:59:16Z', - 'generation': 1, - 'name': 'fl-transformer-yaml', - 'namespace': 'fedlearner', - 'resourceVersion': '432649442', - 'selfLink': - '/apis/sparkoperator.k8s.io/v1beta2/namespaces/fedlearner/sparkapplications/fl-transformer-yaml', - 'uid': '52d66d27-b7b7-11eb-b9df-b8599fdb0aac' - }, - 'spec': { - 'arguments': ['data.csv', 'data_tfrecords/'], - 'driver': { - 'coreLimit': '4000m', - 'cores': 1, - 'labels': { - 'version': '3.0.0' - }, - 'memory': '512m', - 'serviceAccount': 'spark', - }, - 'dynamicAllocation': { - 'enabled': False - }, - 'executor': { - 'cores': 1, - 'instances': 1, - 'labels': { - 'version': '3.0.0' - }, - 'memory': '512m', - }, - 'image': 'dockerhub.com', - 'imagePullPolicy': 'Always', - 'mainApplicationFile': 'transformer.py', - 'mode': 'cluster', - 'pythonVersion': '3', - 'restartPolicy': { - 'type': 'Never' - }, - 'sparkConf': { - 'spark.shuffle.service.enabled': 'false' - }, - 'sparkVersion': '3.0.0', - 'type': 'Python', - }, - 'status': { - 'applicationState': { - 'state': 'COMPLETED' - }, - 'driverInfo': { - 'podName': 'fl-transformer-yaml-driver', - 'webUIAddress': '11.249.131.12:4040', - 'webUIPort': 4040, - 'webUIServiceName': 'fl-transformer-yaml-ui-svc' - }, - 'executionAttempts': 1, - 'executorState': { - 'fl-transformer-yaml-bdc15979a314310b-exec-1': 'PENDING', - 'fl-transformer-yaml-bdc15979a314310b-exec-2': 'COMPLETED' - }, - 'lastSubmissionAttemptTime': '2021-05-18T10:31:13Z', - 'sparkApplicationId': 'spark-a380bfd520164d828a334bcb3a6404f9', - 'submissionAttempts': 1, - 'submissionID': '5bc7e2e7-cc0f-420c-8bc7-138b651a1dde', - 'terminationTime': '2021-05-18T10:32:08Z' - } - } - - sparkapp_info = SparkAppInfo.from_k8s_resp(resp) - self.assertTrue(sparkapp_info.namespace, 'fedlearner') - self.assertTrue(sparkapp_info.name, 'fl-transformer-yaml') - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/sparkapp/service_test.py b/web_console_v2/api/test/fedlearner_webconsole/sparkapp/service_test.py deleted file mode 100644 index d59b57520..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/sparkapp/service_test.py +++ /dev/null @@ -1,296 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import shutil -import tempfile -import unittest - -from unittest.mock import MagicMock, patch -from os.path import dirname - -from envs import Envs -from fedlearner_webconsole.sparkapp.schema import SparkAppConfig -from fedlearner_webconsole.sparkapp.service import SparkAppService - -BASE_DIR = Envs.BASE_DIR - - -class SparkAppServiceTest(unittest.TestCase): - def setUp(self) -> None: - super().setUp() - self._upload_path = os.path.join(BASE_DIR, 'test-spark') - os.makedirs(self._upload_path) - self._patch_upload_path = patch( - 'fedlearner_webconsole.sparkapp.service.UPLOAD_PATH', - self._upload_path) - self._patch_upload_path.start() - self._sparkapp_service = SparkAppService() - - def tearDown(self) -> None: - self._patch_upload_path.stop() - shutil.rmtree(self._upload_path) - return super().tearDown() - - def _get_tar_file_path(self) -> str: - return os.path.join( - BASE_DIR, 'test/fedlearner_webconsole/test_data/sparkapp.tar') - - def test_get_sparkapp_upload_path(self): - existable, sparkapp_path = self._sparkapp_service._get_sparkapp_upload_path( - 'test') - self.assertFalse(existable) - - os.makedirs(sparkapp_path) - existable, _ = self._sparkapp_service._get_sparkapp_upload_path('test') - self.assertTrue(existable) - - def test_copy_files_to_target_filesystem(self): - _, sparkapp_path = self._sparkapp_service._get_sparkapp_upload_path( - 'test') - self._sparkapp_service._clear_and_make_an_empty_dir(sparkapp_path) - files_path = self._get_tar_file_path() - with tempfile.TemporaryDirectory() as temp_dir: - file_name = files_path.rsplit('/', 1)[-1] - temp_file_path = os.path.join(temp_dir, file_name) - shutil.copy(files_path, temp_file_path) - self._sparkapp_service._copy_files_to_target_filesystem( - source_filesystem_path=temp_file_path, - target_filesystem_path=sparkapp_path) - - self.assertTrue( - os.path.exists(os.path.join(sparkapp_path, 'convertor.py'))) - - @patch( - 'fedlearner_webconsole.utils.k8s_client.k8s_client.create_sparkapplication' - ) - def test_submit_sparkapp(self, mock_create_sparkapp: MagicMock): - mock_create_sparkapp.return_value = { - 'apiVersion': 'sparkoperator.k8s.io/v1beta2', - 'kind': 'SparkApplication', - 'metadata': { - 'creationTimestamp': '2021-05-18T08:59:16Z', - 'generation': 1, - 'name': 'fl-transformer-yaml', - 'namespace': 'fedlearner', - 'resourceVersion': '432649442', - 'selfLink': - '/apis/sparkoperator.k8s.io/v1beta2/namespaces/fedlearner/sparkapplications/fl-transformer-yaml', - 'uid': '52d66d27-b7b7-11eb-b9df-b8599fdb0aac' - }, - 'spec': { - 'arguments': ['data.csv', 'data_tfrecords/'], - 'driver': { - 'coreLimit': '4000m', - 'cores': 1, - 'labels': { - 'version': '3.0.0' - }, - 'memory': '512m', - 'serviceAccount': 'spark', - }, - 'dynamicAllocation': { - 'enabled': False - }, - 'executor': { - 'cores': 1, - 'instances': 1, - 'labels': { - 'version': '3.0.0' - }, - 'memory': '512m', - }, - 'image': 'dockerhub.com', - 'imagePullPolicy': 'Always', - 'mainApplicationFile': 'transformer.py', - 'mode': 'cluster', - 'pythonVersion': '3', - 'restartPolicy': { - 'type': 'Never' - }, - 'sparkConf': { - 'spark.shuffle.service.enabled': 'false' - }, - 'sparkVersion': '3.0.0', - 'type': 'Python', - }, - 'status': { - 'applicationState': { - 'state': 'COMPLETED' - }, - 'driverInfo': { - 'podName': 'fl-transformer-yaml-driver', - 'webUIAddress': '11.249.131.12:4040', - 'webUIPort': 4040, - 'webUIServiceName': 'fl-transformer-yaml-ui-svc' - }, - 'executionAttempts': 1, - 'executorState': { - 'fl-transformer-yaml-bdc15979a314310b-exec-1': 'PENDING', - 'fl-transformer-yaml-bdc15979a314310b-exec-2': 'COMPLETED' - }, - 'lastSubmissionAttemptTime': '2021-05-18T10:31:13Z', - 'sparkApplicationId': 'spark-a380bfd520164d828a334bcb3a6404f9', - 'submissionAttempts': 1, - 'submissionID': '5bc7e2e7-cc0f-420c-8bc7-138b651a1dde', - 'terminationTime': '2021-05-18T10:32:08Z' - } - } - - tarball_file_path = os.path.join( - BASE_DIR, 'test/fedlearner_webconsole/test_data/sparkapp.tar') - with open(tarball_file_path, 'rb') as f: - files_bin = f.read() - - inputs = { - 'name': 'fl-transformer-yaml', - 'files': files_bin, - 'image_url': 'dockerhub.com', - 'driver_config': { - 'cores': 1, - 'memory': '200m', - 'coreLimit': '4000m', - }, - 'executor_config': { - 'cores': 1, - 'memory': '200m', - 'instances': 5, - }, - 'command': ['data.csv', 'data.rd'], - 'main_application': '${prefix}/convertor.py' - } - config = SparkAppConfig.from_dict(inputs) - resp = self._sparkapp_service.submit_sparkapp(config) - - self.assertTrue( - os.path.exists( - os.path.join(self._upload_path, 'sparkapp', - 'fl-transformer-yaml', 'convertor.py'))) - mock_create_sparkapp.assert_called_once() - self.assertTrue(resp.namespace, 'fedlearner') - - @patch( - 'fedlearner_webconsole.utils.k8s_client.k8s_client.get_sparkapplication' - ) - def test_get_sparkapp_info(self, mock_get_sparkapp: MagicMock): - mock_get_sparkapp.return_value = { - 'apiVersion': 'sparkoperator.k8s.io/v1beta2', - 'kind': 'SparkApplication', - 'metadata': { - 'creationTimestamp': '2021-05-18T08:59:16Z', - 'generation': 1, - 'name': 'fl-transformer-yaml', - 'namespace': 'fedlearner', - 'resourceVersion': '432649442', - 'selfLink': - '/apis/sparkoperator.k8s.io/v1beta2/namespaces/fedlearner/sparkapplications/fl-transformer-yaml', - 'uid': '52d66d27-b7b7-11eb-b9df-b8599fdb0aac' - }, - 'spec': { - 'arguments': ['data.csv', 'data_tfrecords/'], - 'driver': { - 'coreLimit': '4000m', - 'cores': 1, - 'labels': { - 'version': '3.0.0' - }, - 'memory': '512m', - 'serviceAccount': 'spark', - }, - 'dynamicAllocation': { - 'enabled': False - }, - 'executor': { - 'cores': 1, - 'instances': 1, - 'labels': { - 'version': '3.0.0' - }, - 'memory': '512m', - }, - 'image': 'dockerhub.com', - 'imagePullPolicy': 'Always', - 'mainApplicationFile': 'transformer.py', - 'mode': 'cluster', - 'pythonVersion': '3', - 'restartPolicy': { - 'type': 'Never' - }, - 'sparkConf': { - 'spark.shuffle.service.enabled': 'false' - }, - 'sparkVersion': '3.0.0', - 'type': 'Python', - }, - 'status': { - 'applicationState': { - 'state': 'COMPLETED' - }, - 'driverInfo': { - 'podName': 'fl-transformer-yaml-driver', - 'webUIAddress': '11.249.131.12:4040', - 'webUIPort': 4040, - 'webUIServiceName': 'fl-transformer-yaml-ui-svc' - }, - 'executionAttempts': 1, - 'executorState': { - 'fl-transformer-yaml-bdc15979a314310b-exec-1': 'PENDING', - 'fl-transformer-yaml-bdc15979a314310b-exec-2': 'COMPLETED' - }, - 'lastSubmissionAttemptTime': '2021-05-18T10:31:13Z', - 'sparkApplicationId': 'spark-a380bfd520164d828a334bcb3a6404f9', - 'submissionAttempts': 1, - 'submissionID': '5bc7e2e7-cc0f-420c-8bc7-138b651a1dde', - 'terminationTime': '2021-05-18T10:32:08Z' - } - } - - resp = self._sparkapp_service.get_sparkapp_info('fl-transformer-yaml') - - mock_get_sparkapp.assert_called_once() - self.assertTrue(resp.namespace, 'fedlearner') - - @patch( - 'fedlearner_webconsole.sparkapp.service.SparkAppService._get_sparkapp_upload_path' - ) - @patch('fedlearner_webconsole.utils.file_manager.FileManager.remove') - @patch( - 'fedlearner_webconsole.utils.k8s_client.k8s_client.delete_sparkapplication' - ) - def test_delete_sparkapp(self, mock_delete_sparkapp: MagicMock, - mock_file_mananger_remove: MagicMock, - mock_upload_path: MagicMock): - mock_delete_sparkapp.return_value = { - 'kind': 'Status', - 'apiVersion': 'v1', - 'metadata': {}, - 'status': 'Success', - 'details': { - 'name': 'fl-transformer-yaml', - 'group': 'sparkoperator.k8s.io', - 'kind': 'sparkapplications', - 'uid': '52d66d27-b7b7-11eb-b9df-b8599fdb0aac' - } - } - mock_upload_path.return_value = (True, 'test') - resp = self._sparkapp_service.delete_sparkapp( - name='fl-transformer-yaml') - mock_delete_sparkapp.assert_called_once() - mock_file_mananger_remove.assert_called_once() - self.assertTrue(resp.name, 'fl-transformer-yaml') - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/test_data/code.tar.gz b/web_console_v2/api/test/fedlearner_webconsole/test_data/code.tar.gz deleted file mode 100644 index 6e9b56c5739bee89ff69e63b56caa33c93d6973b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176 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 S19uv@)4-hu?lkb(8u%Ntl9H1E diff --git a/web_console_v2/api/test/fedlearner_webconsole/test_data/workflow_config.json b/web_console_v2/api/test/fedlearner_webconsole/test_data/workflow_config.json deleted file mode 100644 index 3e3d675a1..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/test_data/workflow_config.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "group_alias": "test_workflow", - "is_left": true, - "variables": [ - { - "name": "v1", - "value": "value1", - "access_mode": "PRIVATE", - "widget_schema": "" - }, - { - "name": "v2", - "value": "value2", - "access_mode": "PEER_READABLE", - "widget_schema": "" - }, - { - "name": "v3", - "value": "value3", - "access_mode": "PEER_WRITABLE", - "widget_schema": "" - } - ], - "job_definitions": [ - { - "name": "data-import", - "job_type": "RAW_DATA", - "is_federated": false, - "yaml_template": "data-import-yaml", - "variables": [], - "dependencies": [] - }, - { - "name": "data-join", - "job_type": "PSI_DATA_JOIN", - "is_federated": true, - "yaml_template": "data-join-yaml", - "variables": [], - "dependencies": [ - { - "source": "data-import" - } - ] - }, - { - "name": "training", - "job_type": "TREE_MODEL_TRAINING", - "is_federated": true, - "yaml_template": "training-yaml", - "variables": [], - "dependencies": [ - { - "source": "data-join" - } - ] - } - ] -} diff --git a/web_console_v2/api/test/fedlearner_webconsole/test_data/workflow_config_right.json b/web_console_v2/api/test/fedlearner_webconsole/test_data/workflow_config_right.json deleted file mode 100644 index b4a8f0856..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/test_data/workflow_config_right.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "group_alias": "test_workflow", - "is_left": false, - "variables": [ - { - "name": "v1", - "value": "value1", - "access_mode": "PRIVATE", - "widget_schema": "" - }, - { - "name": "v2", - "value": "value2", - "access_mode": "PEER_READABLE", - "widget_schema": "" - }, - { - "name": "v3", - "value": "value3", - "access_mode": "PEER_WRITABLE", - "widget_schema": "" - } - ], - "job_definitions": [ - { - "name": "data-import", - "job_type": "RAW_DATA", - "is_federated": false, - "yaml_template": "data-import-yaml", - "variables": [], - "dependencies": [] - }, - { - "name": "data-join", - "job_type": "PSI_DATA_JOIN", - "is_federated": true, - "yaml_template": "data-join-yaml", - "variables": [], - "dependencies": [ - { - "source": "data-import" - } - ] - }, - { - "name": "training", - "job_type": "TREE_MODEL_TRAINING", - "is_federated": true, - "yaml_template": "training-yaml", - "variables": [], - "dependencies": [ - { - "source": "data-join" - } - ] - } - ] -} diff --git a/web_console_v2/api/test/fedlearner_webconsole/utils/base64_test.py b/web_console_v2/api/test/fedlearner_webconsole/utils/base64_test.py deleted file mode 100644 index 2667e1025..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/utils/base64_test.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest - -from fedlearner_webconsole.utils.base64 import base64encode, base64decode - - -class Base64Test(unittest.TestCase): - def test_base64encode(self): - self.assertEqual(base64encode('hello 1@2'), 'aGVsbG8gMUAy') - self.assertEqual(base64encode('😈'), '8J+YiA==') - - def test_base64decode(self): - self.assertEqual(base64decode('aGVsbG8gMUAy'), 'hello 1@2') - self.assertEqual(base64decode('JjEzOVlUKiYm'), '&139YT*&&') - - def test_base64_encode_and_decode(self): - self.assertEqual(base64decode(base64encode('test')), 'test') - self.assertEqual(base64encode(base64decode('aGVsbG8gMUAy')), - 'aGVsbG8gMUAy') - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/utils/decorators_test.py b/web_console_v2/api/test/fedlearner_webconsole/utils/decorators_test.py deleted file mode 100644 index 5a6db55db..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/utils/decorators_test.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - -import os -import grpc -import unittest -from unittest.mock import MagicMock, patch - -from fedlearner_webconsole.auth.models import User, Role -from fedlearner_webconsole.utils.decorators import retry_fn, admin_required, jwt_required -from fedlearner_webconsole.exceptions import UnauthorizedException - -@retry_fn(retry_times=2, needed_exceptions=[grpc.RpcError]) -def some_unstable_connect(client): - res = client() - if res['status'] != 0: - raise grpc.RpcError() - else: - return res['data'] - -@admin_required -def some_authorized_login(): - return 1 - - -class DecoratorsTest(unittest.TestCase): - @staticmethod - def generator_helper(inject_res): - for r in inject_res: - yield r - - def test_retry_fn(self): - res = [{ - 'status': -1, - 'data': 'hhhhhh' - }, { - 'status': -1, - 'data': 'hhhh' - }] - - client = MagicMock() - client.side_effect = res - with self.assertRaises(grpc.RpcError): - some_unstable_connect(client=client) - - res = [{'status': -1, 'data': 'hhhhhh'}, {'status': 0, 'data': 'hhhh'}] - client = MagicMock() - client.side_effect = res - self.assertTrue(some_unstable_connect(client=client) == 'hhhh') - - - @patch('fedlearner_webconsole.utils.decorators.get_current_user') - def test_admin_required(self, mock_get_current_user): - admin = User(id=0, username='adamin', password='admin', role=Role.ADMIN) - user = User(id=1, username='ada', password='ada', role=Role.USER) - mock_get_current_user.return_value = admin - self.assertTrue(some_authorized_login() == 1) - - mock_get_current_user.return_value = user - self.assertRaises(UnauthorizedException, some_authorized_login) - -if __name__ == '__main__': - unittest.main() - diff --git a/web_console_v2/api/test/fedlearner_webconsole/utils/file_manager_test.py b/web_console_v2/api/test/fedlearner_webconsole/utils/file_manager_test.py deleted file mode 100644 index f32bf303e..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/utils/file_manager_test.py +++ /dev/null @@ -1,242 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import shutil -import tempfile -import unittest - -from collections import namedtuple -from pathlib import Path -from tensorflow.io import gfile - -from fedlearner_webconsole.utils.file_manager import GFileFileManager, FileManager, File - -FakeFileStatistics = namedtuple('FakeFileStatistics', ['length', 'mtime_nsec']) - - -class GFileFileManagerTest(unittest.TestCase): - - _F1_SIZE = 3 - _F2_SIZE = 4 - _S1_SIZE = 55 - _F1_MTIME = 1613982390 - _F2_MTIME = 1613982391 - _S1_MTIME = 1613982392 - - def _get_file_stat(self, orig_os_stat, path): - gfile_stat = FakeFileStatistics(2, 1613982390 * 1e9) - if path == self._get_temp_path('f1.txt') or \ - path == self._get_temp_path('subdir/f1.txt'): - gfile_stat = FakeFileStatistics(self._F1_SIZE, - self._F1_MTIME * 1e9) - return gfile_stat - elif path == self._get_temp_path('f2.txt') or \ - path == self._get_temp_path('f3.txt'): - gfile_stat = FakeFileStatistics(self._F2_SIZE, - self._F2_MTIME * 1e9) - return gfile_stat - elif path == self._get_temp_path('subdir/s1.txt'): - gfile_stat = FakeFileStatistics(self._S1_SIZE, - self._S1_MTIME * 1e9) - return gfile_stat - else: - return orig_os_stat(path) - - def setUp(self): - # Create a temporary directory - self._test_dir = tempfile.mkdtemp() - subdir = Path(self._test_dir).joinpath('subdir') - subdir.mkdir(exist_ok=True) - Path(self._test_dir).joinpath('f1.txt').write_text('xxx') - Path(self._test_dir).joinpath('f2.txt').write_text('xxx') - subdir.joinpath('s1.txt').write_text('xxx') - - # Mocks os.stat - self._orig_os_stat = os.stat - - def fake_stat(path, *arg, **kwargs): - return self._get_file_stat(self._orig_os_stat, path) - - gfile.stat = fake_stat - - self._fm = GFileFileManager() - - def tearDown(self): - os.stat = self._orig_os_stat - # Remove the directory after the test - shutil.rmtree(self._test_dir) - - def _get_temp_path(self, file_path: str = None) -> str: - return str(Path(self._test_dir, file_path or '').absolute()) - - def test_can_handle(self): - self.assertTrue(self._fm.can_handle('/data/abc')) - self.assertFalse(self._fm.can_handle('data')) - - def test_ls(self): - # List file - self.assertEqual(self._fm.ls(self._get_temp_path('f1.txt')), [ - File(path=self._get_temp_path('f1.txt'), - size=self._F1_SIZE, - mtime=self._F1_MTIME) - ]) - # List folder - self.assertEqual( - sorted(self._fm.ls(self._get_temp_path()), - key=lambda file: file.path), - sorted([ - File(path=self._get_temp_path('f1.txt'), - size=self._F1_SIZE, - mtime=self._F1_MTIME), - File(path=self._get_temp_path('f2.txt'), - size=self._F2_SIZE, - mtime=self._F2_MTIME) - ], - key=lambda file: file.path)) - # List folder recursively - self.assertEqual( - sorted(self._fm.ls(self._get_temp_path(), recursive=True), - key=lambda file: file.path), - sorted([ - File(path=self._get_temp_path('f1.txt'), - size=self._F1_SIZE, - mtime=self._F1_MTIME), - File(path=self._get_temp_path('f2.txt'), - size=self._F2_SIZE, - mtime=self._F2_MTIME), - File(path=self._get_temp_path('subdir/s1.txt'), - size=self._S1_SIZE, - mtime=self._S1_MTIME), - ], - key=lambda file: file.path)) - - def test_move(self): - # Moves to another folder - self._fm.move(self._get_temp_path('f1.txt'), - self._get_temp_path('subdir/')) - self.assertEqual( - sorted(self._fm.ls(self._get_temp_path('subdir')), - key=lambda file: file.path), - sorted([ - File(path=self._get_temp_path('subdir/s1.txt'), - size=self._S1_SIZE, - mtime=self._S1_MTIME), - File(path=self._get_temp_path('subdir/f1.txt'), - size=self._F1_SIZE, - mtime=self._F1_MTIME), - ], - key=lambda file: file.path)) - # Renames - self._fm.move(self._get_temp_path('f2.txt'), - self._get_temp_path('f3.txt')) - with self.assertRaises(ValueError): - self._fm.ls(self._get_temp_path('f2.txt')) - self.assertEqual(self._fm.ls(self._get_temp_path('f3.txt')), [ - File(path=self._get_temp_path('f3.txt'), - size=self._F2_SIZE, - mtime=self._F2_MTIME) - ]) - - def test_remove(self): - self._fm.remove(self._get_temp_path('f1.txt')) - self._fm.remove(self._get_temp_path('subdir')) - self.assertEqual(self._fm.ls(self._get_temp_path(), recursive=True), [ - File(path=self._get_temp_path('f2.txt'), - size=self._F2_SIZE, - mtime=self._F2_MTIME) - ]) - - def test_copy(self): - self._fm.copy(self._get_temp_path('f1.txt'), - self._get_temp_path('subdir')) - self.assertEqual(self._fm.ls(self._get_temp_path('f1.txt')), [ - File(path=self._get_temp_path('f1.txt'), - size=self._F1_SIZE, - mtime=self._F1_MTIME) - ]) - self.assertEqual(self._fm.ls(self._get_temp_path('subdir/f1.txt')), [ - File(path=self._get_temp_path('subdir/f1.txt'), - size=self._F1_SIZE, - mtime=self._F1_MTIME) - ]) - - def test_mkdir(self): - self._fm.mkdir(os.path.join(self._get_temp_path(), 'subdir2')) - self.assertTrue(os.path.isdir(self._get_temp_path('subdir2'))) - - def test_read(self): - content = self._fm.read(self._get_temp_path('f1.txt')) - self.assertEqual('xxx', content) - - -class FileManagerTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - fake_fm = 'testing.fake_file_manager:FakeFileManager' - os.environ['CUSTOMIZED_FILE_MANAGER'] = fake_fm - - @classmethod - def tearDownClass(cls): - del os.environ['CUSTOMIZED_FILE_MANAGER'] - - def setUp(self): - self._fm = FileManager() - - def test_can_handle(self): - self.assertTrue(self._fm.can_handle('fake://123')) - # Falls back to default manager - self.assertTrue(self._fm.can_handle('/data/123')) - self.assertFalse(self._fm.can_handle('unsupported:///123')) - - def test_ls(self): - self.assertEqual(self._fm.ls('fake://data'), [{ - 'path': 'fake://data/f1.txt', - 'size': 0 - }]) - - def test_move(self): - self.assertTrue(self._fm.move('fake://move/123', 'fake://move/234')) - self.assertFalse( - self._fm.move('fake://do_not_move/123', 'fake://move/234')) - # No file manager can handle this - self.assertRaises(RuntimeError, - lambda: self._fm.move('hdfs://123', 'fake://abc')) - - def test_remove(self): - self.assertTrue(self._fm.remove('fake://remove/123')) - self.assertFalse(self._fm.remove('fake://do_not_remove/123')) - # No file manager can handle this - self.assertRaises(RuntimeError, - lambda: self._fm.remove('unsupported://123')) - - def test_copy(self): - self.assertTrue(self._fm.copy('fake://copy/123', 'fake://copy/234')) - self.assertFalse( - self._fm.copy('fake://do_not_copy/123', 'fake://copy/234')) - # No file manager can handle this - self.assertRaises(RuntimeError, - lambda: self._fm.copy('hdfs://123', 'fake://abc')) - - def test_mkdir(self): - self.assertTrue(self._fm.mkdir('fake://mkdir/123')) - self.assertFalse(self._fm.mkdir('fake://do_not_mkdir/123')) - # No file manager can handle this - self.assertRaises(RuntimeError, - lambda: self._fm.mkdir('unsupported:///123')) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/utils/k8s_client_test.py b/web_console_v2/api/test/fedlearner_webconsole/utils/k8s_client_test.py deleted file mode 100644 index 19ffd9fc0..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/utils/k8s_client_test.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest -from unittest.mock import MagicMock - -from kubernetes.client import ApiException - -from fedlearner_webconsole.utils.k8s_client import K8sClient - - -class K8sClientTest(unittest.TestCase): - def setUp(self): - self._k8s_client = K8sClient() - - def test_delete_flapp(self): - mock_crds = MagicMock() - self._k8s_client.crds = mock_crds - # Test delete successfully - mock_crds.delete_namespaced_custom_object = MagicMock() - self._k8s_client.delete_flapp('test_flapp') - mock_crds.delete_namespaced_custom_object.assert_called_once_with( - group='fedlearner.k8s.io', - name='test_flapp', - namespace='default', - plural='flapps', - version='v1alpha1') - # Tests that the flapp has been deleted - mock_crds.delete_namespaced_custom_object = MagicMock( - side_effect=ApiException(status=404)) - self._k8s_client.delete_flapp('test_flapp2') - self.assertEqual(mock_crds.delete_namespaced_custom_object.call_count, - 1) - # Tests with other exceptions - mock_crds.delete_namespaced_custom_object = MagicMock( - side_effect=ApiException(status=500)) - with self.assertRaises(RuntimeError): - self._k8s_client.delete_flapp('test_flapp3') - self.assertEqual(mock_crds.delete_namespaced_custom_object.call_count, - 3) - - def test_create_flapp(self): - test_yaml = { - 'metadata': { - 'name': 'test app' - } - } - mock_crds = MagicMock() - self._k8s_client.crds = mock_crds - # Test create successfully - mock_crds.create_namespaced_custom_object = MagicMock() - self._k8s_client.create_flapp(test_yaml) - mock_crds.create_namespaced_custom_object.assert_called_once_with( - group='fedlearner.k8s.io', - namespace='default', - plural='flapps', - version='v1alpha1', - body=test_yaml) - # Test that flapp exists - mock_crds.create_namespaced_custom_object = MagicMock( - side_effect=[ApiException(status=409), None]) - self._k8s_client.delete_flapp = MagicMock() - self._k8s_client.create_flapp(test_yaml) - self._k8s_client.delete_flapp.assert_called_once_with('test app') - self.assertEqual(mock_crds.create_namespaced_custom_object.call_count, - 2) - # Test with other exceptions - mock_crds.create_namespaced_custom_object = MagicMock( - side_effect=ApiException(status=114)) - with self.assertRaises(ApiException): - self._k8s_client.create_flapp(test_yaml) - self.assertEqual(mock_crds.create_namespaced_custom_object.call_count, - 3) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/utils/kibana_test.py b/web_console_v2/api/test/fedlearner_webconsole/utils/kibana_test.py deleted file mode 100644 index a5478d693..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/utils/kibana_test.py +++ /dev/null @@ -1,37 +0,0 @@ -import unittest - -from fedlearner_webconsole.exceptions import UnauthorizedException -from fedlearner_webconsole.utils.kibana import Kibana - - -class KibanaTest(unittest.TestCase): - def test_auth(self): - self.assertRaises(UnauthorizedException, - Kibana._check_authorization, 'tags.1') - self.assertRaises(UnauthorizedException, - Kibana._check_authorization, 'tags.1:2') - self.assertRaises(UnauthorizedException, - Kibana._check_authorization, 'x:3 and y:4', {'x'}) - self.assertRaises(UnauthorizedException, - Kibana._check_authorization, - 'x:3 OR y:4 AND z:5', {'x', 'z'}) - try: - Kibana._check_authorization('x:1', {'x'}) - Kibana._check_authorization('x:1 AND y:2 OR z:3', {'x', 'y', 'z'}) - Kibana._check_authorization('x:1 oR y:2 aNd z:3', {'x', 'y', 'z'}) - Kibana._check_authorization('*', {'x', '*'}) - Kibana._check_authorization(None, None) - except UnauthorizedException: - self.fail() - - def test_parse_time(self): - dt1 = 0 - dt2 = 60 * 60 * 24 - args = {'start_time': dt1, 'end_time': dt2} - st, et = Kibana._parse_start_end_time(args) - self.assertEqual(st, '1970-01-01T00:00:00Z') - self.assertEqual(et, '1970-01-02T00:00:00Z') - st, et = Kibana._parse_start_end_time({'start_time': -1, - 'end_time': -1}) - self.assertEqual(st, 'now-5y') - self.assertEqual(et, 'now') diff --git a/web_console_v2/api/test/fedlearner_webconsole/utils/metrics_test.py b/web_console_v2/api/test/fedlearner_webconsole/utils/metrics_test.py deleted file mode 100644 index 4d6172fde..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/utils/metrics_test.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import logging -import unittest - -from fedlearner_webconsole.utils import metrics -from fedlearner_webconsole.utils.metrics import _DefaultMetricsHandler, MetricsHandler - - -class _FakeMetricsHandler(MetricsHandler): - - def emit_counter(self, name, value: int, tags: dict = None): - logging.info(f'[Test][Counter] {name} - {value}') - - def emit_store(self, name, value: int, tags: dict = None): - logging.info(f'[Test][Store] {name} - {value}') - - -class DefaultMetricsHandler(unittest.TestCase): - def setUp(self): - self._handler = _DefaultMetricsHandler() - - def test_emit_counter(self): - with self.assertLogs() as cm: - self._handler.emit_counter('test', 1) - self._handler.emit_counter('test2', 2) - logs = [r.msg for r in cm.records] - self.assertEqual(logs, [ - '[Metric][Counter] test: 1', - '[Metric][Counter] test2: 2']) - - def test_emit_store(self): - with self.assertLogs() as cm: - self._handler.emit_store('test', 199) - self._handler.emit_store('test2', 299) - logs = [r.msg for r in cm.records] - self.assertEqual(logs, [ - '[Metric][Store] test: 199', - '[Metric][Store] test2: 299']) - - -class ClientTest(unittest.TestCase): - def setUp(self): - metrics.add_handler(_FakeMetricsHandler()) - - def tearDown(self): - metrics.reset_handlers() - - def test_emit_counter(self): - with self.assertLogs() as cm: - metrics.emit_counter('test', 1) - logs = [r.msg for r in cm.records] - self.assertEqual(logs, [ - '[Metric][Counter] test: 1', - '[Test][Counter] test - 1']) - - def test_emit_store(self): - with self.assertLogs() as cm: - metrics.emit_store('test', 199) - logs = [r.msg for r in cm.records] - self.assertEqual(logs, [ - '[Metric][Store] test: 199', - '[Test][Store] test - 199']) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/utils/mixins_test.py b/web_console_v2/api/test/fedlearner_webconsole/utils/mixins_test.py deleted file mode 100644 index b9573b561..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/utils/mixins_test.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest - -from sqlalchemy import Column, Integer -from sqlalchemy.ext.declarative import declarative_base - -from fedlearner_webconsole.utils.mixins import from_dict_mixin, to_dict_mixin - -Base = declarative_base() - - -@to_dict_mixin() -class DeclarativeClass(Base): - __tablename__ = 'just_a_test' - - test = Column(Integer, primary_key=True) - - -@to_dict_mixin(to_dict_fields=['hhh']) -@from_dict_mixin(from_dict_fields=['hhh'], required_fields=['hhh']) -class SpecifyColumnsClass(object): - def __init__(self) -> None: - self.hhh = None - self.not_include = None - - -class MixinsTest(unittest.TestCase): - def test_to_dict_declarative_api(self): - obj = DeclarativeClass() - res = obj.to_dict() - self.assertEqual(len(res), 1) - self.assertTrue('test' in res) - - def test_to_dict_specify_columns(self): - obj = SpecifyColumnsClass() - obj.hhh = 'hhh' - res = obj.to_dict() - self.assertEqual(len(res), 1) - self.assertTrue('hhh' in res) - - def test_from_dict(self): - inputs_pass = {'hhh': 4, 'hhhh': 1} - inputs_raise = {'hhhh': 1} - - obj = SpecifyColumnsClass.from_dict(inputs_pass) - self.assertEqual(obj.hhh, 4) - with self.assertRaises(ValueError): - obj = SpecifyColumnsClass.from_dict(inputs_raise) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/utils/system_envs_test.py b/web_console_v2/api/test/fedlearner_webconsole/utils/system_envs_test.py deleted file mode 100644 index f90183a59..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/utils/system_envs_test.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import unittest -from unittest.mock import patch - -from fedlearner_webconsole.utils.system_envs import get_system_envs - - -class SystemEnvsTest(unittest.TestCase): - @patch.dict(os.environ, { - 'ES_HOST': 'test es host', - 'ES_PORT': '9200', - 'DB_HOST': 'test db host', - 'DB_PORT': '3306', - 'DB_DATABASE': 'fedlearner', - 'DB_USERNAME': 'username', - 'DB_PASSWORD': 'password', - 'KVSTORE_TYPE': 'mysql', - 'ETCD_NAME': 'fedlearner', - 'ETCD_ADDR': 'fedlearner-stack-etcd.default.svc.cluster.local:2379', - 'ETCD_BASE_DIR': 'fedlearner' - }) - def test_get_system_envs(self): - self.assertEqual( - get_system_envs(), - '{"name": "POD_IP", "valueFrom": {"fieldRef": {"fieldPath": "status.podIP"}}},' - '{"name": "POD_NAME", "valueFrom": {"fieldRef": {"fieldPath": "metadata.name"}}},' - '{"name": "CPU_REQUEST", "valueFrom": {"resourceFieldRef": {"resource": "requests.cpu"}}},' - '{"name": "MEM_REQUEST", "valueFrom": {"resourceFieldRef": {"resource": "requests.memory"}}},' - '{"name": "CPU_LIMIT", "valueFrom": {"resourceFieldRef": {"resource": "limits.cpu"}}},' - '{"name": "MEM_LIMIT", "valueFrom": {"resourceFieldRef": {"resource": "limits.memory"}}},' - '{"name": "ES_HOST", "value": "test es host"},' - '{"name": "ES_PORT", "value": "9200"},' - '{"name": "DB_HOST", "value": "test db host"},' - '{"name": "DB_PORT", "value": "3306"},' - '{"name": "DB_DATABASE", "value": "fedlearner"},' - '{"name": "DB_USERNAME", "value": "username"},' - '{"name": "DB_PASSWORD", "value": "password"},' - '{"name": "KVSTORE_TYPE", "value": "mysql"},' - '{"name": "ETCD_NAME", "value": "fedlearner"},' - '{"name": "ETCD_ADDR", "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379"},' - '{"name": "ETCD_BASE_DIR", "value": "fedlearner"}') - - def test_get_available_envs(self): - self.assertEqual( - get_system_envs(), - '{"name": "POD_IP", "valueFrom": {"fieldRef": {"fieldPath": "status.podIP"}}},' - '{"name": "POD_NAME", "valueFrom": {"fieldRef": {"fieldPath": "metadata.name"}}},' - '{"name": "CPU_REQUEST", "valueFrom": {"resourceFieldRef": {"resource": "requests.cpu"}}},' - '{"name": "MEM_REQUEST", "valueFrom": {"resourceFieldRef": {"resource": "requests.memory"}}},' - '{"name": "CPU_LIMIT", "valueFrom": {"resourceFieldRef": {"resource": "limits.cpu"}}},' - '{"name": "MEM_LIMIT", "valueFrom": {"resourceFieldRef": {"resource": "limits.memory"}}}') - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/workflow/apis_test.py b/web_console_v2/api/test/fedlearner_webconsole/workflow/apis_test.py deleted file mode 100644 index 2c6382658..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/workflow/apis_test.py +++ /dev/null @@ -1,474 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import logging -import random -import string -import time -import json -import unittest -from uuid import UUID -from http import HTTPStatus -from pathlib import Path -from unittest.mock import patch -from google.protobuf.json_format import ParseDict -from fedlearner_webconsole.composer.models import ItemStatus, SchedulerItem -from fedlearner_webconsole.db import db -from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition, JobDefinition -from fedlearner_webconsole.project.models import Project -from fedlearner_webconsole.workflow.cronjob import WorkflowCronJobItem -from fedlearner_webconsole.workflow.models import Workflow, WorkflowState -from fedlearner_webconsole.job.models import Job, JobType, JobState -from fedlearner_webconsole.scheduler.transaction import TransactionState -from fedlearner_webconsole.proto.service_pb2 import GetWorkflowResponse -from fedlearner_webconsole.proto import project_pb2 -from fedlearner_webconsole.rpc.client import RpcClient -from fedlearner_webconsole.proto.common_pb2 import CreateJobFlag -from fedlearner_webconsole.workflow.apis import is_peer_job_inheritance_matched -from testing.common import BaseTestCase -from fedlearner_webconsole.db import db_handler - -class WorkflowsApiTest(BaseTestCase): - class Config(BaseTestCase.Config): - START_GRPC_SERVER = False - START_SCHEDULER = False - - def setUp(self): - self.maxDiff = None - super().setUp() - # Inserts data - workflow1 = Workflow(name='workflow_key_get1', project_id=1) - workflow2 = Workflow(name='workflow_kay_get2', project_id=2) - workflow3 = Workflow(name='workflow_key_get3', project_id=2) - db.session.add(workflow1) - db.session.add(workflow2) - db.session.add(workflow3) - db.session.commit() - - def test_get_with_project(self): - response = self.get_helper('/api/v2/workflows?project=1') - self.assertEqual(response.status_code, HTTPStatus.OK) - data = self.get_response_data(response) - self.assertEqual(len(data), 1) - self.assertEqual(data[0]['name'], 'workflow_key_get1') - - def test_get_with_keyword(self): - response = self.get_helper('/api/v2/workflows?keyword=key') - self.assertEqual(response.status_code, HTTPStatus.OK) - data = self.get_response_data(response) - self.assertEqual(len(data), 2) - self.assertEqual(data[0]['name'], 'workflow_key_get1') - - def test_get_workflows(self): - time.sleep(1) - workflow = Workflow(name='last', project_id=1) - db.session.add(workflow) - db.session.flush() - db.session.commit() - response = self.get_helper('/api/v2/workflows') - data = self.get_response_data(response) - self.assertEqual(data[0]['name'], 'last') - - @patch('fedlearner_webconsole.workflow.apis.scheduler.wakeup') - @patch('fedlearner_webconsole.workflow.apis.uuid4') - def test_create_new_workflow(self, mock_uuid, mock_wakeup): - mock_uuid.return_value = UUID('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') - with open( - Path(__file__, '../../test_data/workflow_config.json').resolve( - )) as workflow_config: - config = json.load(workflow_config) - extra = ''.join( - random.choice(string.ascii_lowercase) for _ in range(10)) - # extra should be a valid json string so we mock one - extra = f'{{"parent_job_name":"{extra}"}}' - workflow = { - 'name': 'test-workflow', - 'project_id': 1234567, - 'forkable': True, - 'comment': 'test-comment', - 'config': config, - 'extra': extra - } - response = self.post_helper('/api/v2/workflows', data=workflow) - self.assertEqual(response.status_code, HTTPStatus.CREATED) - created_workflow = json.loads(response.data).get('data') - # Check scheduler - mock_wakeup.assert_called_once_with(created_workflow['id']) - self.assertIsNotNone(created_workflow['id']) - self.assertIsNotNone(created_workflow['created_at']) - self.assertIsNotNone(created_workflow['updated_at']) - del created_workflow['id'] - del created_workflow['created_at'] - del created_workflow['updated_at'] - del created_workflow['start_at'] - del created_workflow['stop_at'] - self.assertEqual( - created_workflow, { - 'batch_update_interval': -1, - 'name': 'test-workflow', - 'project_id': 1234567, - 'extra': extra, - 'forkable': True, - 'forked_from': None, - 'metric_is_public': False, - 'comment': 'test-comment', - 'state': 'NEW', - 'target_state': 'READY', - 'transaction_state': 'READY', - 'transaction_err': None, - 'create_job_flags': [1, 1, 1], - 'peer_create_job_flags': None, - 'job_ids': [], - 'transaction_state': 'READY', - 'last_triggered_batch': None, - 'recur_at': None, - 'recur_type': 'NONE', - 'trigger_dataset': None, - 'uuid': f'u{mock_uuid().hex[:19]}' - }) - # Check DB - self.assertEqual(len(Workflow.query.all()), 4) - - # Post again - mock_wakeup.reset_mock() - response = self.post_helper('/api/v2/workflows', data=workflow) - self.assertEqual(response.status_code, HTTPStatus.CONFLICT) - # Check mock - mock_wakeup.assert_not_called() - # Check DB - self.assertEqual(len(Workflow.query.all()), 4) - - @patch('fedlearner_webconsole.workflow.apis.composer.get_item_status') - @patch('fedlearner_webconsole.workflow.apis.composer.collect') - @patch('fedlearner_webconsole.workflow.apis.scheduler.wakeup') - def test_post_batch_update_interval_job(self, mock_wakeup, mock_collect, - mock_get_item_status): - mock_get_item_status.return_value = None - with open( - Path(__file__, '../../test_data/workflow_config.json').resolve( - )) as workflow_config: - config = json.load(workflow_config) - workflow = { - 'name': 'test-workflow-left', - 'project_id': 1234567, - 'forkable': True, - 'config': config, - 'batch_update_interval': 10, - } - responce = self.post_helper('/api/v2/workflows', data=workflow) - self.assertEqual(responce.status_code, HTTPStatus.CREATED) - - with open( - Path(__file__, '../../test_data/workflow_config_right.json'). - resolve()) as workflow_config: - config = json.load(workflow_config) - workflow = { - 'name': 'test-workflow-right', - 'project_id': 1234567, - 'forkable': True, - 'config': config, - 'batch_update_interval': 10, - } - responce = self.post_helper('/api/v2/workflows', data=workflow) - self.assertEqual(responce.status_code, HTTPStatus.BAD_REQUEST) - - mock_collect.assert_called() - mock_wakeup.assert_called() - - def test_fork_workflow(self): - # TODO: insert into db first, and then copy it. - pass - - -class WorkflowApiTest(BaseTestCase): - def test_put_successfully(self): - config = { - 'participants': [{ - 'name': 'party_leader', - 'url': '127.0.0.1:5000', - 'domain_name': 'fl-leader.com' - }], - 'variables': [{ - 'name': 'namespace', - 'value': 'leader' - }, { - 'name': 'basic_envs', - 'value': '{}' - }, { - 'name': 'storage_root_dir', - 'value': '/' - }, { - 'name': 'EGRESS_URL', - 'value': '127.0.0.1:1991' - }] - } - project = Project( - name='test', - config=ParseDict(config, - project_pb2.Project()).SerializeToString()) - db.session.add(project) - workflow = Workflow( - name='test-workflow', - project_id=1, - state=WorkflowState.NEW, - transaction_state=TransactionState.PARTICIPANT_PREPARE, - target_state=WorkflowState.READY) - db.session.add(workflow) - db.session.commit() - db.session.refresh(workflow) - - response = self.put_helper(f'/api/v2/workflows/{workflow.id}', - data={ - 'forkable': True, - 'config': { - 'group_alias': 'test-template' - }, - 'comment': 'test comment' - }) - self.assertEqual(response.status_code, HTTPStatus.OK) - - updated_workflow = Workflow.query.get(workflow.id) - self.assertIsNotNone(updated_workflow.config) - self.assertTrue(updated_workflow.forkable) - self.assertEqual(updated_workflow.comment, 'test comment') - self.assertEqual(updated_workflow.target_state, WorkflowState.READY) - - def test_put_resetting(self): - workflow = Workflow( - name='test-workflow', - project_id=123, - config=WorkflowDefinition( - group_alias='test-template').SerializeToString(), - state=WorkflowState.NEW, - ) - db.session.add(workflow) - db.session.commit() - db.session.refresh(workflow) - - response = self.put_helper(f'/api/v2/workflows/{workflow.id}', - data={ - 'forkable': True, - 'config': { - 'group_alias': 'test-template' - }, - }) - self.assertEqual(response.status_code, HTTPStatus.CONFLICT) - - @patch('fedlearner_webconsole.workflow.apis.scheduler.wakeup') - def test_patch_successfully(self, mock_wakeup): - workflow = Workflow( - name='test-workflow', - project_id=123, - config=WorkflowDefinition().SerializeToString(), - forkable=False, - state=WorkflowState.READY, - ) - db.session.add(workflow) - db.session.commit() - db.session.refresh(workflow) - - response = self.patch_helper(f'/api/v2/workflows/{workflow.id}', - data={'target_state': 'RUNNING'}) - self.assertEqual(response.status_code, HTTPStatus.OK) - patched_data = json.loads(response.data).get('data') - self.assertEqual(patched_data['id'], workflow.id) - self.assertEqual(patched_data['state'], 'READY') - self.assertEqual(patched_data['target_state'], 'RUNNING') - # Checks DB - patched_workflow = Workflow.query.get(workflow.id) - self.assertEqual(patched_workflow.target_state, WorkflowState.RUNNING) - # Checks scheduler - mock_wakeup.assert_called_once_with(workflow.id) - - @patch('fedlearner_webconsole.workflow.apis.scheduler.wakeup') - def test_patch_invalid_target_state(self, mock_wakeup): - workflow = Workflow(name='test-workflow', - project_id=123, - config=WorkflowDefinition().SerializeToString(), - forkable=False, - state=WorkflowState.READY, - target_state=WorkflowState.RUNNING) - db.session.add(workflow) - db.session.commit() - db.session.refresh(workflow) - - response = self.patch_helper(f'/api/v2/workflows/{workflow.id}', - data={'target_state': 'READY'}) - self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) - self.assertEqual( - json.loads(response.data).get('details'), - 'Another transaction is in progress [1]') - # Checks DB - patched_workflow = Workflow.query.get(workflow.id) - self.assertEqual(patched_workflow.state, WorkflowState.READY) - self.assertEqual(patched_workflow.target_state, WorkflowState.RUNNING) - # Checks scheduler - mock_wakeup.assert_not_called() - - @patch('fedlearner_webconsole.workflow.apis.composer.get_item_status') - @patch('fedlearner_webconsole.workflow.apis.composer.patch_item_attr') - @patch('fedlearner_webconsole.workflow.apis.composer.finish') - @patch('fedlearner_webconsole.workflow.apis.composer.collect') - def test_patch_batch_update_interval(self, mock_collect, mock_finish, - mock_patch_item, - mock_get_item_status): - mock_get_item_status.side_effect = [None, ItemStatus.ON] - workflow = Workflow( - name='test-workflow-left', - project_id=123, - config=WorkflowDefinition(is_left=True).SerializeToString(), - forkable=False, - state=WorkflowState.STOPPED, - ) - batch_update_interval = 1 - db.session.add(workflow) - db.session.commit() - db.session.refresh(workflow) - - # test create cronjob - response = self.patch_helper( - f'/api/v2/workflows/{workflow.id}', - data={'batch_update_interval': batch_update_interval}) - self.assertEqual(response.status_code, HTTPStatus.OK) - - mock_collect.assert_called_with( - name=f'workflow_cron_job_{workflow.id}', - items=[WorkflowCronJobItem(workflow.id)], - metadata={}, - interval=batch_update_interval * 60) - - # patch new interval time for cronjob - batch_update_interval = 2 - response = self.patch_helper( - f'/api/v2/workflows/{workflow.id}', - data={'batch_update_interval': batch_update_interval}) - self.assertEqual(response.status_code, HTTPStatus.OK) - mock_patch_item.assert_called_with( - name=f'workflow_cron_job_{workflow.id}', - key='interval_time', - value=batch_update_interval * 60) - - # test stop cronjob - response = self.patch_helper(f'/api/v2/workflows/{workflow.id}', - data={'batch_update_interval': -1}) - self.assertEqual(response.status_code, HTTPStatus.OK) - mock_finish.assert_called_with(name=f'workflow_cron_job_{workflow.id}') - - workflow = Workflow( - name='test-workflow-right', - project_id=456, - config=WorkflowDefinition(is_left=False).SerializeToString(), - forkable=False, - state=WorkflowState.STOPPED, - ) - db.session.add(workflow) - db.session.commit() - db.session.refresh(workflow) - - response = self.patch_helper(f'/api/v2/workflows/{workflow.id}', - data={'batch_update_interval': 1}) - self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) - - def test_patch_not_found(self): - response = self.patch_helper('/api/v2/workflows/1', - data={'target_state': 'RUNNING'}) - self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) - - def test_patch_create_job_flags(self): - with db_handler.session_scope() as session: - workflow, job = add_fake_workflow(session) - response = self.patch_helper(f'/api/v2/workflows/{workflow.id}', - data={'create_job_flags': [3]}) - self.assertEqual(response.status_code, HTTPStatus.OK) - patched_job = Job.query.get(job.id) - self.assertEqual(patched_job.is_disabled, True) - response = self.patch_helper(f'/api/v2/workflows/{workflow.id}', - data={'create_job_flags': [1]}) - self.assertEqual(response.status_code, HTTPStatus.OK) - patched_job = Job.query.get(job.id) - self.assertEqual(patched_job.is_disabled, False) - - # TODO: Move it to service_test - @patch('fedlearner_webconsole.rpc.client.RpcClient.get_workflow') - def test_is_peer_job_inheritance_matched(self, mock_get_workflow): - peer_job_0 = JobDefinition(name='raw-data-job') - peer_job_1 = JobDefinition(name='train-job', is_federated=True) - peer_config = WorkflowDefinition() - peer_config.job_definitions.extend([peer_job_0, peer_job_1]) - resp = GetWorkflowResponse(config=peer_config) - mock_get_workflow.return_value = resp - - job_0 = JobDefinition(name='train-job', is_federated=True) - config = WorkflowDefinition(job_definitions=[job_0]) - - project = Project() - participant = project_pb2.Participant() - project.set_config(project_pb2.Project(participants=[participant])) - workflow0 = Workflow(project=project) - workflow0.set_config(config) - db.session.add(workflow0) - db.session.commit() - db.session.flush() - workflow1 = Workflow(project=project, forked_from=workflow0.id) - workflow1.set_config(config) - workflow1.set_create_job_flags([CreateJobFlag.REUSE]) - workflow1.set_peer_create_job_flags( - [CreateJobFlag.NEW, CreateJobFlag.REUSE]) - - self.assertTrue(is_peer_job_inheritance_matched(workflow1)) - - workflow1.set_create_job_flags([CreateJobFlag.NEW]) - self.assertFalse(is_peer_job_inheritance_matched(workflow1)) - - def test_is_local(self): - with db_handler.session_scope() as session: - workflow, job = add_fake_workflow(session) - self.assertTrue(workflow.is_local()) - config = workflow.get_config() - config.job_definitions[ - 0].is_federated = True - workflow.set_config(config) - self.assertFalse(False, workflow.is_local()) - - -def add_fake_workflow(session): - wd = WorkflowDefinition() - jd = wd.job_definitions.add() - workflow = Workflow( - name='test-workflow', - project_id=123, - config=wd.SerializeToString(), - forkable=False, - state=WorkflowState.READY, - ) - session.add(workflow) - session.flush() - job = Job( - name='test_job', - job_type=JobType(1), - config=jd.SerializeToString(), - workflow_id=workflow.id, - project_id=123, - state=JobState.STOPPED, - is_disabled=False) - session.add(job) - session.flush() - workflow.job_ids = str(job.id) - session.commit() - return workflow, job - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/workflow/cronjob_test.py b/web_console_v2/api/test/fedlearner_webconsole/workflow/cronjob_test.py deleted file mode 100644 index f33014a5d..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/workflow/cronjob_test.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest - -from time import sleep -from sqlalchemy import and_ - -from testing.common import BaseTestCase -from fedlearner_webconsole.db import db -from fedlearner_webconsole.workflow.models import Workflow, WorkflowState -from fedlearner_webconsole.workflow.cronjob import WorkflowCronJob, WorkflowCronJobItem -from fedlearner_webconsole.composer.models import Context, RunnerStatus, SchedulerItem, SchedulerRunner -from fedlearner_webconsole.composer.composer import ComposerConfig -from fedlearner_webconsole.composer.interface import ItemType - - -class CronJobTest(BaseTestCase): - """Disable for now, hacking!!!! - - Hopefully it will enabled again! - """ - def setUp(self): - super(CronJobTest, self).setUp() - self.test_id = 8848 - workflow = Workflow(id=self.test_id, state=WorkflowState.RUNNING) - db.session.add(workflow) - db.session.commit() - - @unittest.skip('waiting for refactor of transaction state') - def test_cronjob_alone(self): - cronjob = WorkflowCronJob(task_id=self.test_id) - context = Context(data={}, internal={}, db_engine=db.engine) - cronjob.start(context) - status, output = cronjob.result(context) - self.assertEqual(status, RunnerStatus.DONE) - self.assertTrue(output['msg'] is not None) - - @unittest.skip('waiting for refactor of transaction state') - def test_cronjob_with_composer(self): - config = ComposerConfig( - runner_fn={ItemType.WORKFLOW_CRON_JOB.value: WorkflowCronJob}, - name='test_cronjob') - with self.composer_scope(config=config) as composer: - item_name = f'workflow_cronjob_{self.test_id}' - composer.collect(name=item_name, - items=[WorkflowCronJobItem(self.test_id)], - metadata={}, - interval=10) - sleep(20) - runners = SchedulerRunner.query.filter( - and_(SchedulerRunner.item_id == SchedulerItem.id, - SchedulerItem.name == item_name)).all() - self.assertEqual(len(runners), 2) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/apis_test.py b/web_console_v2/api/test/fedlearner_webconsole/workflow_template/apis_test.py deleted file mode 100644 index 128fd9bd8..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/apis_test.py +++ /dev/null @@ -1,241 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import json -import unittest -from http import HTTPStatus - -from fedlearner_webconsole.db import db -from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition -from fedlearner_webconsole.workflow_template.models import WorkflowTemplate, WorkflowTemplateKind -from fedlearner_webconsole.workflow_template.apis import dict_to_workflow_definition -from testing.common import BaseTestCase - - -class WorkflowTemplatesApiTest(BaseTestCase): - class Config(BaseTestCase.Config): - START_GRPC_SERVER = False - START_SCHEDULER = False - - def setUp(self): - super().setUp() - # Inserts data - template1 = WorkflowTemplate(name='t1', - comment='comment for t1', - group_alias='g1', - is_left=True) - template1.set_config( - WorkflowDefinition( - group_alias='g1', - is_left=True, - )) - template2 = WorkflowTemplate(name='t2', - group_alias='g2', - is_left=False) - template2.set_config( - WorkflowDefinition( - group_alias='g2', - is_left=False, - )) - - template3 = WorkflowTemplate( - name='t3', - group_alias='g3', - is_left=True, - kind=WorkflowTemplateKind.PRESET_DATAJOIN.value) - template3.set_config( - WorkflowDefinition( - group_alias='g3', - is_left=False, - )) - - db.session.add(template1) - db.session.add(template2) - db.session.add(template3) - db.session.commit() - - def test_get_with_group_alias(self): - response = self.get_helper('/api/v2/workflow_templates?group_alias=g1') - self.assertEqual(response.status_code, HTTPStatus.OK) - data = json.loads(response.data).get('data') - self.assertEqual(len(data), 1) - self.assertEqual(data[0]['name'], 't1') - - def test_get_with_group_alias_with_is_left(self): - response = self.get_helper( - '/api/v2/workflow_templates?group_alias=g1&is_left=1') - self.assertEqual(response.status_code, HTTPStatus.OK) - data = json.loads(response.data).get('data') - self.assertEqual(len(data), 1) - self.assertEqual(data[0]['name'], 't1') - response = self.get_helper( - '/api/v2/workflow_templates?group_alias=g1&is_left=0') - self.assertEqual(response.status_code, HTTPStatus.OK) - data = json.loads(response.data).get('data') - self.assertEqual(len(data), 0) - - def test_get_all_templates(self): - response = self.get_helper('/api/v2/workflow_templates') - self.assertEqual(response.status_code, HTTPStatus.OK) - data = json.loads(response.data).get('data') - self.assertEqual(len(data), 3) - - def test_post_without_required_arguments(self): - response = self.post_helper('/api/v2/workflow_templates', data={}) - self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) - self.assertEqual( - json.loads(response.data).get('details'), - {'name': 'name is empty'}) - - response = self.post_helper('/api/v2/workflow_templates', - data={ - 'name': 'test', - 'comment': 'test-comment', - 'config': { - 'is_left': True - } - }) - self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) - self.assertEqual( - json.loads(response.data).get('details'), - {'config.group_alias': 'config.group_alias is required'}) - - response = self.post_helper('/api/v2/workflow_templates', - data={ - 'name': 'test', - 'comment': 'test-comment', - 'config': { - 'group_alias': 'g222', - } - }) - self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) - self.assertEqual( - json.loads(response.data).get('details'), - {'config.is_left': 'config.is_left is required'}) - - def test_post_successfully(self): - template_name = 'test-nb-template' - expected_template = WorkflowTemplate.query.filter_by( - name=template_name).first() - self.assertIsNone(expected_template) - - response = self.post_helper('/api/v2/workflow_templates', - data={ - 'name': template_name, - 'comment': 'test-comment', - 'config': { - 'group_alias': 'g222', - 'is_left': True - }, - 'kind': 1, - }) - self.assertEqual(response.status_code, HTTPStatus.CREATED) - data = json.loads(response.data).get('data') - # Checks DB - expected_template = WorkflowTemplate.query.filter_by( - name=template_name).first() - self.assertEqual(expected_template.name, template_name) - self.assertEqual(expected_template.comment, 'test-comment') - self.assertEqual( - expected_template.config, - WorkflowDefinition(group_alias='g222', - is_left=True).SerializeToString()) - expected_template_dict = { - 'comment': 'test-comment', - 'config': { - 'group_alias': 'g222', - 'is_left': True, - 'job_definitions': [], - 'variables': [] - }, - 'editor_info': { - 'yaml_editor_infos': {} - }, - 'group_alias': 'g222', - 'is_left': True, - 'name': 'test-nb-template', - 'id': 4, - 'kind': 1, - } - self.assertEqual(data, expected_template_dict) - - def test_get_workflow_template(self): - response = self.get_response_data( - self.get_helper('/api/v2/workflow_templates/1')) - self.assertEqual(response['name'], 't1') - - def test_delete_workflow_template(self): - response = self.delete_helper('/api/v2/workflow_templates/1') - self.assertEqual(response.status_code, HTTPStatus.OK) - response = self.delete_helper('/api/v2/workflow_templates/1') - self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) - - def test_put_workflow_template(self): - data = { - 'name': 'test_put', - 'comment': 'test-comment', - 'config': { - 'group_alias': 'g222', - 'is_left': True - } - } - response = self.put_helper('/api/v2/workflow_templates/1', data=data) - self.assertEqual(response.status_code, HTTPStatus.OK) - expected_template = WorkflowTemplate.query.filter_by(id=1).first() - self.assertEqual(expected_template.name, data['name']) - self.assertEqual(expected_template.comment, data['comment']) - self.assertEqual(expected_template.group_alias, - data['config']['group_alias']) - self.assertEqual(expected_template.is_left, data['config']['is_left']) - - def test_dict_to_workflow_definition(self): - config = { - 'variables': [{ - 'name': 'code', - 'value': '{"asdf.py": "asdf"}', - 'value_type': 'CODE' - }] - } - proto = dict_to_workflow_definition(config) - self.assertTrue(isinstance(proto.variables[0].value, str)) - - def test_get_code(self): - response = self.get_helper( - '/api/v2/codes?code_path=test/fedlearner_webconsole/test_data/code.tar.gz' - ) - self.assertEqual(response.status_code, HTTPStatus.OK) - data = json.loads(response.data) - self.assertEqual( - { - 'test/a.py': 'awefawefawefawefwaef', - 'test1/b.py': 'asdfasd', - 'c.py': '', - 'test/d.py': 'asdf' - }, data['data']) - response = self.get_helper( - '/api/v2/codes?code_path=../test_data/code.tar.g1') - self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) - - def test_get_with_kind(self): - response = self.get_helper( - '/api/v2/workflow_templates?from=preset_datajoin') - self.assertEqual(response.status_code, HTTPStatus.OK) - data = json.loads(response.data).get('data') - self.assertEqual(len(data), 1) - self.assertEqual(data[0]['name'], 't3') - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/slots_formater_test.py b/web_console_v2/api/test/fedlearner_webconsole/workflow_template/slots_formater_test.py deleted file mode 100644 index d796a5625..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/slots_formater_test.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the 'License'); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest -from fedlearner_webconsole.proto.workflow_definition_pb2 import Slot -from fedlearner_webconsole.workflow_template.slots_formatter import format_yaml, generate_yaml_template - - -class SlotFormatterTest(unittest.TestCase): - def test_format_yaml(self): - slots = {'Slot_prs': 'prs', - 'Slot_prs1': 'prs1', - 'dada': 'paopaotang'} - yaml = '${Slot_prs} a${asdf} ${Slot_prs1}' - self.assertEqual(format_yaml(yaml, **slots), - 'prs a${asdf} prs1') - - def test_generate_yaml_template(self): - slots = {'Slot_prs': Slot(reference_type=Slot.ReferenceType.DEFAULT, default='prs'), - 'Slot_prs1': Slot(reference_type=Slot.ReferenceType.PROJECT, reference='project.variables.namespace')} - yaml = '${Slot_prs} a${asdf} ${Slot_prs1}' - self.assertEqual(generate_yaml_template(yaml, slots), - 'prs a${asdf} ${project.variables.namespace}') - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/template_validator_test.py b/web_console_v2/api/test/fedlearner_webconsole/workflow_template/template_validator_test.py deleted file mode 100644 index 317a8da5e..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/template_validator_test.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import unittest -from fedlearner_webconsole.workflow_template.template_validaor\ - import check_workflow_definition -from test_template_left import make_workflow_template - - -class TemplateValidatorTest(unittest.TestCase): - - - def test_check_workflow_definition(self): - workflow_definition = make_workflow_template() - check_workflow_definition(workflow_definition) - - def test_check_more_json_wrong(self): - yaml_template_more_comma = '{"a": "aa", "b":"" ,}' - workflow_definition = make_workflow_template() - workflow_definition.job_definitions[0].yaml_template = \ - yaml_template_more_comma - with self.assertRaises(ValueError): - check_workflow_definition(workflow_definition) - - def test_check_more_placeholder(self): - workflow_definition = make_workflow_template() - yaml_template_more_placeholder = '{"a": "${workflow.variables.nobody}"}' - workflow_definition.job_definitions[0].yaml_template = \ - yaml_template_more_placeholder - with self.assertRaises(ValueError): - check_workflow_definition(workflow_definition) - - def test_check_wrong_placeholder(self): - workflow_definition = make_workflow_template() - yaml_template_wrong_placeholder = '{"a": "${workflow.xx!x.nobody}"}' - workflow_definition.job_definitions[0].yaml_template =\ - yaml_template_wrong_placeholder - with self.assertRaises(ValueError): - check_workflow_definition(workflow_definition) - - -if __name__ == '__main__': - unittest.main() diff --git a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/test_template_left.py b/web_console_v2/api/test/fedlearner_webconsole/workflow_template/test_template_left.py deleted file mode 100644 index b52bb9f10..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/test_template_left.py +++ /dev/null @@ -1,1332 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -from google.protobuf.json_format import MessageToDict -from fedlearner_webconsole.proto.workflow_definition_pb2 import ( - WorkflowDefinition, JobDefinition, JobDependency -) -from fedlearner_webconsole.proto.common_pb2 import ( - Variable -) - - -def make_workflow_template(): - workflow = WorkflowDefinition( - group_alias='test_template', - is_left=True, - variables=[ - Variable( - name='image_version', - value='v1.5-rc3', - access_mode=Variable.PEER_READABLE), - Variable( - name='num_partitions', - value='4', - access_mode=Variable.PEER_WRITABLE), - ], - job_definitions=[ - JobDefinition( - name='raw-data-job', - job_type=JobDefinition.RAW_DATA, - is_federated=False, - variables=[ - Variable( - name='input_dir', - value='/app/deploy/integrated_test/tfrecord_raw_data', - access_mode=Variable.PRIVATE), - Variable( - name='file_wildcard', - value='*.rd', - access_mode=Variable.PRIVATE), - Variable( - name='batch_size', - value='1024', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='input_format', - value='TF_RECORD', - access_mode=Variable.PRIVATE), - Variable( - name='output_format', - value='TF_RECORD', - access_mode=Variable.PRIVATE), - Variable( - name='master_cpu', - value='2000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='master_mem', - value='3Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_cpu', - value='2000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='4Gi', - access_mode=Variable.PEER_WRITABLE), - ], - yaml_template='''{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.raw-data-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Follower", - "peerSpecs": { - "Leader": { - "peerURL": "", - "authority": "" - } - }, - "cleanPodPolicy": "All", - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "containers": [ - { - "resources": { - "limits": { - "cpu": "${workflow.jobs.raw-data-job.variables.master_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.master_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.raw-data-job.variables.master_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.master_mem}" - } - }, - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_master.sh" - ], - "args": [], - "env": [ - ${system.basic_envs}, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_PORTAL_NAME", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "OUTPUT_PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "INPUT_BASE_DIR", - "value": "${workflow.jobs.raw-data-job.variables.input_dir}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/raw_data/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "RAW_DATA_PUBLISH_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_PORTAL_TYPE", - "value": "Streaming" - }, - { - "name": "FILE_WILDCARD", - "value": "${workflow.jobs.raw-data-job.variables.file_wildcard}" - } - ], - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow" - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ], - "restartPolicy": "Never" - } - }, - "pair": false, - "replicas": 1 - }, - "Worker": { - "replicas": 4, - "template": { - "spec": { - "containers": [ - { - "resources": { - "limits": { - "cpu": "${workflow.jobs.raw-data-job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.raw-data-job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.worker_mem}" - } - }, - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_worker.sh" - ], - "args": [], - "env": [ - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - ${system.basic_envs}, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "BATCH_SIZE", - "value": "${workflow.jobs.raw-data-job.variables.batch_size}" - }, - { - "name": "INPUT_DATA_FORMAT", - "value": "${workflow.jobs.raw-data-job.variables.input_format}" - }, - { - "name": "COMPRESSED_TYPE", - "value": "" - }, - { - "name": "OUTPUT_DATA_FORMAT", - "value": "${workflow.jobs.raw-data-job.variables.output_format}" - } - ], - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow" - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ], - "restartPolicy": "Never" - } - }, - "pair": false - } - } - } -} - ''' - ), - JobDefinition( - name='data-join-job', - job_type=JobDefinition.DATA_JOIN, - is_federated=True, - variables=[ - Variable( - name='master_cpu', - value='2000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='master_mem', - value='3Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_cpu', - value='4000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='4Gi', - access_mode=Variable.PEER_WRITABLE), - ], - dependencies=[ - JobDependency(source='raw-data-job') - ], - yaml_template=''' -{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.data-join-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Leader", - "cleanPodPolicy": "All", - "peerSpecs": { - "Follower": { - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80", - "authority": "${project.participants[0].egress_domain}", - "extraHeaders": { - "x-host": "default.fedlearner.operator" - } - } - }, - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "leader" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data-join-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "BATCH_MODE", - "value": "--batch_mode" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "START_TIME", - "value": "0" - }, - { - "name": "END_TIME", - "value": "999999999999" - }, - { - "name": "NEGATIVE_SAMPLING_RATE", - "value": "1.0" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/data_join/run_data_join_master.sh" - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.data-join-job.variables.master_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.master_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.data-join-job.variables.master_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.master_mem}" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": 1 - }, - "Worker": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "leader" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data-join-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_BLOCK_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "DATA_BLOCK_DUMP_THRESHOLD", - "value": "65536" - }, - { - "name": "EXAMPLE_ID_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "EXAMPLE_ID_DUMP_THRESHOLD", - "value": "65536" - }, - { - "name": "EXAMPLE_ID_BATCH_SIZE", - "value": "4096" - }, - { - "name": "MAX_FLYING_EXAMPLE_ID", - "value": "307152" - }, - { - "name": "MIN_MATCHING_WINDOW", - "value": "2048" - }, - { - "name": "MAX_MATCHING_WINDOW", - "value": "8192" - }, - { - "name": "RAW_DATA_ITER", - "value": "${workflow.jobs.raw-data-job.variables.output_format}" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/data_join/run_data_join_worker.sh" - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.data-join-job.variables.worker_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.data-join-job.variables.worker_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.worker_mem}" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": ${workflow.variables.num_partitions} - } - } - } -} - ''' - ), - JobDefinition( - name='train-job', - job_type=JobDefinition.NN_MODEL_TRANINING, - is_federated=True, - variables=[ - Variable( - name='code_key', - value='/app/deploy/integrated_test/code_key/criteo-train-2.tar.gz', - access_mode=Variable.PRIVATE - ) - ], - dependencies=[ - JobDependency(source='data-join-job') - ], - yaml_template=''' - { - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.train-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Follower", - "cleanPodPolicy": "All", - "peerSpecs": { - "Leader": { - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80", - "authority": "${project.participants[0].egress_domain}", - "extraHeaders": { - "x-host": "default.fedlearner.operator" - } - } - }, - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.train-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/job_output/${workflow.jobs.train-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/trainer/run_trainer_master.sh" - ], - "args": [], - "resources": { - "limits": { - "cpu": "2000m", - "memory": "2Gi" - }, - "requests": { - "cpu": "1000m", - "memory": "2Gi" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "replicas": 1, - "pair": false - }, - "PS": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.train-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/job_output/${workflow.jobs.train-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/trainer/run_trainer_ps.sh" - ], - "args": [], - "resources": { - "limits": { - "cpu": "2000m", - "memory": "4Gi" - }, - "requests": { - "cpu": "1000m", - "memory": "2Gi" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": false, - "replicas": 1 - }, - "Worker": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.train-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/job_output/${workflow.jobs.train-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "CODE_KEY", - "value": "${workflow.jobs.train-job.variables.code_key}" - }, - { - "name": "SAVE_CHECKPOINT_STEPS", - "value": "1000" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - }, - { - "containerPort": 50052, - "name": "tf-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/trainer/run_trainer_worker.sh" - ], - "resources": { - "limits": { - "cpu": "2000m", - "memory": "4Gi" - }, - "requests": { - "cpu": "1000m", - "memory": "2Gi" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": ${workflow.variables.num_partitions} - } - } - } -} - ''' - ) - ]) - - return workflow - - -import json - -if __name__ == '__main__': - print(json.dumps(MessageToDict( - make_workflow_template(), - preserving_proto_field_name=True, - including_default_value_fields=True))) diff --git a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/test_template_right.py b/web_console_v2/api/test/fedlearner_webconsole/workflow_template/test_template_right.py deleted file mode 100644 index 052a91d8d..000000000 --- a/web_console_v2/api/test/fedlearner_webconsole/workflow_template/test_template_right.py +++ /dev/null @@ -1,1352 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -from google.protobuf.json_format import MessageToDict -from fedlearner_webconsole.proto.workflow_definition_pb2 import ( - WorkflowDefinition, JobDefinition, JobDependency -) -from fedlearner_webconsole.proto.common_pb2 import ( - Variable -) - - -def make_workflow_template(): - workflow = WorkflowDefinition( - group_alias='test_template', - is_left=False, - variables=[ - Variable( - name='image_version', - value='v1.5-rc3', - access_mode=Variable.PEER_READABLE), - Variable( - name='num_partitions', - value='4', - access_mode=Variable.PEER_WRITABLE), - ], - job_definitions=[ - JobDefinition( - name='raw-data-job', - job_type=JobDefinition.RAW_DATA, - is_federated=False, - variables=[ - Variable( - name='input_dir', - value='/app/deploy/integrated_test/tfrecord_raw_data', - access_mode=Variable.PRIVATE), - Variable( - name='file_wildcard', - value='*.rd', - access_mode=Variable.PRIVATE), - Variable( - name='batch_size', - value='1024', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='input_format', - value='TF_RECORD', - access_mode=Variable.PRIVATE), - Variable( - name='output_format', - value='TF_RECORD', - access_mode=Variable.PRIVATE), - Variable( - name='master_cpu', - value='2000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='master_mem', - value='3Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_cpu', - value='2000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='4Gi', - access_mode=Variable.PEER_WRITABLE), - ], - yaml_template='''{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.raw-data-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Follower", - "peerSpecs": { - "Leader": { - "peerURL": "", - "authority": "" - } - }, - "cleanPodPolicy": "All", - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "containers": [ - { - "resources": { - "limits": { - "cpu": "${workflow.jobs.raw-data-job.variables.master_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.master_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.raw-data-job.variables.master_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.master_mem}" - } - }, - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_master.sh" - ], - "args": [], - "env": [ - ${system.basic_envs}, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_PORTAL_NAME", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "OUTPUT_PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "INPUT_BASE_DIR", - "value": "${workflow.jobs.raw-data-job.variables.input_dir}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/raw_data/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "RAW_DATA_PUBLISH_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_PORTAL_TYPE", - "value": "Streaming" - }, - { - "name": "FILE_WILDCARD", - "value": "${workflow.jobs.raw-data-job.variables.file_wildcard}" - } - ], - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow" - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ], - "restartPolicy": "Never" - } - }, - "pair": false, - "replicas": 1 - }, - "Worker": { - "replicas": 4, - "template": { - "spec": { - "containers": [ - { - "resources": { - "limits": { - "cpu": "${workflow.jobs.raw-data-job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.raw-data-job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.worker_mem}" - } - }, - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_worker.sh" - ], - "args": [], - "env": [ - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - ${system.basic_envs}, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "BATCH_SIZE", - "value": "${workflow.jobs.raw-data-job.variables.batch_size}" - }, - { - "name": "INPUT_DATA_FORMAT", - "value": "${workflow.jobs.raw-data-job.variables.input_format}" - }, - { - "name": "COMPRESSED_TYPE", - "value": "" - }, - { - "name": "OUTPUT_DATA_FORMAT", - "value": "${workflow.jobs.raw-data-job.variables.output_format}" - } - ], - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow" - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ], - "restartPolicy": "Never" - } - }, - "pair": false - } - } - } -} - ''' - ), - JobDefinition( - name='data-join-job', - job_type=JobDefinition.DATA_JOIN, - is_federated=True, - variables=[ - Variable( - name='master_cpu', - value='2000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='master_mem', - value='2Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_cpu', - value='3000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='4Gi', - access_mode=Variable.PEER_WRITABLE), - ], - dependencies=[ - JobDependency(source='raw-data-job') - ], - yaml_template=''' -{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.data-join-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Follower", - "cleanPodPolicy": "All", - "peerSpecs": { - "Leader": { - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80", - "authority": "${project.participants[0].egress_domain}", - "extraHeaders": { - "x-host": "default.fedlearner.operator" - } - } - }, - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data-join-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "BATCH_MODE", - "value": "--batch_mode" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "START_TIME", - "value": "0" - }, - { - "name": "END_TIME", - "value": "999999999999" - }, - { - "name": "NEGATIVE_SAMPLING_RATE", - "value": "1.0" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_SOURCE_NAME", - "value": "${workflow.jobs.data-join-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/data_join/run_data_join_master.sh" - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.data-join-job.variables.master_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.master_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.data-join-job.variables.master_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.master_mem}" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": 1 - }, - "Worker": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data-join-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_BLOCK_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "DATA_BLOCK_DUMP_THRESHOLD", - "value": "65536" - }, - { - "name": "EXAMPLE_ID_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "EXAMPLE_ID_DUMP_THRESHOLD", - "value": "65536" - }, - { - "name": "EXAMPLE_ID_BATCH_SIZE", - "value": "4096" - }, - { - "name": "MAX_FLYING_EXAMPLE_ID", - "value": "307152" - }, - { - "name": "MIN_MATCHING_WINDOW", - "value": "2048" - }, - { - "name": "MAX_MATCHING_WINDOW", - "value": "8192" - }, - { - "name": "RAW_DATA_ITER", - "value": "${workflow.jobs.raw-data-job.variables.output_format}" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "DATA_SOURCE_NAME", - "value": "${workflow.jobs.raw-data-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/data_join/run_data_join_worker.sh" - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.data-join-job.variables.worker_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.data-join-job.variables.worker_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.worker_mem}" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": ${workflow.variables.num_partitions} - } - } - } -} - ''' - ), - JobDefinition( - name='train-job', - job_type=JobDefinition.NN_MODEL_TRANINING, - is_federated=True, - variables=[ - Variable( - name='code_key', - value='/app/deploy/integrated_test/code_key/criteo-train-2.tar.gz', - access_mode=Variable.PRIVATE - ) - ], - dependencies=[ - JobDependency(source='data-join-job') - ], - yaml_template=''' - { - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.train-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Leader", - "cleanPodPolicy": "All", - "peerSpecs": { - "Follower": { - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80", - "authority": "${project.participants[0].egress_domain}", - "extraHeaders": { - "x-host": "default.fedlearner.operator" - } - } - }, - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "leader" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.train-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/job_output/${workflow.jobs.train-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "TRAINING_NAME", - "value": "${workflow.jobs.train-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/trainer/run_trainer_master.sh" - ], - "args": [], - "resources": { - "limits": { - "cpu": "2000m", - "memory": "2Gi" - }, - "requests": { - "cpu": "2000m", - "memory": "2Gi" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "replicas": 1, - "pair": false - }, - "PS": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "leader" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.train-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/job_output/${workflow.jobs.train-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "TRAINING_NAME", - "value": "${workflow.jobs.train-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/trainer/run_trainer_ps.sh" - ], - "args": [], - "resources": { - "limits": { - "cpu": "2000m", - "memory": "4Gi" - }, - "requests": { - "cpu": "2000m", - "memory": "4Gi" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": false, - "replicas": 1 - }, - "Worker": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "POD_IP", - "valueFrom": { - "fieldRef": { - "fieldPath": "status.podIP" - } - } - }, - { - "name": "POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "ROLE", - "value": "leader" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.train-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/job_output/${workflow.jobs.train-job.name}" - }, - { - "name": "CPU_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.cpu" - } - } - }, - { - "name": "MEM_REQUEST", - "valueFrom": { - "resourceFieldRef": { - "resource": "requests.memory" - } - } - }, - { - "name": "CPU_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.cpu" - } - } - }, - { - "name": "MEM_LIMIT", - "valueFrom": { - "resourceFieldRef": { - "resource": "limits.memory" - } - } - }, - { - "name": "ETCD_NAME", - "value": "fedlearner" - }, - { - "name": "ETCD_ADDR", - "value": "fedlearner-stack-etcd.default.svc.cluster.local:2379" - }, - { - "name": "ETCD_BASE_DIR", - "value": "fedlearner" - }, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "CODE_KEY", - "value": "${workflow.jobs.train-job.variables.code_key}" - }, - { - "name": "SAVE_CHECKPOINT_STEPS", - "value": "1000" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "TRAINING_NAME", - "value": "${workflow.jobs.train-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - }, - { - "containerPort": 50052, - "name": "tf-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/trainer/run_trainer_worker.sh" - ], - "resources": { - "limits": { - "cpu": "2000m", - "memory": "4Gi" - }, - "requests": { - "cpu": "2000m", - "memory": "4Gi" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": ${workflow.variables.num_partitions} - } - } - } -} - ''' - ) - ]) - - return workflow - - -import json - -if __name__ == '__main__': - print(json.dumps(MessageToDict( - make_workflow_template(), - preserving_proto_field_name=True, - including_default_value_fields=True))) diff --git a/web_console_v2/api/testing/__init__.py b/web_console_v2/api/testing/__init__.py index 3e28547fe..c13b80f8f 100644 --- a/web_console_v2/api/testing/__init__.py +++ b/web_console_v2/api/testing/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/web_console_v2/api/testing/common.py b/web_console_v2/api/testing/common.py index fff83b5a0..d011abd7a 100644 --- a/web_console_v2/api/testing/common.py +++ b/web_console_v2/api/testing/common.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,40 +13,33 @@ # limitations under the License. # coding: utf-8 -import contextlib import json import logging import unittest import secrets from http import HTTPStatus import multiprocessing as mp +from typing import Dict, List, Union +from unittest.mock import patch -from flask import Flask from flask_testing import TestCase -from fedlearner_webconsole.composer.composer import Composer, ComposerConfig -from fedlearner_webconsole.db import db_handler as db, get_database_uri + +from envs import Envs +from fedlearner_webconsole.auth.services import UserService +from fedlearner_webconsole.composer.composer import composer +from fedlearner_webconsole.db import db from fedlearner_webconsole.app import create_app +from fedlearner_webconsole.iam.client import create_iams_for_user from fedlearner_webconsole.initial_db import initial_db +from fedlearner_webconsole.participant.models import Participant from fedlearner_webconsole.scheduler.scheduler import scheduler -# NOTE: the following models imported is intended to be analyzed by SQLAlchemy -from fedlearner_webconsole.auth.models import Role, User, State -from fedlearner_webconsole.composer.models import SchedulerItem, SchedulerRunner, OptimisticLock -from fedlearner_webconsole.utils.base64 import base64encode - +from fedlearner_webconsole.utils.pp_base64 import base64encode +from testing.no_web_server_test_case import NoWebServerTestCase -def create_all_tables(database_uri: str = None): - if database_uri: - db.rebind(database_uri) - # If there's a db file due to some reason, remove it first. - if db.metadata.tables.values(): - db.drop_all() - db.create_all() +class BaseTestCase(NoWebServerTestCase, TestCase): - -class BaseTestCase(TestCase): - class Config(object): - SQLALCHEMY_DATABASE_URI = get_database_uri() + class Config(NoWebServerTestCase.Config): SQLALCHEMY_TRACK_MODIFICATIONS = False JWT_SECRET_KEY = secrets.token_urlsafe(64) PROPAGATE_EXCEPTIONS = True @@ -54,32 +47,35 @@ class Config(object): TESTING = True ENV = 'development' GRPC_LISTEN_PORT = 1990 - START_COMPOSER = False + START_K8S_WATCHER = False def create_app(self): - create_all_tables(self.__class__.Config.SQLALCHEMY_DATABASE_URI) - initial_db() app = create_app(self.__class__.Config) return app def setUp(self): super().setUp() + initial_db() self.signin_helper() + with db.session_scope() as session: + users = UserService(session).get_all_users() + for user in users: + create_iams_for_user(user) def tearDown(self): self.signout_helper() scheduler.stop() - db.drop_all() + composer.stop() super().tearDown() - def get_response_data(self, response): + def get_response_data(self, response) -> dict: return json.loads(response.data).get('data') def signin_as_admin(self): self.signout_helper() - self.signin_helper(username='admin', password='fl@123.') + self.signin_helper(username='admin', password='fl@12345.') - def signin_helper(self, username='ada', password='fl@123.'): + def signin_helper(self, username='ada', password='fl@12345.'): resp = self.client.post('/api/v2/auth/signin', data=json.dumps({ 'username': username, @@ -105,7 +101,7 @@ def _get_headers(self, use_auth=True): def get_helper(self, url, use_auth=True): return self.client.get(url, headers=self._get_headers(use_auth)) - def post_helper(self, url, data, use_auth=True): + def post_helper(self, url, data=None, use_auth=True): return self.client.post(url, data=json.dumps(data), content_type='application/json', @@ -126,44 +122,43 @@ def patch_helper(self, url, data, use_auth=True): def delete_helper(self, url, use_auth=True): return self.client.delete(url, headers=self._get_headers(use_auth)) + def assertResponseDataEqual(self, response, expected_data: Union[Dict, List], ignore_fields=None): + """Asserts if the data in response equals to expected_data. + + It's actually a comparison between two dicts, if ignore_fields is + specified then we ignore those fields in response.""" + actual_data = self.get_response_data(response) + assert type(actual_data) is type(expected_data), 'different type for responce data and expceted data!' + self.assertPartiallyEqual(actual_data, expected_data, ignore_fields) + def setup_project(self, role, peer_port): if role == 'leader': peer_role = 'follower' else: peer_role = 'leader' - + patch.object(Envs, 'DEBUG', True).start() + patch.object(Envs, 'GRPC_SERVER_URL', f'127.0.0.1:{peer_port}').start() name = 'test-project' - config = { - 'participants': [{ - 'name': f'party_{peer_role}', - 'url': f'127.0.0.1:{peer_port}', - 'domain_name': f'fl-{peer_role}.com' - }], - 'variables': [{ - 'name': 'EGRESS_URL', - 'value': f'127.0.0.1:{peer_port}' - }] - } - create_response = self.post_helper('/api/v2/projects', - data={ - 'name': name, - 'config': config, - }) - self.assertEqual(create_response.status_code, HTTPStatus.OK) + with db.session_scope() as session: + participant = Participant(name=f'party_{peer_role}', + host='127.0.0.1', + port=peer_port, + domain_name=f'fl-{peer_role}.com') + session.add(participant) + session.commit() + + create_response = self.post_helper('/api/v2/projects', data={ + 'name': name, + 'participant_ids': [1], + }) + self.assertEqual(create_response.status_code, HTTPStatus.CREATED) return json.loads(create_response.data).get('data') - @contextlib.contextmanager - def composer_scope(self, config: ComposerConfig): - with self.app.app_context(): - composer = Composer(config=config) - composer.run(db.engine) - yield composer - composer.stop() - class TestAppProcess(mp.get_context('spawn').Process): + def __init__(self, test_class, method, config=None, result_queue=None): - super(TestAppProcess, self).__init__() + super().__init__() self._test_class = test_class self._method = method self._app_config = config @@ -177,10 +172,7 @@ def run(self): for h in logging.getLogger().handlers[:]: logging.getLogger().removeHandler(h) h.close() - logging.basicConfig( - level=logging.DEBUG, - format= - 'SPAWN:%(filename)s %(lineno)s %(levelname)s - %(message)s') + logging.basicConfig(level=logging.DEBUG, format='SPAWN:%(filename)s %(lineno)s %(levelname)s - %(message)s') if self._app_config: self._test_class.Config = self._app_config test = self._test_class(self._method) @@ -194,6 +186,7 @@ def new_tear_down(*args, **kwargs): for other_q in self.other_process_queues: other_q.put(None) # check if the test success, than wait others to finish + # pylint: disable=protected-access if not test._outcome.errors: # wait for others for i in range(len(self.other_process_queues)): @@ -207,45 +200,30 @@ def new_tear_down(*args, **kwargs): result = suite.run(result) if result.errors: for method, err in result.errors: - print( - '======================================================================' - ) - - print('ERROR:', method) - print( - '----------------------------------------------------------------------' - ) - print(err) - print( - '----------------------------------------------------------------------' - ) + logging.error('======================================================================') + + logging.error(f'TestAppProcess ERROR: {method}') + logging.error('----------------------------------------------------------------------') + logging.error(err) + logging.error('----------------------------------------------------------------------') if result.failures: for method, fail in result.failures: - print( - '======================================================================' - ) - print('FAIL:', method) - print( - '----------------------------------------------------------------------' - ) - print(fail) - print( - '----------------------------------------------------------------------' - ) + logging.error('======================================================================') + logging.error(f'TestAppProcess FAIL: {method}') + logging.error('----------------------------------------------------------------------') + logging.error(fail) + logging.error('----------------------------------------------------------------------') assert result.wasSuccessful() self._result_queue.put(True) - except Exception as err: - logging.error('expected happened %s', err) + except Exception: + logging.exception('exception happened') self._result_queue.put(False) raise def multi_process_test(test_list): result_queue = mp.get_context('spawn').Queue() - proc_list = [ - TestAppProcess(t['class'], t['method'], t['config'], result_queue) - for t in test_list - ] + proc_list = [TestAppProcess(t['class'], t['method'], t['config'], result_queue) for t in test_list] for p in proc_list: for other_p in proc_list: @@ -265,16 +243,3 @@ def multi_process_test(test_list): p.join() if p.exitcode != 0: raise Exception(f'Subprocess failed: number {i}') - - -class NoWebServerTestCase(unittest.TestCase): - class Config(object): - SQLALCHEMY_DATABASE_URI = get_database_uri() - - def setUp(self) -> None: - super().setUp() - create_all_tables(self.__class__.Config.SQLALCHEMY_DATABASE_URI) - - def tearDown(self) -> None: - db.drop_all() - return super().tearDown() \ No newline at end of file diff --git a/web_console_v2/api/testing/fake_file_manager.py b/web_console_v2/api/testing/fake_file_manager.py index 4a672d327..98af7e4d8 100644 --- a/web_console_v2/api/testing/fake_file_manager.py +++ b/web_console_v2/api/testing/fake_file_manager.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,12 +19,12 @@ class FakeFileManager(FileManagerBase): + def can_handle(self, path: str) -> bool: return path.startswith('fake://') - def ls(self, path: str, recursive=False) -> List[Dict]: - return [{'path': 'fake://data/f1.txt', - 'size': 0}] + def ls(self, path: str, recursive=False, include_directory=False) -> List[Dict]: + return [{'path': 'fake://data/f1.txt', 'size': 0}] def move(self, source: str, destination: str) -> bool: return source.startswith('fake://move') diff --git a/web_console_v2/api/testing/workflow_template/__init__.py b/web_console_v2/api/testing/workflow_template/__init__.py index 3e28547fe..c13b80f8f 100644 --- a/web_console_v2/api/testing/workflow_template/__init__.py +++ b/web_console_v2/api/testing/workflow_template/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. +# Copyright 2023 The FedLearner Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/web_console_v2/api/testing/workflow_template/psi_join_tree_model_no_label.py b/web_console_v2/api/testing/workflow_template/psi_join_tree_model_no_label.py deleted file mode 100644 index 88dde9379..000000000 --- a/web_console_v2/api/testing/workflow_template/psi_join_tree_model_no_label.py +++ /dev/null @@ -1,728 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import json - -from google.protobuf.json_format import MessageToDict -from fedlearner_webconsole.proto.workflow_definition_pb2 import ( - WorkflowDefinition, JobDefinition, JobDependency -) -from fedlearner_webconsole.proto.common_pb2 import ( - Variable -) - - -def make_workflow_template(): - workflow = WorkflowDefinition( - group_alias='psi_join_tree_model', - is_left=True, - variables=[ - Variable( - name='image_version', - value='v1.5-rc3', - access_mode=Variable.PEER_READABLE), - Variable( - name='num_partitions', - value='2', - access_mode=Variable.PEER_WRITABLE), - ], - job_definitions=[ - JobDefinition( - name='raw-data-job', - job_type=JobDefinition.RAW_DATA, - is_federated=False, - variables=[ - Variable( - name='input_dir', - value='/app/deploy/integrated_test/tfrecord_raw_data', - access_mode=Variable.PRIVATE), - Variable( - name='file_wildcard', - value='*.rd', - access_mode=Variable.PRIVATE), - Variable( - name='batch_size', - value='1024', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='input_format', - value='TF_RECORD', - access_mode=Variable.PRIVATE), - Variable( - name='worker_cpu', - value='2000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='4Gi', - access_mode=Variable.PEER_WRITABLE), - ], - yaml_template='''{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.raw-data-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "cleanPodPolicy": "All", - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "containers": [ - { - "resources": { - "limits": { - "cpu": "1000m", - "memory": "2Gi" - }, - "requests": { - "cpu": "1000m", - "memory": "2Gi" - } - }, - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_master.sh" - ], - "args": [], - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_PORTAL_NAME", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "OUTPUT_PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "INPUT_BASE_DIR", - "value": "${workflow.jobs.raw-data-job.variables.input_dir}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/raw_data/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "RAW_DATA_PUBLISH_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_PORTAL_TYPE", - "value": "PSI" - }, - { - "name": "FILE_WILDCARD", - "value": "${workflow.jobs.raw-data-job.variables.file_wildcard}" - } - ], - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow" - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ], - "restartPolicy": "Never" - } - }, - "pair": false, - "replicas": 1 - }, - "Worker": { - "replicas": ${workflow.variables.num_partitions}, - "template": { - "spec": { - "containers": [ - { - "resources": { - "limits": { - "cpu": "${workflow.jobs.raw-data-job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.raw-data-job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.worker_mem}" - } - }, - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_worker.sh" - ], - "args": [], - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "BATCH_SIZE", - "value": "${workflow.jobs.raw-data-job.variables.batch_size}" - }, - { - "name": "INPUT_DATA_FORMAT", - "value": "${workflow.jobs.raw-data-job.variables.input_format}" - }, - { - "name": "COMPRESSED_TYPE", - "value": "" - }, - { - "name": "OUTPUT_DATA_FORMAT", - "value": "TF_RECORD" - } - ], - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow" - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ], - "restartPolicy": "Never" - } - }, - "pair": false - } - } - } -} - ''' - ), - JobDefinition( - name='data-join-job', - job_type=JobDefinition.PSI_DATA_JOIN, - is_federated=True, - variables=[ - Variable( - name='worker_cpu', - value='4000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='4Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='rsa_public_key_path', - value='', - access_mode=Variable.PRIVATE), - ], - dependencies=[ - JobDependency(source='raw-data-job') - ], - yaml_template=''' -{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.data-join-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Follower", - "cleanPodPolicy": "All", - "peerSpecs": { - "Follower": { - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80", - "authority": "${project.participants[0].egress_domain}", - "extraHeaders": { - "x-host": "default.fedlearner.operator" - } - } - }, - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data-join-job.name}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "START_TIME", - "value": "0" - }, - { - "name": "END_TIME", - "value": "999999999999" - }, - { - "name": "NEGATIVE_SAMPLING_RATE", - "value": "1.0" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/rsa_psi/run_psi_data_join_master.sh" - ], - "resources": { - "limits": { - "cpu": "2000m", - "memory": "3Gi" - }, - "requests": { - "cpu": "2000m", - "memory": "3Gi" - } - }, - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": 1 - }, - "Worker": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data-join-job.name}" - }, - { - "name": "RSA_KEY_PATH", - "value": "${workflow.jobs.data-join-job.rsa_public_key_path}" - }, - { - "name": "PSI_RAW_DATA_ITER", - "value": "TF_RECORD" - }, - { - "name": "PSI_OUTPUT_BUILDER", - "value": "TF_RECORD" - }, - { - "name": "DATA_BLOCK_BUILDER", - "value": "TF_RECORD" - }, - { - "name": "DATA_BLOCK_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "DATA_BLOCK_DUMP_THRESHOLD", - "value": "524288" - }, - { - "name": "EXAMPLE_ID_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "EXAMPLE_ID_DUMP_THRESHOLD", - "value": "524288" - }, - { - "name": "EXAMPLE_JOINER", - "value": "SORT_RUN_JOINER" - }, - { - "name": "SIGN_RPC_TIMEOUT_MS", - "value": "128000" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/rsa_psi/run_psi_data_join_worker.sh" - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.data-join-job.variables.worker_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.data-join-job.variables.worker_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.worker_mem}" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": ${workflow.variables.num_partitions} - } - } - } -} - ''' - ), - JobDefinition( - name='train-job', - job_type=JobDefinition.TREE_MODEL_TRAINING, - is_federated=True, - variables=[ - Variable( - name='worker_cpu', - value='4000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='8Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='num_parallel', - value='4', - access_mode=Variable.PEER_WRITABLE), - ], - dependencies=[ - JobDependency(source='data-join-job') - ], - yaml_template=''' - { - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.train-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Follower", - "cleanPodPolicy": "All", - "peerSpecs": { - "Leader": { - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80", - "authority": "${project.participants[0].egress_domain}", - "extraHeaders": { - "x-host": "default.fedlearner.operator" - } - } - }, - "flReplicaSpecs": { - "Worker": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.train-job.name}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/job_output/${workflow.jobs.train-job.name}" - }, - { - "name": "MODE", - "value": "train" - }, - { - "name": "NUM_PARALLEL", - "value": "${workflow.jobs.train-job.variables.num_parallel}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/trainer/run_tree_worker.sh" - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.train-job.variables.worker_cpu}", - "memory": "${workflow.jobs.train-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.train-job.variables.worker_cpu}", - "memory": "${workflow.jobs.train-job.variables.worker_mem}" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": 1 - } - } - } -} - ''' - ) - ]) - - return workflow - - -if __name__ == '__main__': - print(json.dumps(MessageToDict( - make_workflow_template(), - preserving_proto_field_name=True, - including_default_value_fields=True))) diff --git a/web_console_v2/api/testing/workflow_template/psi_join_tree_model_with_label.py b/web_console_v2/api/testing/workflow_template/psi_join_tree_model_with_label.py deleted file mode 100644 index a872671b3..000000000 --- a/web_console_v2/api/testing/workflow_template/psi_join_tree_model_with_label.py +++ /dev/null @@ -1,748 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import json - -from google.protobuf.json_format import MessageToDict -from fedlearner_webconsole.proto.workflow_definition_pb2 import ( - WorkflowDefinition, JobDefinition, JobDependency -) -from fedlearner_webconsole.proto.common_pb2 import ( - Variable -) - - -def make_workflow_template(): - workflow = WorkflowDefinition( - group_alias='psi_join_tree_model', - is_left=False, - variables=[ - Variable( - name='image_version', - value='v1.5-rc3', - access_mode=Variable.PEER_READABLE), - Variable( - name='num_partitions', - value='2', - access_mode=Variable.PEER_WRITABLE), - ], - job_definitions=[ - JobDefinition( - name='raw-data-job', - job_type=JobDefinition.RAW_DATA, - is_federated=False, - variables=[ - Variable( - name='input_dir', - value='/app/deploy/integrated_test/tfrecord_raw_data', - access_mode=Variable.PRIVATE), - Variable( - name='file_wildcard', - value='*.rd', - access_mode=Variable.PRIVATE), - Variable( - name='batch_size', - value='1024', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='input_format', - value='TF_RECORD', - access_mode=Variable.PRIVATE), - Variable( - name='worker_cpu', - value='2000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='4Gi', - access_mode=Variable.PEER_WRITABLE), - ], - yaml_template='''{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.raw-data-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "cleanPodPolicy": "All", - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "containers": [ - { - "resources": { - "limits": { - "cpu": "1000m", - "memory": "2Gi" - }, - "requests": { - "cpu": "1000m", - "memory": "2Gi" - } - }, - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_master.sh" - ], - "args": [], - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_PORTAL_NAME", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "OUTPUT_PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "INPUT_BASE_DIR", - "value": "${workflow.jobs.raw-data-job.variables.input_dir}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/raw_data/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "RAW_DATA_PUBLISH_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "DATA_PORTAL_TYPE", - "value": "PSI" - }, - { - "name": "FILE_WILDCARD", - "value": "${workflow.jobs.raw-data-job.variables.file_wildcard}" - } - ], - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow" - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ], - "restartPolicy": "Never" - } - }, - "pair": false, - "replicas": 1 - }, - "Worker": { - "replicas": ${workflow.variables.num_partitions}, - "template": { - "spec": { - "containers": [ - { - "resources": { - "limits": { - "cpu": "${workflow.jobs.raw-data-job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.raw-data-job.variables.worker_cpu}", - "memory": "${workflow.jobs.raw-data-job.variables.worker_mem}" - } - }, - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "command": [ - "/app/deploy/scripts/data_portal/run_data_portal_worker.sh" - ], - "args": [], - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.raw-data-job.name}" - }, - { - "name": "BATCH_SIZE", - "value": "${workflow.jobs.raw-data-job.variables.batch_size}" - }, - { - "name": "INPUT_DATA_FORMAT", - "value": "${workflow.jobs.raw-data-job.variables.input_format}" - }, - { - "name": "COMPRESSED_TYPE", - "value": "" - }, - { - "name": "OUTPUT_DATA_FORMAT", - "value": "TF_RECORD" - } - ], - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow" - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ], - "restartPolicy": "Never" - } - }, - "pair": false - } - } - } -} - ''' - ), - JobDefinition( - name='data-join-job', - job_type=JobDefinition.PSI_DATA_JOIN, - is_federated=True, - variables=[ - Variable( - name='worker_cpu', - value='4000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='4Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='rsa_private_key_path', - value='', - access_mode=Variable.PRIVATE), - ], - dependencies=[ - JobDependency(source='raw-data-job') - ], - yaml_template=''' -{ - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.data-join-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Leader", - "cleanPodPolicy": "All", - "peerSpecs": { - "Follower": { - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80", - "authority": "${project.participants[0].egress_domain}", - "extraHeaders": { - "x-host": "default.fedlearner.operator" - } - } - }, - "flReplicaSpecs": { - "Master": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "ROLE", - "value": "leader" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data-join-job.name}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - }, - { - "name": "START_TIME", - "value": "0" - }, - { - "name": "END_TIME", - "value": "999999999999" - }, - { - "name": "NEGATIVE_SAMPLING_RATE", - "value": "1.0" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/rsa_psi/run_psi_data_join_master.sh" - ], - "resources": { - "limits": { - "cpu": "2000m", - "memory": "3Gi" - }, - "requests": { - "cpu": "2000m", - "memory": "3Gi" - } - }, - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": 1 - }, - "Worker": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "ROLE", - "value": "follower" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.data-join-job.name}" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/data_source/${workflow.jobs.data-join-job.name}" - }, - { - "name": "RSA_KEY_PATH", - "value": "${workflow.jobs.data-join-job.rsa_private_key_path}" - }, - { - "name": "RSA_PRIVATE_KEY_PATH", - "value": "${workflow.jobs.data-join-job.rsa_private_key_path}" - }, - { - "name": "PSI_RAW_DATA_ITER", - "value": "TF_RECORD" - }, - { - "name": "PSI_OUTPUT_BUILDER", - "value": "TF_RECORD" - }, - { - "name": "DATA_BLOCK_BUILDER", - "value": "TF_RECORD" - }, - { - "name": "DATA_BLOCK_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "DATA_BLOCK_DUMP_THRESHOLD", - "value": "524288" - }, - { - "name": "EXAMPLE_ID_DUMP_INTERVAL", - "value": "600" - }, - { - "name": "EXAMPLE_ID_DUMP_THRESHOLD", - "value": "524288" - }, - { - "name": "EXAMPLE_JOINER", - "value": "SORT_RUN_JOINER" - }, - { - "name": "SIGN_RPC_TIMEOUT_MS", - "value": "128000" - }, - { - "name": "RAW_DATA_SUB_DIR", - "value": "portal_publish_dir/${workflow.jobs.raw-data-job.name}" - }, - { - "name": "PARTITION_NUM", - "value": "${workflow.variables.num_partitions}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/rsa_psi/run_psi_data_join_worker.sh" - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.data-join-job.variables.worker_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.data-join-job.variables.worker_cpu}", - "memory": "${workflow.jobs.data-join-job.variables.worker_mem}" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": ${workflow.variables.num_partitions} - } - } - } -} - ''' - ), - JobDefinition( - name='train-job', - job_type=JobDefinition.TREE_MODEL_TRAINING, - is_federated=True, - variables=[ - Variable( - name='worker_cpu', - value='4000m', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='worker_mem', - value='8Gi', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='send_scores_to_follower', - value='True', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='send_metrics_to_follower', - value='True', - access_mode=Variable.PEER_WRITABLE), - Variable( - name='num_parallel', - value='4', - access_mode=Variable.PEER_WRITABLE), - ], - dependencies=[ - JobDependency(source='data-join-job') - ], - yaml_template=''' - { - "apiVersion": "fedlearner.k8s.io/v1alpha1", - "kind": "FLApp", - "metadata": { - "name": "${workflow.jobs.train-job.name}", - "namespace": "${project.variables.namespace}" - }, - "spec": { - "role": "Leader", - "cleanPodPolicy": "All", - "peerSpecs": { - "Leader": { - "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80", - "authority": "${project.participants[0].egress_domain}", - "extraHeaders": { - "x-host": "default.fedlearner.operator" - } - } - }, - "flReplicaSpecs": { - "Worker": { - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "env": [ - ${system.basic_envs}, - { - "name": "EGRESS_URL", - "value": "fedlearner-stack-ingress-nginx-controller.default.svc.cluster.local:80" - }, - { - "name": "EGRESS_HOST", - "value": "${project.participants[0].egress_host}" - }, - { - "name": "EGRESS_DOMAIN", - "value": "${project.participants[0].egress_domain}" - }, - { - "name": "APPLICATION_ID", - "value": "${workflow.jobs.train-job.name}" - }, - { - "name": "STORAGE_ROOT_PATH", - "value": "${project.variables.storage_root_dir}" - }, - { - "name": "ROLE", - "value": "leader" - }, - { - "name": "OUTPUT_BASE_DIR", - "value": "${project.variables.storage_root_dir}/job_output/${workflow.jobs.train-job.name}" - }, - { - "name": "MODE", - "value": "train" - }, - { - "name": "SEND_SCORES_TO_FOLLOWER", - "value": "${workflow.jobs.train-job.variables.send_scores_to_follower}" - }, - { - "name": "SEND_METRICS_TO_FOLLOWER", - "value": "${workflow.jobs.train-job.variables.send_metrics_to_follower}" - }, - { - "name": "NUM_PARALLEL", - "value": "${workflow.jobs.train-job.variables.num_parallel}" - }, - { - "name": "DATA_SOURCE", - "value": "${workflow.jobs.data-join-job.name}" - } - ], - "imagePullPolicy": "IfNotPresent", - "name": "tensorflow", - "volumeMounts": [ - { - "mountPath": "/data", - "name": "data" - } - ], - "image": "artifact.bytedance.com/fedlearner/fedlearner:${workflow.variables.image_version}", - "ports": [ - { - "containerPort": 50051, - "name": "flapp-port" - } - ], - "command": [ - "/app/deploy/scripts/wait4pair_wrapper.sh" - ], - "args": [ - "/app/deploy/scripts/trainer/run_tree_worker.sh" - ], - "resources": { - "limits": { - "cpu": "${workflow.jobs.train-job.variables.worker_cpu}", - "memory": "${workflow.jobs.train-job.variables.worker_mem}" - }, - "requests": { - "cpu": "${workflow.jobs.train-job.variables.worker_cpu}", - "memory": "${workflow.jobs.train-job.variables.worker_mem}" - } - } - } - ], - "imagePullSecrets": [ - { - "name": "regcred" - } - ], - "volumes": [ - { - "persistentVolumeClaim": { - "claimName": "pvc-fedlearner-default" - }, - "name": "data" - } - ] - } - }, - "pair": true, - "replicas": 1 - } - } - } -} - ''' - ) - ]) - - return workflow - - -if __name__ == '__main__': - print(json.dumps(MessageToDict( - make_workflow_template(), - preserving_proto_field_name=True, - including_default_value_fields=True))) diff --git a/web_console_v2/api/tools/local_runner/app_a.py b/web_console_v2/api/tools/local_runner/app_a.py deleted file mode 100644 index a9f9cb314..000000000 --- a/web_console_v2/api/tools/local_runner/app_a.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import logging - -from envs import Envs -from fedlearner_webconsole.app import create_app -from tools.local_runner.initial_db import init_db - -BASE_DIR = os.path.abspath(os.path.dirname(__file__)) - - -class Config(object): - SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'app_a.db') - MYSQL_CHARSET = 'utf8mb4' - SQLALCHEMY_TRACK_MODIFICATIONS = False - JSON_AS_ASCII = False - JWT_SECRET_KEY = 'secret' - PROPAGATE_EXCEPTIONS = True - LOGGING_LEVEL = logging.INFO - GRPC_LISTEN_PORT = 1993 - JWT_ACCESS_TOKEN_EXPIRES = 86400 - STORAGE_ROOT = Envs.STORAGE_ROOT - - START_GRPC_SERVER = True - START_SCHEDULER = True - START_COMPOSER = True - - -app = create_app(Config) - - -@app.cli.command('create-db') -def create_db(): - init_db(1991, 'fl-demo2.com') diff --git a/web_console_v2/api/tools/local_runner/app_b.py b/web_console_v2/api/tools/local_runner/app_b.py deleted file mode 100644 index e9f7cb612..000000000 --- a/web_console_v2/api/tools/local_runner/app_b.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 -import os -import logging - -from envs import Envs -from fedlearner_webconsole.app import create_app -from tools.local_runner.initial_db import init_db - -BASE_DIR = os.path.abspath(os.path.dirname(__file__)) - - -class Config(object): - SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'app_b.db') - MYSQL_CHARSET = 'utf8mb4' - SQLALCHEMY_TRACK_MODIFICATIONS = False - JSON_AS_ASCII = False - JWT_SECRET_KEY = 'secret' - PROPAGATE_EXCEPTIONS = True - LOGGING_LEVEL = logging.INFO - GRPC_LISTEN_PORT = 1991 - JWT_ACCESS_TOKEN_EXPIRES = 86400 - STORAGE_ROOT = Envs.STORAGE_ROOT - - START_GRPC_SERVER = True - START_SCHEDULER = True - START_COMPOSER = False - - -app = create_app(Config) - - -@app.cli.command('create-db') -def create_db(): - init_db(1993, 'fl-demo1.com') diff --git a/web_console_v2/api/tools/local_runner/initial_db.py b/web_console_v2/api/tools/local_runner/initial_db.py deleted file mode 100644 index 44a510ffd..000000000 --- a/web_console_v2/api/tools/local_runner/initial_db.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2021 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# coding: utf-8 - - - -from google.protobuf.json_format import ParseDict - -from fedlearner_webconsole.db import db -from fedlearner_webconsole.auth.models import User -from fedlearner_webconsole.proto import project_pb2 -from fedlearner_webconsole.project.models import Project - - -def init_db(port, domain_name): - db.create_all() - user = User(username='ada') - user.set_password('ada') - db.session.add(user) - config = { - 'name': 'test', - 'participants': [ - { - 'name': f'{domain_name}', - 'url': f'127.0.0.1:{port}', - 'domain_name': f'{domain_name}', - 'grpc_spec': { - 'authority': f'{domain_name[:-4]}-client-auth.com' - } - } - ], - 'variables': [ - { - 'name': 'namespace', - 'value': 'default' - }, - { - 'name': 'storage_root_dir', - 'value': '/data' - }, - { - 'name': 'EGRESS_URL', - 'value': f'127.0.0.1:{port}' - } - - ] - } - project = Project(name='test', - config=ParseDict(config, - project_pb2.Project()).SerializeToString()) - db.session.add(project) - db.session.commit() diff --git a/web_console_v2/api/tools/local_runner/run_a.sh b/web_console_v2/api/tools/local_runner/run_a.sh deleted file mode 100755 index 6622b5f4c..000000000 --- a/web_console_v2/api/tools/local_runner/run_a.sh +++ /dev/null @@ -1,11 +0,0 @@ -export PYTHONPATH=$PYTHONPATH:"../../" -export FLASK_APP=app_a:app -export FLASK_ENV=development -flask create-db -export K8S_CONFIG_PATH=$1 -export FEDLEARNER_WEBCONSOLE_POLLING_INTERVAL=10 -export SQLALCHEMY_DATABASE_URI="sqlite:///app_a.db" -export FEATURE_MODEL_WORKFLOW_HOOK=True -export FEATURE_MODEL_K8S_HOOK=True -export ES_READ_HOST=172.21.8.76 # aliyun-demo1 fedlearner-stack-elasticsearch-client -flask run --host=0.0.0.0 --no-reload --eager-loading -p 9001 diff --git a/web_console_v2/api/tools/local_runner/run_b.sh b/web_console_v2/api/tools/local_runner/run_b.sh deleted file mode 100755 index 340f94763..000000000 --- a/web_console_v2/api/tools/local_runner/run_b.sh +++ /dev/null @@ -1,11 +0,0 @@ -export PYTHONPATH=$PYTHONPATH:"../../" -export FLASK_APP=app_b:app -export FLASK_ENV=development -flask create-db -export K8S_CONFIG_PATH=$1 -export FEDLEARNER_WEBCONSOLE_POLLING_INTERVAL=1 -export SQLALCHEMY_DATABASE_URI="sqlite:///app_b.db" -export FEATURE_MODEL_WORKFLOW_HOOK=True -export FEATURE_MODEL_K8S_HOOK=True -export ES_READ_HOST=172.21.14.199 # aliyun-demo2 fedlearner-stack-elasticsearch-client -flask run --host=0.0.0.0 --no-reload --eager-loading -p 9002 diff --git a/web_console_v2/client/.eslintignore b/web_console_v2/client/.eslintignore index 896035c6a..5b842b7c4 100644 --- a/web_console_v2/client/.eslintignore +++ b/web_console_v2/client/.eslintignore @@ -1,3 +1,9 @@ .vscode/* config/* scripts/* +src/libs/*.js +dumi/* +build/* +coverage/* +node_modules/* +public/ diff --git a/web_console_v2/client/.eslintrc.js b/web_console_v2/client/.eslintrc.js index a3677e834..b81b908a8 100644 --- a/web_console_v2/client/.eslintrc.js +++ b/web_console_v2/client/.eslintrc.js @@ -33,6 +33,13 @@ module.exports = { '@typescript-eslint/semi': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', 'prettier/prettier': ['warn', {}, { usePrettierrc: true }], + 'prefer-const': [ + 'error', + { + destructuring: 'any', + ignoreReadBeforeAssign: false, + }, + ], }, overrides: [ { diff --git a/web_console_v2/client/.npmrc b/web_console_v2/client/.npmrc index b8bf3987d..3c1748897 100644 --- a/web_console_v2/client/.npmrc +++ b/web_console_v2/client/.npmrc @@ -1,2 +1,2 @@ ; Force to use official registry -registry=https://registry.npmjs.org/ +registry=https://registry.npmjs.org diff --git a/web_console_v2/client/config/env.js b/web_console_v2/client/config/env.js index b65d2ed8a..5259ff27f 100644 --- a/web_console_v2/client/config/env.js +++ b/web_console_v2/client/config/env.js @@ -86,6 +86,9 @@ function getClientEnvironment(publicUrl) { // which is why it's disabled by default. // It is defined here so it is available in the webpackHotDevClient. FAST_REFRESH: process.env.FAST_REFRESH !== 'false', + // Theme env variable is used in src/styles/index.js. + // It is defined which theme we can use. + THEME: process.env.THEME || 'normal', }, ); // Stringify all values so we can feed into webpack DefinePlugin diff --git a/web_console_v2/client/config/webpack.config.js b/web_console_v2/client/config/webpack.config.js index bdf53a75e..ff172fc9d 100644 --- a/web_console_v2/client/config/webpack.config.js +++ b/web_console_v2/client/config/webpack.config.js @@ -25,7 +25,8 @@ const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin'); const ForkTsCheckerWebpackPlugin = require('react-dev-utils/ForkTsCheckerWebpackPlugin'); const typescriptFormatter = require('react-dev-utils/typescriptFormatter'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); -const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin'); +const ArcoWebpackPlugin = require('@arco-design/webpack-plugin'); +const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); const postcssNormalize = require('postcss-normalize'); @@ -70,6 +71,11 @@ const hasJsxRuntime = (() => { } })(); +const themeEnvToArcoThemeLibNameMap = { + normal: '@arco-themes/react-privacy-computing', + bioland: '@arco-themes/react-privacy-computing-bioland', +}; + // This is the production and development configuration. // It is focused on developer experience, fast rebuilds, and a minimal bundle. module.exports = function (webpackEnv) { @@ -178,7 +184,7 @@ module.exports = function (webpackEnv) { ); }; - return { + const config = { mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development', // Stop compilation early in production bail: isEnvProduction, @@ -230,6 +236,7 @@ module.exports = function (webpackEnv) { minimizer: [ // This is only used in production mode new TerserPlugin({ + extractComments: false, terserOptions: { parse: { // We want terser to parse ecma 8 code. However, we don't want it @@ -252,6 +259,8 @@ module.exports = function (webpackEnv) { // Pending further investigation: // https://github.com/terser-js/terser/issues/120 inline: 2, + drop_console: true, + drop_debugger: true, }, mangle: { safari10: true, @@ -295,6 +304,24 @@ module.exports = function (webpackEnv) { splitChunks: { chunks: 'all', name: false, + cacheGroups: { + monacoEditor: { + chunks: 'async', + name: () => 'monaco.editor', + priority: 4, + test: /[\\/]node_modules[\\/]monaco-editor/, + enforce: true, + reuseExistingChunk: true, + }, + mpld3: { + chunks: 'async', + name: 'mpld3', + priority: 4, + test: /[\\/]node_modules[\\/]mpld3/, + enforce: true, + reuseExistingChunk: true, + }, + } }, // Keep the runtime chunk separated to enable long term caching // https://twitter.com/wSokra/status/969679223278505985 @@ -319,9 +346,6 @@ module.exports = function (webpackEnv) { .map((ext) => `.${ext}`) .filter((ext) => useTypeScript || !ext.includes('ts')), alias: { - // Support React Native Web - // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/ - 'react-native': 'react-native-web', // Allows for better profiling with ReactDevTools ...(isEnvProductionProfile && { 'react-dom$': 'react-dom/profiling', @@ -360,6 +384,10 @@ module.exports = function (webpackEnv) { // match the requirements. When no loader matches it will fall // back to the "file" loader at the end of the loader list. oneOf: [ + { + test: /\.metayml$/i, + loader: require.resolve('raw-loader'), + }, // TODO: Merge this config once `image/avif` is in the mime-db // https://github.com/jshttp/mime-db { @@ -428,6 +456,9 @@ module.exports = function (webpackEnv) { presets: [ [require.resolve('babel-preset-react-app/dependencies'), { helpers: true }], ], + plugins: [ + '@babel/plugin-proposal-class-properties' + ], cacheDirectory: true, // See #6846 for context on why cacheCompression is disabled cacheCompression: false, @@ -508,6 +539,19 @@ module.exports = function (webpackEnv) { ), sideEffects: true, }, + { + test: lessModuleRegex, + use: getStyleLoaders( + { + importLoaders: 3, + sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment, + modules: { + getLocalIdent: getCSSModuleLocalIdent, + }, + }, + 'less-loader', + ), + }, // Adds support for CSS Modules, but using SASS // using the extension .module.scss or .module.sass { @@ -546,7 +590,13 @@ module.exports = function (webpackEnv) { ], }, plugins: [ - new AntdDayjsWebpackPlugin(), + new ArcoWebpackPlugin({ + theme: themeEnvToArcoThemeLibNameMap[process.env.THEME || 'normal'], + }), + new MonacoWebpackPlugin({ + languages: ['json', 'python', 'shell', 'javascript', 'go', 'yaml'], + publicPath: isEnvProduction ? '/v2/static/js/' : '/', + }), getHtmlPluginConfig('index'), // getHtmlPluginConfig('login'), // Inlines the webpack runtime script. This script is too small to warrant @@ -654,10 +704,6 @@ module.exports = function (webpackEnv) { : undefined, tsconfig: paths.appTsConfig, reportFiles: [ - // This one is specifically to match during CI tests, - // as micromatch doesn't match - // '../cra-template-typescript/template/src/App.tsx' - // otherwise. '../**/src/**/*.{ts,tsx}', '**/src/**/*.{ts,tsx}', '!**/src/**/__tests__/**', @@ -704,4 +750,6 @@ module.exports = function (webpackEnv) { // our own hints via the FileSizeReporter performance: false, }; + + return config; }; diff --git a/web_console_v2/client/config/webpackDevServer.config.js b/web_console_v2/client/config/webpackDevServer.config.js index d08a8fac5..8a211fd4b 100644 --- a/web_console_v2/client/config/webpackDevServer.config.js +++ b/web_console_v2/client/config/webpackDevServer.config.js @@ -6,6 +6,7 @@ const ignoredFiles = require('react-dev-utils/ignoredFiles'); const redirectServedPath = require('react-dev-utils/redirectServedPathMiddleware'); const paths = require('./paths'); const getHttpsConfig = require('./getHttpsConfig'); +const { createProxyMiddleware } = require('http-proxy-middleware'); const host = process.env.HOST || '0.0.0.0'; const sockHost = process.env.WDS_SOCKET_HOST; @@ -111,6 +112,14 @@ module.exports = function (proxy, allowedHost) { // This registers user provided middleware for proxy reasons require(paths.proxySetup)(app); } + + app.use( + '/mock/20021', + createProxyMiddleware({ + target: 'xxx', + changeOrigin: true, + }), + ); }, after(app) { // Redirect to `PUBLIC_URL` or `homepage` from `package.json` if url not match diff --git a/web_console_v2/client/docs/KNOWN_ISSUES.md b/web_console_v2/client/docs/KNOWN_ISSUES.md deleted file mode 100644 index 7b1432398..000000000 --- a/web_console_v2/client/docs/KNOWN_ISSUES.md +++ /dev/null @@ -1,7 +0,0 @@ -# Known issues - -## Development - -1. Build stuck at `Compiling...` - -Sometime you changed files' structure will lead this happen, just ctrl+c quit process and restart building should get it works. diff --git a/web_console_v2/client/jest.config.js b/web_console_v2/client/jest.config.js index 542d0ac68..8828be6fd 100644 --- a/web_console_v2/client/jest.config.js +++ b/web_console_v2/client/jest.config.js @@ -4,16 +4,15 @@ module.exports = { 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/i18n/index.ts', - '!src/i18n/resources/modules/*.ts', - '!src/components/**/*.tsx', + '!src/i18n/resources/**/*.ts', '!src/stores/**/*.ts', '!src/typings/*.ts', '!src/views/**/*.tsx', - '!src/shared/variablePresets.ts', - '!src/shared/file.ts', - '!src/services/mocks/**/*.ts', - '!src/App.ts', - '!src/index.ts', + '!src/services/**/*.ts', + '!src/libs/*.ts', + '!src/App.tsx', + '!src/index.tsx', + '!src/components/IconPark/**/*.{ts,tsx}', ], setupFiles: ['react-app-polyfill/jsdom'], setupFilesAfterEnv: ['/tests/setup.ts'], @@ -32,9 +31,10 @@ module.exports = { transformIgnorePatterns: [ '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$', '^.+\\.module\\.(css|sass|scss|less)$', - '/node_modules/(?!antd|@ant-design|rc-.+?|@babel/runtime).+(js|jsx)$', + '/node_modules/(?!antd|lodash-es|@ant-design|rc-.+?|@babel/runtime).+(js|jsx|ts|tsx)$', + 'node_modules/(?!(monaco-editor)/)', ], - modulePaths: [], + modulePaths: ['/src/'], moduleNameMapper: { '^react-native$': 'react-native-web', '^.+\\.module\\.(css|sass|scss|less)$': 'identity-obj-proxy', @@ -44,9 +44,13 @@ module.exports = { 'services/(.*)': '/src/services/$1', 'typings/(.*)': '/src/typings/$1', 'views/(.*)': '/src/views/$1', - 'components/(.*)': ['/src/components/$1', '@ant-design/icons'], + // It will replace lodash-es with the commonjs version during testing runtime. + '^lodash-es$': 'lodash', + '^components/(.*)': '/src/components/$1', 'i18n/(.*)': '/src/i18n/$1', i18n: '/src/i18n/index.ts', + 'stores/(.*)': '/src/stores/$1', + 'assets/(.*)': '/src/assets/$1', }, moduleFileExtensions: [ 'web.js', diff --git a/web_console_v2/client/package.json b/web_console_v2/client/package.json index 0aa988a8a..bb5548351 100644 --- a/web_console_v2/client/package.json +++ b/web_console_v2/client/package.json @@ -9,25 +9,36 @@ "test": "node scripts/test.js", "test:coverage": "npx jest --coverage", "lint": "eslint '*/**/*.{js,ts,tsx}' --fix", - "pritter": "npx prettier --write ./src" + "lint:prod": "npx cross-env NODE_ENV=production eslint '*/**/*.{js,ts,tsx}' --fix", + "pritter": "npx prettier --write ./src", + "build:theme": "node scripts/multiLessVarsTransform.js", + "build:theme:watch": "cross-env NODE_ENV=development node scripts/multiLessVarsTransform.js", + "dumi": "cross-env APP_ROOT=dumi dumi dev", + "dumi-build": "cross-env APP_ROOT=dumi dumi build", + "ts:check": "tsc" }, "dependencies": { - "@ant-design/icons": "^4.6.2", - "@formily/antd": "^1.3.8", - "@formily/antd-components": "^1.3.8", - "@monaco-editor/react": "^4.0.11", - "@welldone-software/why-did-you-render": "^6.1.1", - "antd": "^4.14.0", + "@arco-design/web-react": "2.28.2", + "@arco-themes/react-privacy-computing": "0.0.4", + "@arco-themes/react-privacy-computing-bioland": "0.0.4", + "@formily/core": "^2.2.12", + "@formily/react": "^2.2.12", + "@monaco-editor/react": "^4.4.6", "axios": "^0.21.0", - "chart.js": "^3.2.1", "classnames": "^2.2.6", - "dayjs": "^1.9.7", + "dayjs": "^1.10.8", + "debounce-promise": "^3.1.2", "i18next": "^19.8.3", "ip-port-regex": "^2.0.0", "keyboardjs": "^2.6.4", "lodash-es": "^4.17.15", + "monaco-editor": "^0.34.1", + "mpld3": "0.5.2", "pubsub-js": "^1.9.2", + "qs": "^6.10.1", "rc-menu": "^8.10.6", + "rc-upload": "^4.3.1", + "re-resizable": "^6.9.0", "react": "^17.0.1", "react-app-polyfill": "^2.0.0", "react-chartjs-2": "^3.0.3", @@ -40,32 +51,38 @@ "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-use": "^15.3.4", - "recoil": "^0.1.2", + "recoil": "0.3.1", "store2": "^2.12.0", "styled-components": "^5.2.1", "utility-types": "^3.10.0" }, "devDependencies": { + "@arco-design/webpack-plugin": "1.7.0", "@babel/core": "7.12.3", + "@babel/plugin-proposal-class-properties": "^7.18.6", "@pmmmwh/react-refresh-webpack-plugin": "0.4.2", + "@simbathesailor/use-what-changed": "^2.0.0", "@svgr/webpack": "5.4.0", "@testing-library/jest-dom": "^5.11.5", "@testing-library/react": "^11.1.1", + "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^12.2.0", + "@types/chart.js": "^2.9.34", "@types/classnames": "^2.2.11", + "@types/debounce-promise": "^3.1.4", "@types/jest": "^26.0.15", "@types/keyboardjs": "^2.5.0", "@types/less": "^3.0.1", - "@types/lodash": "^4.14.164", + "@types/lodash-es": "^4.17.4", "@types/node": "^12.19.3", "@types/pubsub-js": "^1.8.1", + "@types/qs": "^6.9.7", "@types/react": "^16.9.55", "@types/react-dom": "^16.9.9", "@types/react-router-dom": "^5.1.6", "@types/styled-components": "^5.1.4", "@typescript-eslint/eslint-plugin": "^4.5.0", "@typescript-eslint/parser": "^4.5.0", - "antd-dayjs-webpack-plugin": "^1.0.6", "babel-eslint": "^10.1.0", "babel-jest": "^26.6.0", "babel-loader": "8.1.0", @@ -74,9 +91,12 @@ "bfj": "^7.0.2", "camelcase": "^6.1.0", "case-sensitive-paths-webpack-plugin": "2.3.0", + "chart.js": "^3.5.0", + "cross-env": "^7.0.3", "css-loader": "4.3.0", "dotenv": "8.2.0", "dotenv-expand": "5.1.0", + "dumi": "^1.1.30", "eslint": "^7.11.0", "eslint-config-prettier": "^6.15.0", "eslint-config-react-app": "^6.0.0", @@ -92,17 +112,19 @@ "file-loader": "6.1.1", "fs-extra": "^9.0.1", "html-webpack-plugin": "4.5.0", + "http-proxy-middleware": "^2.0.6", "identity-obj-proxy": "3.0.0", "jest": "26.6.0", "jest-circus": "26.6.0", "jest-resolve": "26.6.0", + "jest-styled-components": "^7.0.5", "jest-watch-typeahead": "0.6.1", "less": "^3.12.2", "less-loader": "^7.0.2", "less-vars-to-js": "^1.3.0", "lint-staged": "^10.5.1", - "lodash": "^4.17.21", "mini-css-extract-plugin": "0.11.3", + "monaco-editor-webpack-plugin": "^7.0.1", "optimize-css-assets-webpack-plugin": "5.0.4", "pnp-webpack-plugin": "1.6.4", "postcss-flexbugs-fixes": "4.2.1", @@ -111,6 +133,7 @@ "postcss-preset-env": "6.7.0", "postcss-safe-parser": "5.0.2", "prettier": "^2.1.2", + "raw-loader": "^4.0.2", "resolve": "1.18.1", "resolve-url-loader": "^3.1.2", "sass-loader": "8.0.2", @@ -126,7 +149,8 @@ "webpack-cli": "^4.5.0", "webpack-dev-server": "3.11.0", "webpack-manifest-plugin": "2.2.0", - "workbox-webpack-plugin": "5.1.4" + "workbox-webpack-plugin": "5.1.4", + "xhr-mock": "^2.5.1" }, "lint-staged": { "./src/**/*.{js,ts,tsx}": [ @@ -158,5 +182,6 @@ "presets": [ "react-app" ] - } + }, + "proxy": "xxx" } diff --git a/web_console_v2/client/pnpm-lock.yaml b/web_console_v2/client/pnpm-lock.yaml index 2e8578d93..5aa8d9689 100644 --- a/web_console_v2/client/pnpm-lock.yaml +++ b/web_console_v2/client/pnpm-lock.yaml @@ -1,23 +1,166 @@ +lockfileVersion: 5.3 + +overrides: + styled-components: ^5 + +specifiers: + '@arco-design/web-react': 2.28.2 + '@arco-design/webpack-plugin': 1.7.0 + '@arco-themes/react-privacy-computing': 0.0.4 + '@arco-themes/react-privacy-computing-bioland': 0.0.4 + '@babel/core': 7.12.3 + '@babel/plugin-proposal-class-properties': ^7.18.6 + '@formily/core': ^2.2.12 + '@formily/react': ^2.2.12 + '@monaco-editor/react': ^4.4.6 + '@pmmmwh/react-refresh-webpack-plugin': 0.4.2 + '@simbathesailor/use-what-changed': ^2.0.0 + '@svgr/webpack': 5.4.0 + '@testing-library/jest-dom': ^5.11.5 + '@testing-library/react': ^11.1.1 + '@testing-library/react-hooks': ^7.0.2 + '@testing-library/user-event': ^12.2.0 + '@types/chart.js': ^2.9.34 + '@types/classnames': ^2.2.11 + '@types/debounce-promise': ^3.1.4 + '@types/jest': ^26.0.15 + '@types/keyboardjs': ^2.5.0 + '@types/less': ^3.0.1 + '@types/lodash-es': ^4.17.4 + '@types/node': ^12.19.3 + '@types/pubsub-js': ^1.8.1 + '@types/qs': ^6.9.7 + '@types/react': ^16.9.55 + '@types/react-dom': ^16.9.9 + '@types/react-router-dom': ^5.1.6 + '@types/styled-components': ^5.1.4 + '@typescript-eslint/eslint-plugin': ^4.5.0 + '@typescript-eslint/parser': ^4.5.0 + axios: ^0.21.0 + babel-eslint: ^10.1.0 + babel-jest: ^26.6.0 + babel-loader: 8.1.0 + babel-plugin-named-asset-import: ^0.3.7 + babel-preset-react-app: ^10.0.0 + bfj: ^7.0.2 + camelcase: ^6.1.0 + case-sensitive-paths-webpack-plugin: 2.3.0 + chart.js: ^3.5.0 + classnames: ^2.2.6 + cross-env: ^7.0.3 + css-loader: 4.3.0 + dayjs: ^1.10.8 + debounce-promise: ^3.1.2 + dotenv: 8.2.0 + dotenv-expand: 5.1.0 + dumi: ^1.1.30 + eslint: ^7.11.0 + eslint-config-prettier: ^6.15.0 + eslint-config-react-app: ^6.0.0 + eslint-plugin-flowtype: ^5.2.0 + eslint-plugin-import: ^2.22.1 + eslint-plugin-jest: ^24.1.0 + eslint-plugin-jsx-a11y: ^6.3.1 + eslint-plugin-prettier: ^3.1.4 + eslint-plugin-react: ^7.21.5 + eslint-plugin-react-hooks: ^4.2.0 + eslint-plugin-testing-library: ^3.9.2 + eslint-webpack-plugin: ^2.1.0 + file-loader: 6.1.1 + fs-extra: ^9.0.1 + html-webpack-plugin: 4.5.0 + http-proxy-middleware: ^2.0.6 + i18next: ^19.8.3 + identity-obj-proxy: 3.0.0 + ip-port-regex: ^2.0.0 + jest: 26.6.0 + jest-circus: 26.6.0 + jest-resolve: 26.6.0 + jest-styled-components: ^7.0.5 + jest-watch-typeahead: 0.6.1 + keyboardjs: ^2.6.4 + less: ^3.12.2 + less-loader: ^7.0.2 + less-vars-to-js: ^1.3.0 + lint-staged: ^10.5.1 + lodash-es: ^4.17.15 + mini-css-extract-plugin: 0.11.3 + monaco-editor: ^0.34.1 + monaco-editor-webpack-plugin: ^7.0.1 + mpld3: 0.5.2 + optimize-css-assets-webpack-plugin: 5.0.4 + pnp-webpack-plugin: 1.6.4 + postcss-flexbugs-fixes: 4.2.1 + postcss-loader: 3.0.0 + postcss-normalize: 8.0.1 + postcss-preset-env: 6.7.0 + postcss-safe-parser: 5.0.2 + prettier: ^2.1.2 + pubsub-js: ^1.9.2 + qs: ^6.10.1 + raw-loader: ^4.0.2 + rc-menu: ^8.10.6 + rc-upload: ^4.3.1 + re-resizable: ^6.9.0 + react: ^17.0.1 + react-app-polyfill: ^2.0.0 + react-chartjs-2: ^3.0.3 + react-dev-utils: ^11.0.0 + react-dom: ^17.0.1 + react-flow-renderer: ^9.1.1 + react-i18next: ^11.7.3 + react-query: ^3.9.8 + react-refresh: ^0.8.3 + react-router: ^5.2.0 + react-router-dom: ^5.2.0 + react-use: ^15.3.4 + recoil: 0.3.1 + resolve: 1.18.1 + resolve-url-loader: ^3.1.2 + sass-loader: 8.0.2 + semver: 7.3.2 + store2: ^2.12.0 + strip-json-comments: ^3.1.1 + style-loader: 1.3.0 + styled-components: ^5 + terser-webpack-plugin: 4.2.3 + ts-pnp: 1.2.0 + tsconfig-paths-webpack-plugin: ^3.3.0 + typescript: ^4.0.5 + url-loader: 4.1.1 + utility-types: ^3.10.0 + webpack: 4.44.2 + webpack-cli: ^4.5.0 + webpack-dev-server: 3.11.0 + webpack-manifest-plugin: 2.2.0 + workbox-webpack-plugin: 5.1.4 + xhr-mock: ^2.5.1 + dependencies: - '@ant-design/icons': 4.6.2_react-dom@17.0.2+react@17.0.2 - '@formily/antd': 1.3.13_b13a12cfbb184a60a7e1275d8262e450 - '@formily/antd-components': 1.3.13_b13a12cfbb184a60a7e1275d8262e450 - '@monaco-editor/react': 4.1.0_react-dom@17.0.2+react@17.0.2 - '@welldone-software/why-did-you-render': 6.1.1_react@17.0.2 - antd: 4.14.1_2235c505ed33ea6efd93d3050f896208 + '@arco-design/web-react': 2.28.2_d8837eed98748ff5a1dd894fdd80cd72 + '@arco-themes/react-privacy-computing': 0.0.4_@arco-design+web-react@2.28.2 + '@arco-themes/react-privacy-computing-bioland': 0.0.4_@arco-design+web-react@2.28.2 + '@formily/core': 2.2.12 + '@formily/react': 2.2.12_338bd5ec353cfb7439af722c4fbc028f + '@monaco-editor/react': 4.4.6_ec62f306aa7ee40038c222aca8db4940 axios: 0.21.1 - chart.js: 3.2.1 classnames: 2.2.6 - dayjs: 1.10.4 + dayjs: 1.10.8 + debounce-promise: 3.1.2 i18next: 19.9.2 ip-port-regex: 2.0.0 keyboardjs: 2.6.4 lodash-es: 4.17.21 + monaco-editor: 0.34.1 + mpld3: 0.5.2 pubsub-js: 1.9.3 + qs: 6.10.1 rc-menu: 8.10.6_react-dom@17.0.2+react@17.0.2 + rc-upload: 4.3.1_react-dom@17.0.2+react@17.0.2 + re-resizable: 6.9.0_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-app-polyfill: 2.0.0 - react-chartjs-2: 3.0.3_chart.js@3.2.1+react@17.0.2 + react-chartjs-2: 3.0.4_chart.js@3.5.0+react@17.0.2 react-dev-utils: 11.0.4 react-dom: 17.0.2_react@17.0.2 react-flow-renderer: 9.4.0_react-dom@17.0.2+react@17.0.2 @@ -27,31 +170,38 @@ dependencies: react-router: 5.2.0_react@17.0.2 react-router-dom: 5.2.0_react@17.0.2 react-use: 15.3.8_react-dom@17.0.2+react@17.0.2 - recoil: 0.1.3_react-dom@17.0.2+react@17.0.2 + recoil: 0.3.1_react-dom@17.0.2+react@17.0.2 store2: 2.12.0 styled-components: 5.2.1_react-dom@17.0.2+react@17.0.2 utility-types: 3.10.0 + devDependencies: + '@arco-design/webpack-plugin': 1.7.0_webpack@4.44.2 '@babel/core': 7.12.3 + '@babel/plugin-proposal-class-properties': 7.18.6_@babel+core@7.12.3 '@pmmmwh/react-refresh-webpack-plugin': 0.4.2_d00fcc46a48175a4e289da7534b00e9a + '@simbathesailor/use-what-changed': 2.0.0_react@17.0.2 '@svgr/webpack': 5.4.0 '@testing-library/jest-dom': 5.11.10 '@testing-library/react': 11.2.5_react-dom@17.0.2+react@17.0.2 + '@testing-library/react-hooks': 7.0.2_react-dom@17.0.2+react@17.0.2 '@testing-library/user-event': 12.8.3 + '@types/chart.js': 2.9.34 '@types/classnames': 2.2.11 + '@types/debounce-promise': 3.1.4 '@types/jest': 26.0.22 '@types/keyboardjs': 2.5.0 '@types/less': 3.0.2 - '@types/lodash': 4.14.168 + '@types/lodash-es': 4.17.4 '@types/node': 12.20.7 '@types/pubsub-js': 1.8.2 + '@types/qs': 6.9.7 '@types/react': 16.14.5 '@types/react-dom': 16.9.12 '@types/react-router-dom': 5.1.7 '@types/styled-components': 5.1.9 '@typescript-eslint/eslint-plugin': 4.19.0_821acdc8bc493ad1aa2628c9b724d688 '@typescript-eslint/parser': 4.19.0_eslint@7.23.0+typescript@4.2.3 - antd-dayjs-webpack-plugin: 1.0.6_dayjs@1.10.4 babel-eslint: 10.1.0_eslint@7.23.0 babel-jest: 26.6.3_@babel+core@7.12.3 babel-loader: 8.1.0_427212bc1158d185e577033f19ca0757 @@ -60,9 +210,12 @@ devDependencies: bfj: 7.0.2 camelcase: 6.2.0 case-sensitive-paths-webpack-plugin: 2.3.0 + chart.js: 3.5.0 + cross-env: 7.0.3 css-loader: 4.3.0_webpack@4.44.2 dotenv: 8.2.0 dotenv-expand: 5.1.0 + dumi: 1.1.30_ab15ddca82409ccfb2f88ffa3dfddc1b eslint: 7.23.0 eslint-config-prettier: 6.15.0_eslint@7.23.0 eslint-config-react-app: 6.0.0_2fb64cc94b95fae32741d239fe65ddca @@ -78,17 +231,19 @@ devDependencies: file-loader: 6.1.1_webpack@4.44.2 fs-extra: 9.1.0 html-webpack-plugin: 4.5.0_webpack@4.44.2 + http-proxy-middleware: 2.0.6 identity-obj-proxy: 3.0.0 jest: 26.6.0 jest-circus: 26.6.0 jest-resolve: 26.6.0 + jest-styled-components: 7.0.5_styled-components@5.2.1 jest-watch-typeahead: 0.6.1_jest@26.6.0 less: 3.13.1 less-loader: 7.3.0_less@3.13.1+webpack@4.44.2 less-vars-to-js: 1.3.0 lint-staged: 10.5.4 - lodash: 4.17.21 mini-css-extract-plugin: 0.11.3_webpack@4.44.2 + monaco-editor-webpack-plugin: 7.0.1_d758ab496f5c143a3f97c41b30684737 optimize-css-assets-webpack-plugin: 5.0.4_webpack@4.44.2 pnp-webpack-plugin: 1.6.4_typescript@4.2.3 postcss-flexbugs-fixes: 4.2.1 @@ -97,6 +252,7 @@ devDependencies: postcss-preset-env: 6.7.0 postcss-safe-parser: 5.0.2 prettier: 2.2.1 + raw-loader: 4.0.2_webpack@4.44.2 resolve: 1.18.1 resolve-url-loader: 3.1.2 sass-loader: 8.0.2_webpack@4.44.2 @@ -113,68 +269,100 @@ devDependencies: webpack-dev-server: 3.11.0_webpack-cli@4.6.0+webpack@4.44.2 webpack-manifest-plugin: 2.2.0_webpack@4.44.2 workbox-webpack-plugin: 5.1.4_webpack@4.44.2 -lockfileVersion: 5.2 -overrides: - styled-components: ^5 + xhr-mock: 2.5.1 + packages: - /@ant-design/colors/6.0.0: + + /@arco-design/color/0.4.0: + resolution: {integrity: sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g==} dependencies: - '@ctrl/tinycolor': 3.4.0 + color: 3.2.1 dev: false - resolution: - integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ== - /@ant-design/icons-svg/4.1.0: - dev: false - resolution: - integrity: sha512-Fi03PfuUqRs76aI3UWYpP864lkrfPo0hluwGqh7NJdLhvH4iRDc3jbJqZIvRDLHKbXrvAfPPV3+zjUccfFvWOQ== - /@ant-design/icons/4.6.2_react-dom@17.0.2+react@17.0.2: + + /@arco-design/web-react/2.28.2_d8837eed98748ff5a1dd894fdd80cd72: + resolution: {integrity: sha512-GLVzw9c+j8feSOdOv1Qk/XrQSt40bjlZOYtnLcJ0re6lpNyxbqtTxVXeoP1SsZeUcgTiYoSUS4e+7HVXUsJSqA==} + peerDependencies: + react: '>=16' + react-dom: '>=16' dependencies: - '@ant-design/colors': 6.0.0 - '@ant-design/icons-svg': 4.1.0 - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 + '@arco-design/color': 0.4.0 + '@babel/runtime': 7.20.13 + b-tween: 0.3.3 + b-validate: 1.4.4 + compute-scroll-into-view: 1.0.20 + dayjs: 1.11.7 + lodash: 4.17.21 + number-precision: 1.6.0 react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + react-focus-lock: 2.9.3_5170878e5e8a60dfb58a26e1cbcc99ef + react-transition-group: 4.4.5_react-dom@17.0.2+react@17.0.2 + resize-observer-polyfill: 1.5.1 + scroll-into-view-if-needed: 2.2.20 + shallowequal: 1.1.0 + transitivePeerDependencies: + - '@types/react' dev: false - engines: - node: '>=8' + + /@arco-design/webpack-plugin/1.7.0_webpack@4.44.2: + resolution: {integrity: sha512-dRDXaNK9pzjTfxo6jQe2oZs9PLJIF6kovT5HJCM843XgdClSyvVvlZo+xtzK84Y+VRL+YE4zUt3Jb+3AEp5gqA==} peerDependencies: - react: '>=16.0.0' - react-dom: '*' - resolution: - integrity: sha512-QsBG2BxBYU/rxr2eb8b2cZ4rPKAPBpzAR+0v6rrZLp/lnyvflLH3tw1vregK+M7aJauGWjIGNdFmUfpAOtw25A== - /@ant-design/react-slick/0.28.2: + webpack: ^4.0.0 || ^5.0.0 dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - json2mq: 0.2.0 + babel-plugin-import: 1.13.6 + chalk: 4.1.2 lodash: 4.17.21 - resize-observer-polyfill: 1.5.1 + micromatch: 4.0.5 + webpack: 4.44.2_webpack-cli@4.6.0 + dev: true + + /@arco-themes/react-privacy-computing-bioland/0.0.4_@arco-design+web-react@2.28.2: + resolution: {integrity: sha512-3n/k43xXtnkiCgJqIoFVl1TBoj+ARP3JtInKBYz2hTqFaMb7W2M0L9Y451tOYv2qh0RqfZ514q7wcHcjsjgwDg==} + peerDependencies: + '@arco-design/web-react': ^2.23.5 + dependencies: + '@arco-design/web-react': 2.28.2_d8837eed98748ff5a1dd894fdd80cd72 dev: false - resolution: - integrity: sha512-nkrvXsO29pLToFaBb3MlJY4McaUFR4UHtXTz6A5HBzYmxH4SwKerX54mWdGc/6tKpHvS3vUwjEOt2T5XqZEo8Q== + + /@arco-themes/react-privacy-computing/0.0.4_@arco-design+web-react@2.28.2: + resolution: {integrity: sha512-g7DYkX06ylB34iNGGq0MgtKVUJfOeG2wYgTD6EiE5UXwYoP6WHqrk8xjXHyoIiq1uZy+S+lE+B74/drRuZrKxA==} + peerDependencies: + '@arco-design/web-react': ^2.23.5 + dependencies: + '@arco-design/web-react': 2.28.2_d8837eed98748ff5a1dd894fdd80cd72 + dev: false + /@babel/code-frame/7.10.4: + resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} dependencies: '@babel/highlight': 7.13.10 dev: false - resolution: - integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== + /@babel/code-frame/7.12.11: + resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} dependencies: '@babel/highlight': 7.13.10 dev: true - resolution: - integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + /@babel/code-frame/7.12.13: + resolution: {integrity: sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==} dependencies: '@babel/highlight': 7.13.10 - resolution: - integrity: sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== + + /@babel/code-frame/7.18.6: + resolution: {integrity: sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.18.6 + dev: true + /@babel/compat-data/7.13.12: + resolution: {integrity: sha512-3eJJ841uKxeV8dcN/2yGEUy+RfgQspPEgQat85umsE1rotuquQ2AbIub4S6j7c50a2d+4myc+zSlnXeIHrOnhQ==} dev: true - resolution: - integrity: sha512-3eJJ841uKxeV8dcN/2yGEUy+RfgQspPEgQat85umsE1rotuquQ2AbIub4S6j7c50a2d+4myc+zSlnXeIHrOnhQ== + /@babel/core/7.12.3: + resolution: {integrity: sha512-0qXcZYKZp3/6N2jKYVxZv0aNCsxTSVCiK72DTiTYZAu7sjg73W0/aynWjMbiGd87EQL4WyA8reiJVh92AVla9g==} + engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.12.13 '@babel/generator': 7.13.9 @@ -192,31 +380,49 @@ packages: resolve: 1.18.1 semver: 5.7.1 source-map: 0.5.7 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-0qXcZYKZp3/6N2jKYVxZv0aNCsxTSVCiK72DTiTYZAu7sjg73W0/aynWjMbiGd87EQL4WyA8reiJVh92AVla9g== + /@babel/generator/7.13.9: + resolution: {integrity: sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==} dependencies: '@babel/types': 7.13.13 jsesc: 2.5.2 source-map: 0.5.7 - resolution: - integrity: sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw== + + /@babel/generator/7.20.5: + resolution: {integrity: sha512-jl7JY2Ykn9S0yj4DQP82sYvPU+T3g0HFcWTqDLqiuA9tGRNIj9VfbtXGAYTTkyNEnQk1jkMGOdYka8aG/lulCA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.20.5 + '@jridgewell/gen-mapping': 0.3.2 + jsesc: 2.5.2 + dev: true + /@babel/helper-annotate-as-pure/7.12.13: + resolution: {integrity: sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw==} dependencies: '@babel/types': 7.13.13 - resolution: - integrity: sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw== + + /@babel/helper-annotate-as-pure/7.18.6: + resolution: {integrity: sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.20.5 + dev: true + /@babel/helper-builder-binary-assignment-operator-visitor/7.12.13: + resolution: {integrity: sha512-CZOv9tGphhDRlVjVkAgm8Nhklm9RzSmWpX2my+t7Ua/KT616pEzXsQCjinzvkRvHWJ9itO4f296efroX23XCMA==} dependencies: '@babel/helper-explode-assignable-expression': 7.13.0 '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-CZOv9tGphhDRlVjVkAgm8Nhklm9RzSmWpX2my+t7Ua/KT616pEzXsQCjinzvkRvHWJ9itO4f296efroX23XCMA== + /@babel/helper-compilation-targets/7.13.13_@babel+core@7.12.3: + resolution: {integrity: sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ==} + peerDependencies: + '@babel/core': ^7.0.0 dependencies: '@babel/compat-data': 7.13.12 '@babel/core': 7.12.3 @@ -224,11 +430,11 @@ packages: browserslist: 4.16.3 semver: 6.3.0 dev: true + + /@babel/helper-create-class-features-plugin/7.13.11_@babel+core@7.12.3: + resolution: {integrity: sha512-ays0I7XYq9xbjCSvT+EvysLgfc3tOkwCULHjrnscGT3A9qD4sk3wXnJ3of0MAWsWGjdinFvajHU2smYuqXKMrw==} peerDependencies: '@babel/core': ^7.0.0 - resolution: - integrity: sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ== - /@babel/helper-create-class-features-plugin/7.13.11_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-function-name': 7.12.13 @@ -236,22 +442,42 @@ packages: '@babel/helper-optimise-call-expression': 7.12.13 '@babel/helper-replace-supers': 7.13.12 '@babel/helper-split-export-declaration': 7.12.13 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/helper-create-class-features-plugin/7.20.5_@babel+core@7.12.3: + resolution: {integrity: sha512-3RCdA/EmEaikrhayahwToF0fpweU/8o2p8vhc1c/1kftHOdTKuC65kik/TLc+qfbS8JKw4qqJbne4ovICDhmww==} + engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - resolution: - integrity: sha512-ays0I7XYq9xbjCSvT+EvysLgfc3tOkwCULHjrnscGT3A9qD4sk3wXnJ3of0MAWsWGjdinFvajHU2smYuqXKMrw== + dependencies: + '@babel/core': 7.12.3 + '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-function-name': 7.19.0 + '@babel/helper-member-expression-to-functions': 7.18.9 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/helper-replace-supers': 7.19.1 + '@babel/helper-split-export-declaration': 7.18.6 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/helper-create-regexp-features-plugin/7.12.17_@babel+core@7.12.3: + resolution: {integrity: sha512-p2VGmBu9oefLZ2nQpgnEnG0ZlRPvL8gAGvPUMQwUdaE8k49rOMuZpOwdQoy5qJf6K8jL3bcAMhVUlHAjIgJHUg==} + peerDependencies: + '@babel/core': ^7.0.0 dependencies: '@babel/core': 7.12.3 '@babel/helper-annotate-as-pure': 7.12.13 regexpu-core: 4.7.1 dev: true - peerDependencies: - '@babel/core': ^7.0.0 - resolution: - integrity: sha512-p2VGmBu9oefLZ2nQpgnEnG0ZlRPvL8gAGvPUMQwUdaE8k49rOMuZpOwdQoy5qJf6K8jL3bcAMhVUlHAjIgJHUg== + /@babel/helper-define-polyfill-provider/0.1.5_@babel+core@7.12.3: + resolution: {integrity: sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==} + peerDependencies: + '@babel/core': ^7.4.0-0 dependencies: '@babel/core': 7.12.3 '@babel/helper-compilation-targets': 7.13.13_@babel+core@7.12.3 @@ -262,48 +488,84 @@ packages: lodash.debounce: 4.0.8 resolve: 1.18.1 semver: 6.3.0 + transitivePeerDependencies: + - supports-color dev: true - peerDependencies: - '@babel/core': ^7.4.0-0 - resolution: - integrity: sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg== + + /@babel/helper-environment-visitor/7.18.9: + resolution: {integrity: sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-explode-assignable-expression/7.13.0: + resolution: {integrity: sha512-qS0peLTDP8kOisG1blKbaoBg/o9OSa1qoumMjTK5pM+KDTtpxpsiubnCGP34vK8BXGcb2M9eigwgvoJryrzwWA==} dependencies: '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-qS0peLTDP8kOisG1blKbaoBg/o9OSa1qoumMjTK5pM+KDTtpxpsiubnCGP34vK8BXGcb2M9eigwgvoJryrzwWA== + /@babel/helper-function-name/7.12.13: + resolution: {integrity: sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==} dependencies: '@babel/helper-get-function-arity': 7.12.13 '@babel/template': 7.12.13 '@babel/types': 7.13.13 - resolution: - integrity: sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA== + + /@babel/helper-function-name/7.19.0: + resolution: {integrity: sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.18.10 + '@babel/types': 7.20.5 + dev: true + /@babel/helper-get-function-arity/7.12.13: + resolution: {integrity: sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg==} dependencies: '@babel/types': 7.13.13 - resolution: - integrity: sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg== + /@babel/helper-hoist-variables/7.13.0: + resolution: {integrity: sha512-0kBzvXiIKfsCA0y6cFEIJf4OdzfpRuNk4+YTeHZpGGc666SATFKTz6sRncwFnQk7/ugJ4dSrCj6iJuvW4Qwr2g==} dependencies: '@babel/traverse': 7.13.13 '@babel/types': 7.13.13 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-hoist-variables/7.18.6: + resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.20.5 dev: true - resolution: - integrity: sha512-0kBzvXiIKfsCA0y6cFEIJf4OdzfpRuNk4+YTeHZpGGc666SATFKTz6sRncwFnQk7/ugJ4dSrCj6iJuvW4Qwr2g== + /@babel/helper-member-expression-to-functions/7.13.12: + resolution: {integrity: sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw==} dependencies: '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw== + + /@babel/helper-member-expression-to-functions/7.18.9: + resolution: {integrity: sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.20.5 + dev: true + /@babel/helper-module-imports/7.13.12: + resolution: {integrity: sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==} dependencies: '@babel/types': 7.13.13 - resolution: - integrity: sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA== + + /@babel/helper-module-imports/7.18.6: + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.20.7 + dev: true + /@babel/helper-module-transforms/7.13.12: + resolution: {integrity: sha512-7zVQqMO3V+K4JOOj40kxiCrMf6xlQAkewBB0eu2b03OO/Q21ZutOzjpfD79A5gtE/2OWi1nv625MrDlGlkbknQ==} dependencies: '@babel/helper-module-imports': 7.13.12 '@babel/helper-replace-supers': 7.13.12 @@ -313,224 +575,313 @@ packages: '@babel/template': 7.12.13 '@babel/traverse': 7.13.13 '@babel/types': 7.13.13 + transitivePeerDependencies: + - supports-color dev: true - resolution: - integrity: sha512-7zVQqMO3V+K4JOOj40kxiCrMf6xlQAkewBB0eu2b03OO/Q21ZutOzjpfD79A5gtE/2OWi1nv625MrDlGlkbknQ== + /@babel/helper-optimise-call-expression/7.12.13: + resolution: {integrity: sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==} dependencies: '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA== + + /@babel/helper-optimise-call-expression/7.18.6: + resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.20.5 + dev: true + /@babel/helper-plugin-utils/7.13.0: + resolution: {integrity: sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ==} + dev: true + + /@babel/helper-plugin-utils/7.20.2: + resolution: {integrity: sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==} + engines: {node: '>=6.9.0'} dev: true - resolution: - integrity: sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ== + /@babel/helper-remap-async-to-generator/7.13.0: + resolution: {integrity: sha512-pUQpFBE9JvC9lrQbpX0TmeNIy5s7GnZjna2lhhcHC7DzgBs6fWn722Y5cfwgrtrqc7NAJwMvOa0mKhq6XaE4jg==} dependencies: '@babel/helper-annotate-as-pure': 7.12.13 '@babel/helper-wrap-function': 7.13.0 '@babel/types': 7.13.13 + transitivePeerDependencies: + - supports-color dev: true - resolution: - integrity: sha512-pUQpFBE9JvC9lrQbpX0TmeNIy5s7GnZjna2lhhcHC7DzgBs6fWn722Y5cfwgrtrqc7NAJwMvOa0mKhq6XaE4jg== + /@babel/helper-replace-supers/7.13.12: + resolution: {integrity: sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw==} dependencies: '@babel/helper-member-expression-to-functions': 7.13.12 '@babel/helper-optimise-call-expression': 7.12.13 '@babel/traverse': 7.13.13 '@babel/types': 7.13.13 - dev: true - resolution: - integrity: sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw== + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-replace-supers/7.19.1: + resolution: {integrity: sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-member-expression-to-functions': 7.18.9 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/traverse': 7.20.5 + '@babel/types': 7.20.5 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/helper-simple-access/7.13.12: + resolution: {integrity: sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA==} dependencies: '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA== + /@babel/helper-skip-transparent-expression-wrappers/7.12.1: + resolution: {integrity: sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA==} dependencies: '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA== + /@babel/helper-split-export-declaration/7.12.13: + resolution: {integrity: sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg==} dependencies: '@babel/types': 7.13.13 - resolution: - integrity: sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg== + + /@babel/helper-split-export-declaration/7.18.6: + resolution: {integrity: sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.20.5 + dev: true + + /@babel/helper-string-parser/7.19.4: + resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-validator-identifier/7.12.11: - resolution: - integrity: sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== + resolution: {integrity: sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==} + + /@babel/helper-validator-identifier/7.19.1: + resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-validator-option/7.12.17: + resolution: {integrity: sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw==} dev: true - resolution: - integrity: sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw== + /@babel/helper-wrap-function/7.13.0: + resolution: {integrity: sha512-1UX9F7K3BS42fI6qd2A4BjKzgGjToscyZTdp1DjknHLCIvpgne6918io+aL5LXFcER/8QWiwpoY902pVEqgTXA==} dependencies: '@babel/helper-function-name': 7.12.13 '@babel/template': 7.12.13 '@babel/traverse': 7.13.13 '@babel/types': 7.13.13 + transitivePeerDependencies: + - supports-color dev: true - resolution: - integrity: sha512-1UX9F7K3BS42fI6qd2A4BjKzgGjToscyZTdp1DjknHLCIvpgne6918io+aL5LXFcER/8QWiwpoY902pVEqgTXA== + /@babel/helpers/7.13.10: + resolution: {integrity: sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ==} dependencies: '@babel/template': 7.12.13 '@babel/traverse': 7.13.13 '@babel/types': 7.13.13 + transitivePeerDependencies: + - supports-color dev: true - resolution: - integrity: sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ== + /@babel/highlight/7.13.10: + resolution: {integrity: sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg==} dependencies: '@babel/helper-validator-identifier': 7.12.11 chalk: 2.4.2 js-tokens: 4.0.0 - resolution: - integrity: sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== + + /@babel/highlight/7.18.6: + resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.19.1 + chalk: 2.4.2 + js-tokens: 4.0.0 + dev: true + /@babel/parser/7.13.13: - engines: - node: '>=6.0.0' + resolution: {integrity: sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw==} + engines: {node: '>=6.0.0'} + hasBin: true + + /@babel/parser/7.20.5: + resolution: {integrity: sha512-r27t/cy/m9uKLXQNWWebeCUHgnAZq0CpG1OwKRxzJMP1vpSU4bSIK2hq+/cp0bQxetkXx38n09rNu8jVkcK/zA==} + engines: {node: '>=6.0.0'} hasBin: true - resolution: - integrity: sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw== + dev: true + /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.13.12_@babel+core@7.12.3: + resolution: {integrity: sha512-d0u3zWKcoZf379fOeJdr1a5WPDny4aOFZ6hlfKivgK0LY7ZxNfoaHL2fWwdGtHyVvra38FC+HVYkO+byfSA8AQ==} + peerDependencies: + '@babel/core': ^7.13.0 dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-skip-transparent-expression-wrappers': 7.12.1 '@babel/plugin-proposal-optional-chaining': 7.13.12_@babel+core@7.12.3 dev: true - peerDependencies: - '@babel/core': ^7.13.0 - resolution: - integrity: sha512-d0u3zWKcoZf379fOeJdr1a5WPDny4aOFZ6hlfKivgK0LY7ZxNfoaHL2fWwdGtHyVvra38FC+HVYkO+byfSA8AQ== + /@babel/plugin-proposal-async-generator-functions/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-rPBnhj+WgoSmgq+4gQUtXx/vOcU+UYtjy1AA/aeD61Hwj410fwYyqfUcRP3lR8ucgliVJL/G7sXcNUecC75IXA==} + peerDependencies: + '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-remap-async-to-generator': 7.13.0 '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.12.3 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-proposal-class-properties/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-rPBnhj+WgoSmgq+4gQUtXx/vOcU+UYtjy1AA/aeD61Hwj410fwYyqfUcRP3lR8ucgliVJL/G7sXcNUecC75IXA== - /@babel/plugin-proposal-class-properties/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-create-class-features-plugin': 7.13.11_@babel+core@7.12.3 '@babel/helper-plugin-utils': 7.13.0 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-proposal-class-properties/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-KnTDjFNC1g+45ka0myZNvSBFLhNCLN+GeGYLDEA8Oq7MZ6yMgfLoIRh86GRT0FjtJhZw8JyUskP9uvj5pHM9Zg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w== - /@babel/plugin-proposal-class-properties/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-create-class-features-plugin': 7.13.11_@babel+core@7.12.3 '@babel/helper-plugin-utils': 7.13.0 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-proposal-class-properties/7.18.6_@babel+core@7.12.3: + resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} + engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-KnTDjFNC1g+45ka0myZNvSBFLhNCLN+GeGYLDEA8Oq7MZ6yMgfLoIRh86GRT0FjtJhZw8JyUskP9uvj5pHM9Zg== + dependencies: + '@babel/core': 7.12.3 + '@babel/helper-create-class-features-plugin': 7.20.5_@babel+core@7.12.3 + '@babel/helper-plugin-utils': 7.20.2 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/plugin-proposal-decorators/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-knNIuusychgYN8fGJHONL0RbFxLGawhXOJNLBk75TniTsZZeA+wdkDuv6wp4lGwzQEKjZi6/WYtnb3udNPmQmQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.12.3 '@babel/helper-create-class-features-plugin': 7.13.11_@babel+core@7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-decorators': 7.12.13_@babel+core@7.12.3 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-proposal-dynamic-import/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-ONWKj0H6+wIRCkZi9zSbZtE/r73uOhMVHh256ys0UzfM7I3d4n+spZNWjOnJv2gzopumP2Wxi186vI8N0Y2JyQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-knNIuusychgYN8fGJHONL0RbFxLGawhXOJNLBk75TniTsZZeA+wdkDuv6wp4lGwzQEKjZi6/WYtnb3udNPmQmQ== - /@babel/plugin-proposal-dynamic-import/7.13.8_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-export-namespace-from/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-INAgtFo4OnLN3Y/j0VwAgw3HDXcDtX+C/erMvWzuV9v71r7urb6iyMXu7eM9IgLr1ElLlOkaHjJ0SbCmdOQ3Iw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-ONWKj0H6+wIRCkZi9zSbZtE/r73uOhMVHh256ys0UzfM7I3d4n+spZNWjOnJv2gzopumP2Wxi186vI8N0Y2JyQ== - /@babel/plugin-proposal-export-namespace-from/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-export-namespace-from': 7.8.3_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-json-strings/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-w4zOPKUFPX1mgvTmL/fcEqy34hrQ1CRcGxdphBc6snDnnqJ47EZDIyop6IwXzAC8G916hsIuXB2ZMBCExC5k7Q==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-INAgtFo4OnLN3Y/j0VwAgw3HDXcDtX+C/erMvWzuV9v71r7urb6iyMXu7eM9IgLr1ElLlOkaHjJ0SbCmdOQ3Iw== - /@babel/plugin-proposal-json-strings/7.13.8_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-logical-assignment-operators/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-aul6znYB4N4HGweImqKn59Su9RS8lbUIqxtXTOcAGtNIDczoEFv+l1EhmX8rUBp3G1jMjKJm8m0jXVp63ZpS4A==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-w4zOPKUFPX1mgvTmL/fcEqy34hrQ1CRcGxdphBc6snDnnqJ47EZDIyop6IwXzAC8G916hsIuXB2ZMBCExC5k7Q== - /@babel/plugin-proposal-logical-assignment-operators/7.13.8_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-nullish-coalescing-operator/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-aul6znYB4N4HGweImqKn59Su9RS8lbUIqxtXTOcAGtNIDczoEFv+l1EhmX8rUBp3G1jMjKJm8m0jXVp63ZpS4A== - /@babel/plugin-proposal-nullish-coalescing-operator/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-nullish-coalescing-operator/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-iePlDPBn//UhxExyS9KyeYU7RM9WScAG+D3Hhno0PLJebAEpDZMocbDe64eqynhNAnwz/vZoL/q/QB2T1OH39A==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg== - /@babel/plugin-proposal-nullish-coalescing-operator/7.13.8_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-numeric-separator/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-O1jFia9R8BUCl3ZGB7eitaAPu62TXJRHn7rh+ojNERCFyqRwJMTmhz+tJ+k0CwI6CLjX/ee4qW74FSqlq9I35w==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-iePlDPBn//UhxExyS9KyeYU7RM9WScAG+D3Hhno0PLJebAEpDZMocbDe64eqynhNAnwz/vZoL/q/QB2T1OH39A== - /@babel/plugin-proposal-numeric-separator/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-numeric-separator/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-MR7Ok+Af3OhNTCxYVjJZHS0t97ydnJZt/DbR4WISO39iDnhiD8XHrY12xuSJ90FFEGjir0Fzyyn7g/zY6hxbxA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-O1jFia9R8BUCl3ZGB7eitaAPu62TXJRHn7rh+ojNERCFyqRwJMTmhz+tJ+k0CwI6CLjX/ee4qW74FSqlq9I35w== - /@babel/plugin-proposal-numeric-separator/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-object-rest-spread/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-DhB2EuB1Ih7S3/IRX5AFVgZ16k3EzfRbq97CxAVI1KSYcW+lexV8VZb7G7L8zuPVSdQMRn0kiBpf/Yzu9ZKH0g==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-MR7Ok+Af3OhNTCxYVjJZHS0t97ydnJZt/DbR4WISO39iDnhiD8XHrY12xuSJ90FFEGjir0Fzyyn7g/zY6hxbxA== - /@babel/plugin-proposal-object-rest-spread/7.13.8_@babel+core@7.12.3: dependencies: '@babel/compat-data': 7.13.12 '@babel/core': 7.12.3 @@ -539,265 +890,268 @@ packages: '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.12.3 '@babel/plugin-transform-parameters': 7.13.0_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-optional-catch-binding/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-0wS/4DUF1CuTmGo+NiaHfHcVSeSLj5S3e6RivPTg/2k3wOv3jO35tZ6/ZWsQhQMvdgI7CwphjQa/ccarLymHVA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-DhB2EuB1Ih7S3/IRX5AFVgZ16k3EzfRbq97CxAVI1KSYcW+lexV8VZb7G7L8zuPVSdQMRn0kiBpf/Yzu9ZKH0g== - /@babel/plugin-proposal-optional-catch-binding/7.13.8_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-optional-chaining/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-c2uRpY6WzaVDzynVY9liyykS+kVU+WRZPMPYpkelXH8KBt1oXoI89kPbZKKG/jDT5UK92FTW2fZkZaJhdiBabw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-0wS/4DUF1CuTmGo+NiaHfHcVSeSLj5S3e6RivPTg/2k3wOv3jO35tZ6/ZWsQhQMvdgI7CwphjQa/ccarLymHVA== - /@babel/plugin-proposal-optional-chaining/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-skip-transparent-expression-wrappers': 7.12.1 '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-optional-chaining/7.13.12_@babel+core@7.12.3: + resolution: {integrity: sha512-fcEdKOkIB7Tf4IxrgEVeFC4zeJSTr78no9wTdBuZZbqF64kzllU0ybo2zrzm7gUQfxGhBgq4E39oRs8Zx/RMYQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-c2uRpY6WzaVDzynVY9liyykS+kVU+WRZPMPYpkelXH8KBt1oXoI89kPbZKKG/jDT5UK92FTW2fZkZaJhdiBabw== - /@babel/plugin-proposal-optional-chaining/7.13.12_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-skip-transparent-expression-wrappers': 7.12.1 '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.12.3 dev: true + + /@babel/plugin-proposal-private-methods/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-MXyyKQd9inhx1kDYPkFRVOBXQ20ES8Pto3T7UZ92xj2mY0EVD8oAVzeyYuVfy/mxAdTSIayOvg+aVzcHV2bn6Q==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-fcEdKOkIB7Tf4IxrgEVeFC4zeJSTr78no9wTdBuZZbqF64kzllU0ybo2zrzm7gUQfxGhBgq4E39oRs8Zx/RMYQ== - /@babel/plugin-proposal-private-methods/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-create-class-features-plugin': 7.13.11_@babel+core@7.12.3 '@babel/helper-plugin-utils': 7.13.0 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-proposal-unicode-property-regex/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-XyJmZidNfofEkqFV5VC/bLabGmO5QzenPO/YOfGuEbgU+2sSwMmio3YLb4WtBgcmmdwZHyVyv8on77IUjQ5Gvg==} + engines: {node: '>=4'} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-MXyyKQd9inhx1kDYPkFRVOBXQ20ES8Pto3T7UZ92xj2mY0EVD8oAVzeyYuVfy/mxAdTSIayOvg+aVzcHV2bn6Q== - /@babel/plugin-proposal-unicode-property-regex/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-create-regexp-features-plugin': 7.12.17_@babel+core@7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true - engines: - node: '>=4' + + /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.12.3: + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-XyJmZidNfofEkqFV5VC/bLabGmO5QzenPO/YOfGuEbgU+2sSwMmio3YLb4WtBgcmmdwZHyVyv8on77IUjQ5Gvg== - /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.12.3: + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== - /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== - /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-decorators/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-Rw6aIXGuqDLr6/LoBBYE57nKOzQpz/aDkKlMqEwH+Vp0MXbG6H/TfRjaY343LKxzAKAMXIHsQ8JzaZKuDZ9MwA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== - /@babel/plugin-syntax-decorators/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-dynamic-import/7.8.3_@babel+core@7.12.3: + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-Rw6aIXGuqDLr6/LoBBYE57nKOzQpz/aDkKlMqEwH+Vp0MXbG6H/TfRjaY343LKxzAKAMXIHsQ8JzaZKuDZ9MwA== - /@babel/plugin-syntax-dynamic-import/7.8.3_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-export-namespace-from/7.8.3_@babel+core@7.12.3: + resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== - /@babel/plugin-syntax-export-namespace-from/7.8.3_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-flow/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-J/RYxnlSLXZLVR7wTRsozxKT8qbsx1mNKJzXEEjQ0Kjx1ZACcyHgbanNWNCFtc36IzuWhYWPpvJFFoexoOWFmA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== - /@babel/plugin-syntax-flow/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-import-meta/7.10.4_@babel+core@7.12.3: + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-J/RYxnlSLXZLVR7wTRsozxKT8qbsx1mNKJzXEEjQ0Kjx1ZACcyHgbanNWNCFtc36IzuWhYWPpvJFFoexoOWFmA== - /@babel/plugin-syntax-import-meta/7.10.4_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.12.3: + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== - /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-jsx/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-d4HM23Q1K7oq/SLNmG6mRt85l2csmQ0cHRaxRXjKW0YFdEXqlZ5kzFQKH5Uc3rDJECgu+yCRgPkG04Mm98R/1g==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== - /@babel/plugin-syntax-jsx/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-logical-assignment-operators/7.10.4_@babel+core@7.12.3: + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-d4HM23Q1K7oq/SLNmG6mRt85l2csmQ0cHRaxRXjKW0YFdEXqlZ5kzFQKH5Uc3rDJECgu+yCRgPkG04Mm98R/1g== - /@babel/plugin-syntax-logical-assignment-operators/7.10.4_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.12.3: + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== - /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.12.3: + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== - /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.12.3: + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== - /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-optional-catch-binding/7.8.3_@babel+core@7.12.3: + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== - /@babel/plugin-syntax-optional-catch-binding/7.8.3_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.12.3: + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== - /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-top-level-await/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-A81F9pDwyS7yM//KwbCSDqy3Uj4NMIurtplxphWxoYtNPov7cJsDkAFNNyVlIZ3jwGycVsurZ+LtOA8gZ376iQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== - /@babel/plugin-syntax-top-level-await/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-syntax-typescript/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-cHP3u1JiUiG2LFDKbXnwVad81GvfyIOmCD6HIEId6ojrY0Drfy2q1jw7BwN7dE84+kTnBjLkXoL3IEy/3JPu2w==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-A81F9pDwyS7yM//KwbCSDqy3Uj4NMIurtplxphWxoYtNPov7cJsDkAFNNyVlIZ3jwGycVsurZ+LtOA8gZ376iQ== - /@babel/plugin-syntax-typescript/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-arrow-functions/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-96lgJagobeVmazXFaDrbmCLQxBysKu7U6Do3mLsx27gf5Dk85ezysrs2BZUpXD703U/Su1xTBDxxar2oa4jAGg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-cHP3u1JiUiG2LFDKbXnwVad81GvfyIOmCD6HIEId6ojrY0Drfy2q1jw7BwN7dE84+kTnBjLkXoL3IEy/3JPu2w== - /@babel/plugin-transform-arrow-functions/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-async-to-generator/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-3j6E004Dx0K3eGmhxVJxwwI89CTJrce7lg3UrtFuDAVQ/2+SJ/h/aSFOeE6/n0WB1GsOffsJp6MnPQNQ8nmwhg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-96lgJagobeVmazXFaDrbmCLQxBysKu7U6Do3mLsx27gf5Dk85ezysrs2BZUpXD703U/Su1xTBDxxar2oa4jAGg== - /@babel/plugin-transform-async-to-generator/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-module-imports': 7.13.12 '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-remap-async-to-generator': 7.13.0 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-transform-block-scoped-functions/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-zNyFqbc3kI/fVpqwfqkg6RvBgFpC4J18aKKMmv7KdQ/1GgREapSJAykLMVNwfRGO3BtHj3YQZl8kxCXPcVMVeg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-3j6E004Dx0K3eGmhxVJxwwI89CTJrce7lg3UrtFuDAVQ/2+SJ/h/aSFOeE6/n0WB1GsOffsJp6MnPQNQ8nmwhg== - /@babel/plugin-transform-block-scoped-functions/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-block-scoping/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-Pxwe0iqWJX4fOOM2kEZeUuAxHMWb9nK+9oh5d11bsLoB0xMg+mkDpt0eYuDZB7ETrY9bbcVlKUGTOGWy7BHsMQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-zNyFqbc3kI/fVpqwfqkg6RvBgFpC4J18aKKMmv7KdQ/1GgREapSJAykLMVNwfRGO3BtHj3YQZl8kxCXPcVMVeg== - /@babel/plugin-transform-block-scoping/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-classes/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-9BtHCPUARyVH1oXGcSJD3YpsqRLROJx5ZNP6tN5vnk17N0SVf9WCtf8Nuh1CFmgByKKAIMstitKduoCmsaDK5g==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-Pxwe0iqWJX4fOOM2kEZeUuAxHMWb9nK+9oh5d11bsLoB0xMg+mkDpt0eYuDZB7ETrY9bbcVlKUGTOGWy7BHsMQ== - /@babel/plugin-transform-classes/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-annotate-as-pure': 7.12.13 @@ -807,129 +1161,135 @@ packages: '@babel/helper-replace-supers': 7.13.12 '@babel/helper-split-export-declaration': 7.12.13 globals: 11.12.0 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-transform-computed-properties/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-RRqTYTeZkZAz8WbieLTvKUEUxZlUTdmL5KGMyZj7FnMfLNKV4+r5549aORG/mgojRmFlQMJDUupwAMiF2Q7OUg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-9BtHCPUARyVH1oXGcSJD3YpsqRLROJx5ZNP6tN5vnk17N0SVf9WCtf8Nuh1CFmgByKKAIMstitKduoCmsaDK5g== - /@babel/plugin-transform-computed-properties/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-destructuring/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-zym5em7tePoNT9s964c0/KU3JPPnuq7VhIxPRefJ4/s82cD+q1mgKfuGRDMCPL0HTyKz4dISuQlCusfgCJ86HA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-RRqTYTeZkZAz8WbieLTvKUEUxZlUTdmL5KGMyZj7FnMfLNKV4+r5549aORG/mgojRmFlQMJDUupwAMiF2Q7OUg== - /@babel/plugin-transform-destructuring/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-dotall-regex/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-foDrozE65ZFdUC2OfgeOCrEPTxdB3yjqxpXh8CH+ipd9CHd4s/iq81kcUpyH8ACGNEPdFqbtzfgzbT/ZGlbDeQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-zym5em7tePoNT9s964c0/KU3JPPnuq7VhIxPRefJ4/s82cD+q1mgKfuGRDMCPL0HTyKz4dISuQlCusfgCJ86HA== - /@babel/plugin-transform-dotall-regex/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-create-regexp-features-plugin': 7.12.17_@babel+core@7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-duplicate-keys/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-NfADJiiHdhLBW3pulJlJI2NB0t4cci4WTZ8FtdIuNc2+8pslXdPtRRAEWqUY+m9kNOk2eRYbTAOipAxlrOcwwQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-foDrozE65ZFdUC2OfgeOCrEPTxdB3yjqxpXh8CH+ipd9CHd4s/iq81kcUpyH8ACGNEPdFqbtzfgzbT/ZGlbDeQ== - /@babel/plugin-transform-duplicate-keys/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-exponentiation-operator/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-fbUelkM1apvqez/yYx1/oICVnGo2KM5s63mhGylrmXUxK/IAXSIf87QIxVfZldWf4QsOafY6vV3bX8aMHSvNrA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-NfADJiiHdhLBW3pulJlJI2NB0t4cci4WTZ8FtdIuNc2+8pslXdPtRRAEWqUY+m9kNOk2eRYbTAOipAxlrOcwwQ== - /@babel/plugin-transform-exponentiation-operator/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-builder-binary-assignment-operator-visitor': 7.12.13 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-flow-strip-types/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-8hAtkmsQb36yMmEtk2JZ9JnVyDSnDOdlB+0nEGzIDLuK4yR3JcEjfuFPYkdEPSh8Id+rAMeBEn+X0iVEyho6Hg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-fbUelkM1apvqez/yYx1/oICVnGo2KM5s63mhGylrmXUxK/IAXSIf87QIxVfZldWf4QsOafY6vV3bX8aMHSvNrA== - /@babel/plugin-transform-flow-strip-types/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-flow': 7.12.13_@babel+core@7.12.3 dev: true + + /@babel/plugin-transform-for-of/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-IHKT00mwUVYE0zzbkDgNRP6SRzvfGCYsOxIRz8KsiaaHCcT9BWIkO+H9QRJseHBLOGBZkHUdHiqj6r0POsdytg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-8hAtkmsQb36yMmEtk2JZ9JnVyDSnDOdlB+0nEGzIDLuK4yR3JcEjfuFPYkdEPSh8Id+rAMeBEn+X0iVEyho6Hg== - /@babel/plugin-transform-for-of/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-function-name/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-6K7gZycG0cmIwwF7uMK/ZqeCikCGVBdyP2J5SKNCXO5EOHcqi+z7Jwf8AmyDNcBgxET8DrEtCt/mPKPyAzXyqQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-IHKT00mwUVYE0zzbkDgNRP6SRzvfGCYsOxIRz8KsiaaHCcT9BWIkO+H9QRJseHBLOGBZkHUdHiqj6r0POsdytg== - /@babel/plugin-transform-function-name/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-function-name': 7.12.13 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-literals/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-FW+WPjSR7hiUxMcKqyNjP05tQ2kmBCdpEpZHY1ARm96tGQCCBvXKnpjILtDplUnJ/eHZ0lALLM+d2lMFSpYJrQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-6K7gZycG0cmIwwF7uMK/ZqeCikCGVBdyP2J5SKNCXO5EOHcqi+z7Jwf8AmyDNcBgxET8DrEtCt/mPKPyAzXyqQ== - /@babel/plugin-transform-literals/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-member-expression-literals/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-kxLkOsg8yir4YeEPHLuO2tXP9R/gTjpuTOjshqSpELUN3ZAg2jfDnKUvzzJxObun38sw3wm4Uu69sX/zA7iRvg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-FW+WPjSR7hiUxMcKqyNjP05tQ2kmBCdpEpZHY1ARm96tGQCCBvXKnpjILtDplUnJ/eHZ0lALLM+d2lMFSpYJrQ== - /@babel/plugin-transform-member-expression-literals/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-modules-amd/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-EKy/E2NHhY/6Vw5d1k3rgoobftcNUmp9fGjb9XZwQLtTctsRBOTRO7RHHxfIky1ogMN5BxN7p9uMA3SzPfotMQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-kxLkOsg8yir4YeEPHLuO2tXP9R/gTjpuTOjshqSpELUN3ZAg2jfDnKUvzzJxObun38sw3wm4Uu69sX/zA7iRvg== - /@babel/plugin-transform-modules-amd/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-module-transforms': 7.13.12 '@babel/helper-plugin-utils': 7.13.0 babel-plugin-dynamic-import-node: 2.3.3 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-transform-modules-commonjs/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-9QiOx4MEGglfYZ4XOnU79OHr6vIWUakIj9b4mioN8eQIoEh+pf5p/zEB36JpDFWA12nNMiRf7bfoRvl9Rn79Bw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-EKy/E2NHhY/6Vw5d1k3rgoobftcNUmp9fGjb9XZwQLtTctsRBOTRO7RHHxfIky1ogMN5BxN7p9uMA3SzPfotMQ== - /@babel/plugin-transform-modules-commonjs/7.13.8_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-module-transforms': 7.13.12 '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-simple-access': 7.13.12 babel-plugin-dynamic-import-node: 2.3.3 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-transform-modules-systemjs/7.13.8_@babel+core@7.12.3: + resolution: {integrity: sha512-hwqctPYjhM6cWvVIlOIe27jCIBgHCsdH2xCJVAYQm7V5yTMoilbVMi9f6wKg0rpQAOn6ZG4AOyvCqFF/hUh6+A==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-9QiOx4MEGglfYZ4XOnU79OHr6vIWUakIj9b4mioN8eQIoEh+pf5p/zEB36JpDFWA12nNMiRf7bfoRvl9Rn79Bw== - /@babel/plugin-transform-modules-systemjs/7.13.8_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-hoist-variables': 7.13.0 @@ -937,122 +1297,128 @@ packages: '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-validator-identifier': 7.12.11 babel-plugin-dynamic-import-node: 2.3.3 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-transform-modules-umd/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-D/ILzAh6uyvkWjKKyFE/W0FzWwasv6vPTSqPcjxFqn6QpX3u8DjRVliq4F2BamO2Wee/om06Vyy+vPkNrd4wxw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-hwqctPYjhM6cWvVIlOIe27jCIBgHCsdH2xCJVAYQm7V5yTMoilbVMi9f6wKg0rpQAOn6ZG4AOyvCqFF/hUh6+A== - /@babel/plugin-transform-modules-umd/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-module-transforms': 7.13.12 '@babel/helper-plugin-utils': 7.13.0 + transitivePeerDependencies: + - supports-color dev: true - peerDependencies: - '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-D/ILzAh6uyvkWjKKyFE/W0FzWwasv6vPTSqPcjxFqn6QpX3u8DjRVliq4F2BamO2Wee/om06Vyy+vPkNrd4wxw== + /@babel/plugin-transform-named-capturing-groups-regex/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-Xsm8P2hr5hAxyYblrfACXpQKdQbx4m2df9/ZZSQ8MAhsadw06+jW7s9zsSw6he+mJZXRlVMyEnVktJo4zjk1WA==} + peerDependencies: + '@babel/core': ^7.0.0 dependencies: '@babel/core': 7.12.3 '@babel/helper-create-regexp-features-plugin': 7.12.17_@babel+core@7.12.3 dev: true - peerDependencies: - '@babel/core': ^7.0.0 - resolution: - integrity: sha512-Xsm8P2hr5hAxyYblrfACXpQKdQbx4m2df9/ZZSQ8MAhsadw06+jW7s9zsSw6he+mJZXRlVMyEnVktJo4zjk1WA== + /@babel/plugin-transform-new-target/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-/KY2hbLxrG5GTQ9zzZSc3xWiOy379pIETEhbtzwZcw9rvuaVV4Fqy7BYGYOWZnaoXIQYbbJ0ziXLa/sKcGCYEQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-object-super/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-JzYIcj3XtYspZDV8j9ulnoMPZZnF/Cj0LUxPOjR89BdBVx+zYJI9MdMIlUZjbXDX+6YVeS6I3e8op+qQ3BYBoQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-/KY2hbLxrG5GTQ9zzZSc3xWiOy379pIETEhbtzwZcw9rvuaVV4Fqy7BYGYOWZnaoXIQYbbJ0ziXLa/sKcGCYEQ== - /@babel/plugin-transform-object-super/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-replace-supers': 7.13.12 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-transform-parameters/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-Jt8k/h/mIwE2JFEOb3lURoY5C85ETcYPnbuAJ96zRBzh1XHtQZfs62ChZ6EP22QlC8c7Xqr9q+e1SU5qttwwjw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-JzYIcj3XtYspZDV8j9ulnoMPZZnF/Cj0LUxPOjR89BdBVx+zYJI9MdMIlUZjbXDX+6YVeS6I3e8op+qQ3BYBoQ== - /@babel/plugin-transform-parameters/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-property-literals/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-nqVigwVan+lR+g8Fj8Exl0UQX2kymtjcWfMOYM1vTYEKujeyv2SkMgazf2qNcK7l4SDiKyTA/nHCPqL4e2zo1A==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-Jt8k/h/mIwE2JFEOb3lURoY5C85ETcYPnbuAJ96zRBzh1XHtQZfs62ChZ6EP22QlC8c7Xqr9q+e1SU5qttwwjw== - /@babel/plugin-transform-property-literals/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-react-constant-elements/7.13.13_@babel+core@7.12.3: + resolution: {integrity: sha512-SNJU53VM/SjQL0bZhyU+f4kJQz7bQQajnrZRSaU21hruG/NWY41AEM9AWXeXX90pYr/C2yAmTgI6yW3LlLrAUQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-nqVigwVan+lR+g8Fj8Exl0UQX2kymtjcWfMOYM1vTYEKujeyv2SkMgazf2qNcK7l4SDiKyTA/nHCPqL4e2zo1A== - /@babel/plugin-transform-react-constant-elements/7.13.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-react-display-name/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-MprESJzI9O5VnJZrL7gg1MpdqmiFcUv41Jc7SahxYsNP2kDkFqClxxTZq+1Qv4AFCamm+GXMRDQINNn+qrxmiA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-SNJU53VM/SjQL0bZhyU+f4kJQz7bQQajnrZRSaU21hruG/NWY41AEM9AWXeXX90pYr/C2yAmTgI6yW3LlLrAUQ== - /@babel/plugin-transform-react-display-name/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-react-display-name/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-cAzB+UzBIrekfYxyLlFqf/OagTvHLcVBb5vpouzkYkBclRPraiygVnafvAoipErZLI8ANv8Ecn6E/m5qPXD26w==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-MprESJzI9O5VnJZrL7gg1MpdqmiFcUv41Jc7SahxYsNP2kDkFqClxxTZq+1Qv4AFCamm+GXMRDQINNn+qrxmiA== - /@babel/plugin-transform-react-display-name/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-react-jsx-development/7.12.17_@babel+core@7.12.3: + resolution: {integrity: sha512-BPjYV86SVuOaudFhsJR1zjgxxOhJDt6JHNoD48DxWEIxUCAMjV1ys6DYw4SDYZh0b1QsS2vfIA9t/ZsQGsDOUQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-cAzB+UzBIrekfYxyLlFqf/OagTvHLcVBb5vpouzkYkBclRPraiygVnafvAoipErZLI8ANv8Ecn6E/m5qPXD26w== - /@babel/plugin-transform-react-jsx-development/7.12.17_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/plugin-transform-react-jsx': 7.13.12_@babel+core@7.12.3 dev: true + + /@babel/plugin-transform-react-jsx-self/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-FXYw98TTJ125GVCCkFLZXlZ1qGcsYqNQhVBQcZjyrwf8FEUtVfKIoidnO8S0q+KBQpDYNTmiGo1gn67Vti04lQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-BPjYV86SVuOaudFhsJR1zjgxxOhJDt6JHNoD48DxWEIxUCAMjV1ys6DYw4SDYZh0b1QsS2vfIA9t/ZsQGsDOUQ== - /@babel/plugin-transform-react-jsx-self/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-react-jsx-source/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-O5JJi6fyfih0WfDgIJXksSPhGP/G0fQpfxYy87sDc+1sFmsCS6wr3aAn+whbzkhbjtq4VMqLRaSzR6IsshIC0Q==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-FXYw98TTJ125GVCCkFLZXlZ1qGcsYqNQhVBQcZjyrwf8FEUtVfKIoidnO8S0q+KBQpDYNTmiGo1gn67Vti04lQ== - /@babel/plugin-transform-react-jsx-source/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-react-jsx/7.13.12_@babel+core@7.12.3: + resolution: {integrity: sha512-jcEI2UqIcpCqB5U5DRxIl0tQEProI2gcu+g8VTIqxLO5Iidojb4d77q+fwGseCvd8af/lJ9masp4QWzBXFE2xA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-O5JJi6fyfih0WfDgIJXksSPhGP/G0fQpfxYy87sDc+1sFmsCS6wr3aAn+whbzkhbjtq4VMqLRaSzR6IsshIC0Q== - /@babel/plugin-transform-react-jsx/7.13.12_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-annotate-as-pure': 7.12.13 @@ -1061,39 +1427,39 @@ packages: '@babel/plugin-syntax-jsx': 7.12.13_@babel+core@7.12.3 '@babel/types': 7.13.13 dev: true + + /@babel/plugin-transform-react-pure-annotations/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-jcEI2UqIcpCqB5U5DRxIl0tQEProI2gcu+g8VTIqxLO5Iidojb4d77q+fwGseCvd8af/lJ9masp4QWzBXFE2xA== - /@babel/plugin-transform-react-pure-annotations/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-annotate-as-pure': 7.12.13 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-regenerator/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-lxb2ZAvSLyJ2PEe47hoGWPmW22v7CtSl9jW8mingV4H2sEX/JOcrAj2nPuGWi56ERUm2bUpjKzONAuT6HCn2EA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg== - /@babel/plugin-transform-regenerator/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 regenerator-transform: 0.14.5 dev: true + + /@babel/plugin-transform-reserved-words/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-xhUPzDXxZN1QfiOy/I5tyye+TRz6lA7z6xaT4CLOjPRMVg1ldRf0LHw0TDBpYL4vG78556WuHdyO9oi5UmzZBg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-lxb2ZAvSLyJ2PEe47hoGWPmW22v7CtSl9jW8mingV4H2sEX/JOcrAj2nPuGWi56ERUm2bUpjKzONAuT6HCn2EA== - /@babel/plugin-transform-reserved-words/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-runtime/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-Ac/H6G9FEIkS2tXsZjL4RAdS3L3WHxci0usAnz7laPWUmFiGtj7tIASChqKZMHTSQTQY6xDbOq+V1/vIq3QrWg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-xhUPzDXxZN1QfiOy/I5tyye+TRz6lA7z6xaT4CLOjPRMVg1ldRf0LHw0TDBpYL4vG78556WuHdyO9oi5UmzZBg== - /@babel/plugin-transform-runtime/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-module-imports': 7.13.12 @@ -1101,87 +1467,89 @@ packages: resolve: 1.18.1 semver: 5.7.1 dev: true + + /@babel/plugin-transform-shorthand-properties/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-xpL49pqPnLtf0tVluuqvzWIgLEhuPpZzvs2yabUHSKRNlN7ScYU7aMlmavOeyXJZKgZKQRBlh8rHbKiJDraTSw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-Ac/H6G9FEIkS2tXsZjL4RAdS3L3WHxci0usAnz7laPWUmFiGtj7tIASChqKZMHTSQTQY6xDbOq+V1/vIq3QrWg== - /@babel/plugin-transform-shorthand-properties/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-spread/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-V6vkiXijjzYeFmQTr3dBxPtZYLPcUfY34DebOU27jIl2M/Y8Egm52Hw82CSjjPqd54GTlJs5x+CR7HeNr24ckg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-xpL49pqPnLtf0tVluuqvzWIgLEhuPpZzvs2yabUHSKRNlN7ScYU7aMlmavOeyXJZKgZKQRBlh8rHbKiJDraTSw== - /@babel/plugin-transform-spread/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/helper-skip-transparent-expression-wrappers': 7.12.1 dev: true + + /@babel/plugin-transform-sticky-regex/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-Jc3JSaaWT8+fr7GRvQP02fKDsYk4K/lYwWq38r/UGfaxo89ajud321NH28KRQ7xy1Ybc0VUE5Pz8psjNNDUglg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-V6vkiXijjzYeFmQTr3dBxPtZYLPcUfY34DebOU27jIl2M/Y8Egm52Hw82CSjjPqd54GTlJs5x+CR7HeNr24ckg== - /@babel/plugin-transform-sticky-regex/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-template-literals/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-d67umW6nlfmr1iehCcBv69eSUSySk1EsIS8aTDX4Xo9qajAh6mYtcl4kJrBkGXuxZPEgVr7RVfAvNW6YQkd4Mw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-Jc3JSaaWT8+fr7GRvQP02fKDsYk4K/lYwWq38r/UGfaxo89ajud321NH28KRQ7xy1Ybc0VUE5Pz8psjNNDUglg== - /@babel/plugin-transform-template-literals/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-typeof-symbol/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-eKv/LmUJpMnu4npgfvs3LiHhJua5fo/CysENxa45YCQXZwKnGCQKAg87bvoqSW1fFT+HA32l03Qxsm8ouTY3ZQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-d67umW6nlfmr1iehCcBv69eSUSySk1EsIS8aTDX4Xo9qajAh6mYtcl4kJrBkGXuxZPEgVr7RVfAvNW6YQkd4Mw== - /@babel/plugin-transform-typeof-symbol/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-typescript/7.13.0_@babel+core@7.12.3: + resolution: {integrity: sha512-elQEwluzaU8R8dbVuW2Q2Y8Nznf7hnjM7+DSCd14Lo5fF63C9qNLbwZYbmZrtV9/ySpSUpkRpQXvJb6xyu4hCQ==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-eKv/LmUJpMnu4npgfvs3LiHhJua5fo/CysENxa45YCQXZwKnGCQKAg87bvoqSW1fFT+HA32l03Qxsm8ouTY3ZQ== - /@babel/plugin-transform-typescript/7.13.0_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-create-class-features-plugin': 7.13.11_@babel+core@7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-syntax-typescript': 7.12.13_@babel+core@7.12.3 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/plugin-transform-unicode-escapes/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-0bHEkdwJ/sN/ikBHfSmOXPypN/beiGqjo+o4/5K+vxEFNPRPdImhviPakMKG4x96l85emoa0Z6cDflsdBusZbw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-elQEwluzaU8R8dbVuW2Q2Y8Nznf7hnjM7+DSCd14Lo5fF63C9qNLbwZYbmZrtV9/ySpSUpkRpQXvJb6xyu4hCQ== - /@babel/plugin-transform-unicode-escapes/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/plugin-transform-unicode-regex/7.12.13_@babel+core@7.12.3: + resolution: {integrity: sha512-mDRzSNY7/zopwisPZ5kM9XKCfhchqIYwAKRERtEnhYscZB79VRekuRSoYbN0+KVe3y8+q1h6A4svXtP7N+UoCA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-0bHEkdwJ/sN/ikBHfSmOXPypN/beiGqjo+o4/5K+vxEFNPRPdImhviPakMKG4x96l85emoa0Z6cDflsdBusZbw== - /@babel/plugin-transform-unicode-regex/7.12.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-create-regexp-features-plugin': 7.12.17_@babel+core@7.12.3 '@babel/helper-plugin-utils': 7.13.0 dev: true + + /@babel/preset-env/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-H8kxXmtPaAGT7TyBvSSkoSTUK6RHh61So05SyEbpmr0MCZrsNYn7mGMzzeYoOUCdHzww61k8XBft2TaES+xPLg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-mDRzSNY7/zopwisPZ5kM9XKCfhchqIYwAKRERtEnhYscZB79VRekuRSoYbN0+KVe3y8+q1h6A4svXtP7N+UoCA== - /@babel/preset-env/7.12.1_@babel+core@7.12.3: dependencies: '@babel/compat-data': 7.13.12 '@babel/core': 7.12.3 @@ -1250,12 +1618,14 @@ packages: '@babel/types': 7.13.13 core-js-compat: 3.9.1 semver: 5.7.1 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/preset-env/7.13.12_@babel+core@7.12.3: + resolution: {integrity: sha512-JzElc6jk3Ko6zuZgBtjOd01pf9yYDEIH8BcqVuYIuOkzOwDesoa/Nz4gIo4lBG6K861KTV9TvIgmFuT6ytOaAA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-H8kxXmtPaAGT7TyBvSSkoSTUK6RHh61So05SyEbpmr0MCZrsNYn7mGMzzeYoOUCdHzww61k8XBft2TaES+xPLg== - /@babel/preset-env/7.13.12_@babel+core@7.12.3: dependencies: '@babel/compat-data': 7.13.12 '@babel/core': 7.12.3 @@ -1327,12 +1697,14 @@ packages: babel-plugin-polyfill-regenerator: 0.1.6_@babel+core@7.12.3 core-js-compat: 3.9.1 semver: 6.3.0 + transitivePeerDependencies: + - supports-color dev: true + + /@babel/preset-modules/0.1.4_@babel+core@7.12.3: + resolution: {integrity: sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-JzElc6jk3Ko6zuZgBtjOd01pf9yYDEIH8BcqVuYIuOkzOwDesoa/Nz4gIo4lBG6K861KTV9TvIgmFuT6ytOaAA== - /@babel/preset-modules/0.1.4_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 @@ -1341,11 +1713,11 @@ packages: '@babel/types': 7.13.13 esutils: 2.0.3 dev: true + + /@babel/preset-react/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-euCExymHCi0qB9u5fKw7rvlw7AZSjw/NaB9h7EkdTt5+yHRrXdiRTh7fkG3uBPpJg82CqLfp1LHLqWGSCrab+g==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg== - /@babel/preset-react/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 @@ -1356,11 +1728,11 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.12.13_@babel+core@7.12.3 '@babel/plugin-transform-react-pure-annotations': 7.12.1_@babel+core@7.12.3 dev: true + + /@babel/preset-react/7.13.13_@babel+core@7.12.3: + resolution: {integrity: sha512-gx+tDLIE06sRjKJkVtpZ/t3mzCDOnPG+ggHZG9lffUbX8+wC739x20YQc9V35Do6ZAxaUc/HhVHIiOzz5MvDmA==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-euCExymHCi0qB9u5fKw7rvlw7AZSjw/NaB9h7EkdTt5+yHRrXdiRTh7fkG3uBPpJg82CqLfp1LHLqWGSCrab+g== - /@babel/preset-react/7.13.13_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 @@ -1370,46 +1742,74 @@ packages: '@babel/plugin-transform-react-jsx-development': 7.12.17_@babel+core@7.12.3 '@babel/plugin-transform-react-pure-annotations': 7.12.1_@babel+core@7.12.3 dev: true + + /@babel/preset-typescript/7.12.1_@babel+core@7.12.3: + resolution: {integrity: sha512-hNK/DhmoJPsksdHuI/RVrcEws7GN5eamhi28JkO52MqIxU8Z0QpmiSOQxZHWOHV7I3P4UjHV97ay4TcamMA6Kw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-gx+tDLIE06sRjKJkVtpZ/t3mzCDOnPG+ggHZG9lffUbX8+wC739x20YQc9V35Do6ZAxaUc/HhVHIiOzz5MvDmA== - /@babel/preset-typescript/7.12.1_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-plugin-utils': 7.13.0 '@babel/plugin-transform-typescript': 7.13.0_@babel+core@7.12.3 + transitivePeerDependencies: + - supports-color dev: true - peerDependencies: - '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-hNK/DhmoJPsksdHuI/RVrcEws7GN5eamhi28JkO52MqIxU8Z0QpmiSOQxZHWOHV7I3P4UjHV97ay4TcamMA6Kw== + /@babel/runtime-corejs3/7.13.10: + resolution: {integrity: sha512-x/XYVQ1h684pp1mJwOV4CyvqZXqbc8CMsMGUnAbuc82ZCdv1U63w5RSUzgDSXQHG5Rps/kiksH6g2D5BuaKyXg==} dependencies: core-js-pure: 3.9.1 regenerator-runtime: 0.13.7 dev: true - resolution: - integrity: sha512-x/XYVQ1h684pp1mJwOV4CyvqZXqbc8CMsMGUnAbuc82ZCdv1U63w5RSUzgDSXQHG5Rps/kiksH6g2D5BuaKyXg== + /@babel/runtime/7.12.1: + resolution: {integrity: sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA==} dependencies: regenerator-runtime: 0.13.7 dev: true - resolution: - integrity: sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA== + + /@babel/runtime/7.12.5: + resolution: {integrity: sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==} + dependencies: + regenerator-runtime: 0.13.9 + dev: true + /@babel/runtime/7.13.10: + resolution: {integrity: sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==} dependencies: regenerator-runtime: 0.13.7 - resolution: - integrity: sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw== + + /@babel/runtime/7.14.8: + resolution: {integrity: sha512-twj3L8Og5SaCRCErB4x4ajbvBIVV77CGeFglHpeg5WC5FF8TZzBWXtTJ4MqaD9QszLYTtr+IsaAL2rEUevb+eg==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.13.9 + + /@babel/runtime/7.20.13: + resolution: {integrity: sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.13.11 + dev: false + /@babel/template/7.12.13: + resolution: {integrity: sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA==} dependencies: '@babel/code-frame': 7.12.13 '@babel/parser': 7.13.13 '@babel/types': 7.13.13 - resolution: - integrity: sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA== + + /@babel/template/7.18.10: + resolution: {integrity: sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.18.6 + '@babel/parser': 7.20.5 + '@babel/types': 7.20.5 + dev: true + /@babel/traverse/7.13.13: + resolution: {integrity: sha512-CblEcwmXKR6eP43oQGG++0QMTtCjAsa3frUuzHoiIJWpaIIi8dwMyEFUJoXRLxagGqCK+jALRwIO+o3R9p/uUg==} dependencies: '@babel/code-frame': 7.12.13 '@babel/generator': 7.13.9 @@ -1419,10 +1819,12 @@ packages: '@babel/types': 7.13.13 debug: 4.3.1 globals: 11.12.0 + transitivePeerDependencies: + - supports-color dev: true - resolution: - integrity: sha512-CblEcwmXKR6eP43oQGG++0QMTtCjAsa3frUuzHoiIJWpaIIi8dwMyEFUJoXRLxagGqCK+jALRwIO+o3R9p/uUg== + /@babel/traverse/7.13.13_supports-color@5.5.0: + resolution: {integrity: sha512-CblEcwmXKR6eP43oQGG++0QMTtCjAsa3frUuzHoiIJWpaIIi8dwMyEFUJoXRLxagGqCK+jALRwIO+o3R9p/uUg==} dependencies: '@babel/code-frame': 7.12.13 '@babel/generator': 7.13.9 @@ -1432,73 +1834,105 @@ packages: '@babel/types': 7.13.13 debug: 4.3.1_supports-color@5.5.0 globals: 11.12.0 - dev: false - peerDependencies: - supports-color: '*' - resolution: - integrity: sha512-CblEcwmXKR6eP43oQGG++0QMTtCjAsa3frUuzHoiIJWpaIIi8dwMyEFUJoXRLxagGqCK+jALRwIO+o3R9p/uUg== + transitivePeerDependencies: + - supports-color + dev: false + + /@babel/traverse/7.20.5: + resolution: {integrity: sha512-WM5ZNN3JITQIq9tFZaw1ojLU3WgWdtkxnhM1AegMS+PvHjkM5IXjmYEGY7yukz5XS4sJyEf2VzWjI8uAavhxBQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.18.6 + '@babel/generator': 7.20.5 + '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-function-name': 7.19.0 + '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-split-export-declaration': 7.18.6 + '@babel/parser': 7.20.5 + '@babel/types': 7.20.5 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/types/7.13.13: + resolution: {integrity: sha512-kt+EpC6qDfIaqlP+DIbIJOclYy/A1YXs9dAf/ljbi+39Bcbc073H6jKVpXEr/EoIh5anGn5xq/yRVzKl+uIc9w==} dependencies: '@babel/helper-validator-identifier': 7.12.11 lodash: 4.17.21 to-fast-properties: 2.0.0 - resolution: - integrity: sha512-kt+EpC6qDfIaqlP+DIbIJOclYy/A1YXs9dAf/ljbi+39Bcbc073H6jKVpXEr/EoIh5anGn5xq/yRVzKl+uIc9w== + + /@babel/types/7.20.5: + resolution: {integrity: sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.19.4 + '@babel/helper-validator-identifier': 7.19.1 + to-fast-properties: 2.0.0 + dev: true + + /@babel/types/7.20.7: + resolution: {integrity: sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.19.4 + '@babel/helper-validator-identifier': 7.19.1 + to-fast-properties: 2.0.0 + dev: true + /@bcoe/v8-coverage/0.2.3: + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true - resolution: - integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + + /@bloomberg/record-tuple-polyfill/0.0.3: + resolution: {integrity: sha512-sBnCqW0nqofE47mxFnw+lvx6kzsQstwaQMVkh66qm/A6IlsnH7WsyGuVXTou8RF2wL4W7ybOoHPvP2WgIo6rhQ==} + dev: true + /@cnakazawa/watch/1.0.4: + resolution: {integrity: sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==} + engines: {node: '>=0.1.95'} + hasBin: true dependencies: exec-sh: 0.3.6 minimist: 1.2.5 dev: true - engines: - node: '>=0.1.95' - hasBin: true - resolution: - integrity: sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ== + /@csstools/convert-colors/1.4.0: + resolution: {integrity: sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw==} + engines: {node: '>=4.0.0'} dev: true - engines: - node: '>=4.0.0' - resolution: - integrity: sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw== + /@csstools/normalize.css/10.1.0: + resolution: {integrity: sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==} dev: true - resolution: - integrity: sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg== - /@ctrl/tinycolor/3.4.0: - dev: false - engines: - node: '>=10' - resolution: - integrity: sha512-JZButFdZ1+/xAfpguQHoabIXkcqRRKpMrWKBkpEZZyxfY9C1DpADFB8PEqGSTeFr135SaTRfKqGKx5xSCLI7ZQ== + /@discoveryjs/json-ext/0.5.2: + resolution: {integrity: sha512-HyYEUDeIj5rRQU2Hk5HTB2uHsbRQpF70nvMhVzi+VJR0X+xNEhjPui4/kBf3VeH/wqD28PT4sVOm8qqLjBrSZg==} + engines: {node: '>=10.0.0'} dev: true - engines: - node: '>=10.0.0' - resolution: - integrity: sha512-HyYEUDeIj5rRQU2Hk5HTB2uHsbRQpF70nvMhVzi+VJR0X+xNEhjPui4/kBf3VeH/wqD28PT4sVOm8qqLjBrSZg== + /@emotion/is-prop-valid/0.8.8: + resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} dependencies: '@emotion/memoize': 0.7.4 dev: false - resolution: - integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== + /@emotion/memoize/0.7.4: + resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} dev: false - resolution: - integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== + /@emotion/stylis/0.8.5: + resolution: {integrity: sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==} dev: false - resolution: - integrity: sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ== + /@emotion/unitless/0.7.5: + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} dev: false - resolution: - integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== + /@eslint/eslintrc/0.4.0: + resolution: {integrity: sha512-2ZPCc+uNbjV5ERJr+aKSPRwZgKd2z11x0EgLvb1PURmUrn9QNRXFqje0Ldq454PfAVyaJYyrDvvIKSFP4NnBog==} + engines: {node: ^10.12.0 || >=12.0.0} dependencies: ajv: 6.12.6 debug: 4.3.1 @@ -1509,216 +1943,149 @@ packages: js-yaml: 3.14.1 minimatch: 3.0.4 strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: ^10.12.0 || >=12.0.0 - resolution: - integrity: sha512-2ZPCc+uNbjV5ERJr+aKSPRwZgKd2z11x0EgLvb1PURmUrn9QNRXFqje0Ldq454PfAVyaJYyrDvvIKSFP4NnBog== - /@formily/antd-components/1.3.13_b13a12cfbb184a60a7e1275d8262e450: - dependencies: - '@ant-design/icons': 4.6.2_react-dom@17.0.2+react@17.0.2 - '@formily/antd': 1.3.13_b13a12cfbb184a60a7e1275d8262e450 - '@formily/react-schema-renderer': 1.3.13_d8837eed98748ff5a1dd894fdd80cd72 - '@formily/react-shared-components': 1.3.13 - '@formily/shared': 1.3.13 - antd: 4.14.1_2235c505ed33ea6efd93d3050f896208 - classnames: 2.2.6 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - styled-components: 5.2.1_react-dom@17.0.2+react@17.0.2 + + /@formily/core/2.2.12: + resolution: {integrity: sha512-79UjPFLm04zEvrrdIuQDdGG4g/Jdr78SKNfqGWDudHIgL+7pjcUn0usjspGiK2SP+f5ma/X7KMt2pktCQArHvw==} + engines: {npm: '>=3.0.0'} + dependencies: + '@formily/reactive': 2.2.12 + '@formily/shared': 2.2.12 + '@formily/validator': 2.2.12 dev: false - engines: - npm: '>=3.0.0' + + /@formily/json-schema/2.2.12_typescript@4.2.3: + resolution: {integrity: sha512-t1rKA748PVLcPpemlyXNhtGlgDNCMVJeFnxKDjrjU0vAMF3bnncQuwGVQzESvSX0e44u1PSCEFCPk7vymFNl4A==} + engines: {npm: '>=3.0.0'} peerDependencies: - '@types/react': '*' - antd: ^3.14.1 || ^4.0.0 - react: '>=16.8.0' - react-dom: '>=16.8.0' - styled-components: ^4.1.1 - resolution: - integrity: sha512-1QDTwXJ8srbJ1LjcQR5zdC1rFPk7m7vh4bfp0+mtyh6s1ifslnG2sJHESw9a7HqVDjEiVihLYcbUyvmDtVYnwQ== - /@formily/antd/1.3.13_b13a12cfbb184a60a7e1275d8262e450: - dependencies: - '@formily/react-schema-renderer': 1.3.13_f57bb6aef800468546cdc9a81ce5c019 - '@formily/react-shared-components': 1.3.13 - '@formily/shared': 1.3.13 - antd: 4.14.1_2235c505ed33ea6efd93d3050f896208 - classnames: 2.2.6 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - react-eva: 1.1.14 - react-stikky: 0.1.30 - rxjs: 6.6.7 - styled-components: 5.2.1_react-dom@17.0.2+react@17.0.2 + typescript: ^4.1.5 + dependencies: + '@formily/core': 2.2.12 + '@formily/reactive': 2.2.12 + '@formily/shared': 2.2.12 + typescript: 4.2.3 dev: false - engines: - npm: '>=3.0.0' - peerDependencies: - '@types/react': '*' - antd: ^3.14.1 || ^4.0.0 - react: '>=16.8.0' - react-dom: '>=16.8.0' - styled-components: ^4.1.1 - resolution: - integrity: sha512-xKXSkrlLI/jyf25/aVdxSYwXJ+WWYlagAz6dZzk6MVuXvH2SZHbCSEl7QvsFy8Up2UKVUysq0xD9WoSev7T/8w== - /@formily/core/1.3.13: - dependencies: - '@formily/shared': 1.3.13 - '@formily/validator': 1.3.13 - immer: 6.0.9 - dev: false - engines: - npm: '>=3.0.0' - peerDependencies: - scheduler: '>=0.11.2' - resolution: - integrity: sha512-Llwa0VbDOWptDgKz1l9gjn4V0Rrrx7cr7at2cDgBnH8qBCYPqvmsh9VBQ1mT/anvq1LbMoo8VoHG+CXTBa6UXA== - /@formily/react-schema-renderer/1.3.13_d8837eed98748ff5a1dd894fdd80cd72: - dependencies: - '@formily/core': 1.3.13 - '@formily/react': 1.3.13_d8837eed98748ff5a1dd894fdd80cd72 - '@formily/shared': 1.3.13 - '@formily/validator': 1.3.13 - '@types/react': 16.14.5 - hoist-non-react-statics: 3.3.2 - pascal-case: 2.0.1 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 + + /@formily/path/2.2.12: + resolution: {integrity: sha512-UZ7vL2Lj2S99YPqopKL6z53ShdtzJUOP1OROXKXRI0DkoDnpLYl3wtgUVUC1PNlOqF25K9ljw86Q5fVJMno6mw==} + engines: {npm: '>=3.0.0'} dev: false - engines: - npm: '>=3.0.0' + + /@formily/react/2.2.12_338bd5ec353cfb7439af722c4fbc028f: + resolution: {integrity: sha512-k8MZZ6Own3D8NdJs+/BDiTatyWY8cZ5x6Lmq/D/e8KtgVS88FB2cjgBh7vx31QYlsRxJR/27cfR77O+0OGzuzw==} + engines: {npm: '>=3.0.0'} peerDependencies: - '@types/react': ^16.8.23 + '@types/react': '>=16.8.0' + '@types/react-dom': '>=16.8.0' react: '>=16.8.0' react-dom: '>=16.8.0' - react-eva: ^1.1.7 - rxjs: ^6.5.1 - resolution: - integrity: sha512-2iFWqhv/EBwQOwsOAqBjI/mPnVTyymtumnMtkpis7conFK3NJstXnYzhRmYKRAV+41beSaKpesYu2HvtYrq1IA== - /@formily/react-schema-renderer/1.3.13_f57bb6aef800468546cdc9a81ce5c019: - dependencies: - '@formily/core': 1.3.13 - '@formily/react': 1.3.13_f57bb6aef800468546cdc9a81ce5c019 - '@formily/shared': 1.3.13 - '@formily/validator': 1.3.13 + react-is: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@formily/core': 2.2.12 + '@formily/json-schema': 2.2.12_typescript@4.2.3 + '@formily/reactive': 2.2.12 + '@formily/reactive-react': 2.2.12_f7ae02aba8ed3dfe5b262aea61025694 + '@formily/shared': 2.2.12 + '@formily/validator': 2.2.12 '@types/react': 16.14.5 + '@types/react-dom': 16.9.12 hoist-non-react-statics: 3.3.2 - pascal-case: 2.0.1 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - react-eva: 1.1.14 - rxjs: 6.6.7 + transitivePeerDependencies: + - typescript dev: false - engines: - npm: '>=3.0.0' + + /@formily/reactive-react/2.2.12_f7ae02aba8ed3dfe5b262aea61025694: + resolution: {integrity: sha512-jkH8j4q9Pw7Itppi6FF3YXF+J+GAMpWErfsmr8TlVsMBKs69hSP+Xet0CMrZga61bxMlq+UDylgQ7Dtuho57iA==} + engines: {npm: '>=3.0.0'} peerDependencies: - '@types/react': ^16.8.23 + '@types/react': '>=16.8.0' + '@types/react-dom': '>=16.8.0' react: '>=16.8.0' react-dom: '>=16.8.0' - react-eva: ^1.1.7 - rxjs: ^6.5.1 - resolution: - integrity: sha512-2iFWqhv/EBwQOwsOAqBjI/mPnVTyymtumnMtkpis7conFK3NJstXnYzhRmYKRAV+41beSaKpesYu2HvtYrq1IA== - /@formily/react-shared-components/1.3.13: - dependencies: - '@formily/shared': 1.3.13 - react-drag-listview: 0.1.8 - dev: false - engines: - npm: '>=3.0.0' - resolution: - integrity: sha512-6YSxgM0dAisMjg+77Lsi7uBl0jfXxVjZqRGKPssIinfFQiyVQg9JolYun/vsLnWWqCvFJabtxlikcpfhPI7j8A== - /@formily/react/1.3.13_d8837eed98748ff5a1dd894fdd80cd72: - dependencies: - '@formily/core': 1.3.13 - '@formily/shared': 1.3.13 + react-is: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@formily/reactive': 2.2.12 '@types/react': 16.14.5 + '@types/react-dom': 16.9.12 + hoist-non-react-statics: 3.3.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 dev: false - engines: - npm: '>=3.0.0' - peerDependencies: - '@types/react': ^16.8.23 - react: '>=16.8.0' - react-dom: '>=16.8.0' - react-eva: ^1.0.0-alpha.0 - rxjs: ^6.5.1 - resolution: - integrity: sha512-zbfCuGDvHBRnR4FwekOtU8KljJW2fb7aV7Lz3arMOhHYnUD1CoNasmpptPzNCi8SwkrNSJZ2dBu1ZSn7w4aM7Q== - /@formily/react/1.3.13_f57bb6aef800468546cdc9a81ce5c019: - dependencies: - '@formily/core': 1.3.13 - '@formily/shared': 1.3.13 - '@types/react': 16.14.5 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - react-eva: 1.1.14 - rxjs: 6.6.7 + + /@formily/reactive/2.2.12: + resolution: {integrity: sha512-iW3p0ENVblDQeThFh8d1QRjPOTpaTf8+LcTAPuwa2Gq6VzGfz3xpPGybgbvgkdcxesWWQGDMBDYoFtrCoyrvMQ==} + engines: {npm: '>=3.0.0'} dev: false - engines: - npm: '>=3.0.0' - peerDependencies: - '@types/react': ^16.8.23 - react: '>=16.8.0' - react-dom: '>=16.8.0' - react-eva: ^1.0.0-alpha.0 - rxjs: ^6.5.1 - resolution: - integrity: sha512-zbfCuGDvHBRnR4FwekOtU8KljJW2fb7aV7Lz3arMOhHYnUD1CoNasmpptPzNCi8SwkrNSJZ2dBu1ZSn7w4aM7Q== - /@formily/shared/1.3.13: - dependencies: - camel-case: 3.0.0 - cool-path: 0.1.32 - lower-case: 1.1.4 - scheduler: 0.19.1 - upper-case: 1.1.3 + + /@formily/shared/2.2.12: + resolution: {integrity: sha512-BhnG0aQ/4o2weDOig3IOm6A+8Pc3oQnjQky68FoK+NVoXQUkjTSdARYzMa7WzFK1uMzZWqeGvqx5FAs/HmlIgQ==} + engines: {npm: '>=3.0.0'} + dependencies: + '@formily/path': 2.2.12 + camel-case: 4.1.2 + lower-case: 2.0.2 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + upper-case: 2.0.2 dev: false - engines: - npm: '>=3.0.0' - resolution: - integrity: sha512-pu0/8xJT39NOqI8vYRQik4DK5gLoUsQgA3HLWeTR0TzyNA/b0ozKpri2FMl3sp4aab+S+QOJiY5tpIyJ+feldA== - /@formily/validator/1.3.13: + + /@formily/validator/2.2.12: + resolution: {integrity: sha512-CldnZFZgXO1Aw1rPBMuJ5UueCpD2GdTnui0fbSVXJcP7SI9SfDmKePl4mU0SQli38IJN6YDtao9Qnc52eSOXMQ==} + engines: {npm: '>=3.0.0'} dependencies: - '@formily/shared': 1.3.13 + '@formily/shared': 2.2.12 dev: false - engines: - npm: '>=3.0.0' - resolution: - integrity: sha512-+nKNc8Tdi/7P/3MgURq1uMp11ExFGTeVsnHI0bOFBNs5JyI6853evpfIZwrk/G/wh8ev6KGztRKpTd9x136EVg== + /@hapi/address/2.1.4: + resolution: {integrity: sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ==} deprecated: Moved to 'npm install @sideway/address' dev: true - resolution: - integrity: sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ== + /@hapi/bourne/1.3.2: + resolution: {integrity: sha512-1dVNHT76Uu5N3eJNTYcvxee+jzX4Z9lfciqRRHCU27ihbUcYi+iSc2iml5Ke1LXe1SyJCLA0+14Jh4tXJgOppA==} deprecated: This version has been deprecated and is no longer supported or maintained dev: true - resolution: - integrity: sha512-1dVNHT76Uu5N3eJNTYcvxee+jzX4Z9lfciqRRHCU27ihbUcYi+iSc2iml5Ke1LXe1SyJCLA0+14Jh4tXJgOppA== + /@hapi/hoek/8.5.1: + resolution: {integrity: sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow==} deprecated: This version has been deprecated and is no longer supported or maintained dev: true - resolution: - integrity: sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow== + /@hapi/joi/15.1.1: + resolution: {integrity: sha512-entf8ZMOK8sc+8YfeOlM8pCfg3b5+WZIKBfUaaJT8UsjAAPjartzxIYm3TIbjvA4u+u++KbcXD38k682nVHDAQ==} + deprecated: Switch to 'npm install joi' dependencies: '@hapi/address': 2.1.4 '@hapi/bourne': 1.3.2 '@hapi/hoek': 8.5.1 '@hapi/topo': 3.1.6 - deprecated: Switch to 'npm install joi' dev: true - resolution: - integrity: sha512-entf8ZMOK8sc+8YfeOlM8pCfg3b5+WZIKBfUaaJT8UsjAAPjartzxIYm3TIbjvA4u+u++KbcXD38k682nVHDAQ== + /@hapi/topo/3.1.6: + resolution: {integrity: sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ==} + deprecated: This version has been deprecated and is no longer supported or maintained dependencies: '@hapi/hoek': 8.5.1 - deprecated: This version has been deprecated and is no longer supported or maintained dev: true - resolution: - integrity: sha512-tAag0jEcjwH+P2quUfipd7liWCNX2F8NvYjQp2wtInsZxnMlypdw0FtAOLxtvvkO+GSRRbmNi8m/5y42PQJYCQ== + /@istanbuljs/load-nyc-config/1.1.0: + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} dependencies: camelcase: 5.3.1 find-up: 4.1.0 @@ -1726,17 +2093,15 @@ packages: js-yaml: 3.14.1 resolve-from: 5.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + /@istanbuljs/schema/0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + /@jest/console/26.6.2: + resolution: {integrity: sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 '@types/node': 12.20.7 @@ -1745,11 +2110,10 @@ packages: jest-util: 26.6.2 slash: 3.0.0 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g== + /@jest/core/26.6.3: + resolution: {integrity: sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/console': 26.6.2 '@jest/reporters': 26.6.2 @@ -1779,23 +2143,27 @@ packages: rimraf: 3.0.2 slash: 3.0.0 strip-ansi: 6.0.0 - dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw== + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + /@jest/environment/26.6.2: + resolution: {integrity: sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/fake-timers': 26.6.2 '@jest/types': 26.6.2 '@types/node': 12.20.7 jest-mock: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA== + /@jest/fake-timers/26.6.2: + resolution: {integrity: sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 '@sinonjs/fake-timers': 6.0.1 @@ -1804,21 +2172,19 @@ packages: jest-mock: 26.6.2 jest-util: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA== + /@jest/globals/26.6.2: + resolution: {integrity: sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/environment': 26.6.2 '@jest/types': 26.6.2 expect: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA== + /@jest/reporters/26.6.2: + resolution: {integrity: sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw==} + engines: {node: '>= 10.14.2'} dependencies: '@bcoe/v8-coverage': 0.2.3 '@jest/console': 26.6.2 @@ -1844,47 +2210,51 @@ packages: string-length: 4.0.2 terminal-link: 2.1.1 v8-to-istanbul: 7.1.0 - dev: true - engines: - node: '>= 10.14.2' optionalDependencies: node-notifier: 8.0.2 - resolution: - integrity: sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw== + transitivePeerDependencies: + - supports-color + dev: true + /@jest/source-map/26.6.2: + resolution: {integrity: sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA==} + engines: {node: '>= 10.14.2'} dependencies: callsites: 3.1.0 graceful-fs: 4.2.6 source-map: 0.6.1 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA== + /@jest/test-result/26.6.2: + resolution: {integrity: sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/console': 26.6.2 '@jest/types': 26.6.2 '@types/istanbul-lib-coverage': 2.0.3 collect-v8-coverage: 1.0.1 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ== + /@jest/test-sequencer/26.6.3: + resolution: {integrity: sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/test-result': 26.6.2 graceful-fs: 4.2.6 jest-haste-map: 26.6.2 jest-runner: 26.6.3 jest-runtime: 26.6.3 - dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw== + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + /@jest/transform/26.6.2: + resolution: {integrity: sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==} + engines: {node: '>= 10.14.2'} dependencies: '@babel/core': 7.12.3 '@jest/types': 26.6.2 @@ -1901,12 +2271,13 @@ packages: slash: 3.0.0 source-map: 0.6.1 write-file-atomic: 3.0.3 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA== + /@jest/types/26.6.2: + resolution: {integrity: sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==} + engines: {node: '>= 10.14.2'} dependencies: '@types/istanbul-lib-coverage': 2.0.3 '@types/istanbul-reports': 3.0.0 @@ -1914,76 +2285,102 @@ packages: '@types/yargs': 15.0.13 chalk: 4.1.0 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== - /@monaco-editor/loader/1.0.1: + + /@jridgewell/gen-mapping/0.3.2: + resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==} + engines: {node: '>=6.0.0'} dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/trace-mapping': 0.3.17 + dev: true + + /@jridgewell/resolve-uri/3.1.0: + resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/set-array/1.1.2: + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec/1.4.14: + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + dev: true + + /@jridgewell/trace-mapping/0.3.17: + resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /@mapbox/hast-util-to-jsx/1.0.0: + resolution: {integrity: sha512-HJRp3qkr0uGIBFASzA8rVATLo6y/UoOMoD8eXsG8HVofk5Dokc9PV+dh266zYLZniYgtpJbc2+AKf1fNpsVqAA==} + engines: {node: '>=10'} + dependencies: + kebab-case: 1.0.1 + postcss: 7.0.35 + postcss-js: 2.0.3 + property-information: 5.6.0 + react-attr-converter: 0.3.1 + stringify-entities: 3.1.0 + stringify-object: 3.3.0 + dev: true + + /@monaco-editor/loader/1.3.2_monaco-editor@0.34.1: + resolution: {integrity: sha512-BTDbpHl3e47r3AAtpfVFTlAi7WXv4UQ/xZmz8atKl4q7epQV5e7+JbigFDViWF71VBi4IIBdcWP57Hj+OWuc9g==} + peerDependencies: + monaco-editor: '>= 0.21.0 < 1' + dependencies: + monaco-editor: 0.34.1 state-local: 1.0.7 dev: false + + /@monaco-editor/react/4.4.6_ec62f306aa7ee40038c222aca8db4940: + resolution: {integrity: sha512-Gr3uz3LYf33wlFE3eRnta4RxP5FSNxiIV9ENn2D2/rN8KgGAD8ecvcITRtsbbyuOuNkwbuHYxfeaz2Vr+CtyFA==} peerDependencies: - monaco-editor: '>= 0.21.0 < 1' - resolution: - integrity: sha512-hycGOhLqLYjnD0A/FHs56covEQWnDFrSnm/qLKkB/yoeayQ7ju+Vaj4SdTojGrXeY6jhMDx59map0+Jqwquh1Q== - /@monaco-editor/react/4.1.0_react-dom@17.0.2+react@17.0.2: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - '@monaco-editor/loader': 1.0.1 - prop-types: 15.7.2 + '@monaco-editor/loader': 1.3.2_monaco-editor@0.34.1 + monaco-editor: 0.34.1 + prop-types: 15.8.1 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - state-local: 1.0.7 dev: false - peerDependencies: - monaco-editor: ^0.21.2 - react: ^16.8.0 || ^17.0.0 - react-dom: ^16.8.0 || ^17.0.0 - resolution: - integrity: sha512-Hh895v/KfGgckDLXq8sdDGT4xS89+2hbQOP1l57sLd2XlJycChdzPiCj02nQDIduLmUIVHittjaj1/xmy94C3A== + /@nodelib/fs.scandir/2.1.4: + resolution: {integrity: sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==} + engines: {node: '>= 8'} dependencies: '@nodelib/fs.stat': 2.0.4 run-parallel: 1.2.0 - engines: - node: '>= 8' - resolution: - integrity: sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA== + /@nodelib/fs.stat/2.0.4: - engines: - node: '>= 8' - resolution: - integrity: sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q== + resolution: {integrity: sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==} + engines: {node: '>= 8'} + /@nodelib/fs.walk/1.2.6: + resolution: {integrity: sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==} + engines: {node: '>= 8'} dependencies: '@nodelib/fs.scandir': 2.1.4 fastq: 1.11.0 - engines: - node: '>= 8' - resolution: - integrity: sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow== + /@npmcli/move-file/1.1.2: + resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} + engines: {node: '>=10'} dependencies: mkdirp: 1.0.4 rimraf: 3.0.2 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg== + /@pmmmwh/react-refresh-webpack-plugin/0.4.2_d00fcc46a48175a4e289da7534b00e9a: - dependencies: - ansi-html: 0.0.7 - error-stack-parser: 2.0.6 - html-entities: 1.4.0 - native-url: 0.2.6 - react-refresh: 0.8.3 - schema-utils: 2.7.1 - source-map: 0.7.3 - webpack: 4.44.2_webpack-cli@4.6.0 - webpack-dev-server: 3.11.0_webpack-cli@4.6.0+webpack@4.44.2 - dev: true - engines: - node: '>= 10.x' + resolution: {integrity: sha512-Loc4UDGutcZ+Bd56hBInkm6JyjyCwWy4t2wcDXzN8EDPANgVRj0VP8Nxn0Zq2pc+WKauZwEivQgbDGg4xZO20A==} + engines: {node: '>= 10.x'} peerDependencies: '@types/webpack': 4.x react-refresh: ^0.8.3 @@ -2006,9 +2403,23 @@ packages: optional: true webpack-plugin-serve: optional: true - resolution: - integrity: sha512-Loc4UDGutcZ+Bd56hBInkm6JyjyCwWy4t2wcDXzN8EDPANgVRj0VP8Nxn0Zq2pc+WKauZwEivQgbDGg4xZO20A== + dependencies: + ansi-html: 0.0.7 + error-stack-parser: 2.0.6 + html-entities: 1.4.0 + native-url: 0.2.6 + react-refresh: 0.8.3 + schema-utils: 2.7.1 + source-map: 0.7.3 + webpack: 4.44.2_webpack-cli@4.6.0 + webpack-dev-server: 3.11.0_webpack-cli@4.6.0+webpack@4.44.2 + dev: true + /@rollup/plugin-node-resolve/7.1.3_rollup@1.32.1: + resolution: {integrity: sha512-RxtSL3XmdTAE2byxekYLnx+98kEUOrPHF/KRVjLH+DEIHy6kjIw7YINQzn+NXiH/NTrQLAwYs0GWB+csWygA9Q==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 dependencies: '@rollup/pluginutils': 3.1.0_rollup@1.32.1 '@types/resolve': 0.0.8 @@ -2017,103 +2428,99 @@ packages: resolve: 1.18.1 rollup: 1.32.1 dev: true - engines: - node: '>= 8.0.0' - peerDependencies: - rollup: ^1.20.0||^2.0.0 - resolution: - integrity: sha512-RxtSL3XmdTAE2byxekYLnx+98kEUOrPHF/KRVjLH+DEIHy6kjIw7YINQzn+NXiH/NTrQLAwYs0GWB+csWygA9Q== + /@rollup/plugin-replace/2.4.2_rollup@1.32.1: + resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 dependencies: '@rollup/pluginutils': 3.1.0_rollup@1.32.1 magic-string: 0.25.7 rollup: 1.32.1 dev: true - peerDependencies: - rollup: ^1.20.0 || ^2.0.0 - resolution: - integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg== + /@rollup/pluginutils/3.1.0_rollup@1.32.1: + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 dependencies: '@types/estree': 0.0.39 estree-walker: 1.0.1 picomatch: 2.2.2 rollup: 1.32.1 dev: true - engines: - node: '>= 8.0.0' + + /@simbathesailor/use-what-changed/2.0.0_react@17.0.2: + resolution: {integrity: sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==} peerDependencies: - rollup: ^1.20.0||^2.0.0 - resolution: - integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== + react: '>=16' + dependencies: + react: 17.0.2 + dev: true + /@sinonjs/commons/1.8.2: + resolution: {integrity: sha512-sruwd86RJHdsVf/AtBoijDmUqJp3B6hF/DGC23C+JaegnDHaZyewCjoVGTdg3J0uz3Zs7NnIT05OBOmML72lQw==} dependencies: type-detect: 4.0.8 dev: true - resolution: - integrity: sha512-sruwd86RJHdsVf/AtBoijDmUqJp3B6hF/DGC23C+JaegnDHaZyewCjoVGTdg3J0uz3Zs7NnIT05OBOmML72lQw== + /@sinonjs/fake-timers/6.0.1: + resolution: {integrity: sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==} dependencies: '@sinonjs/commons': 1.8.2 dev: true - resolution: - integrity: sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA== + /@surma/rollup-plugin-off-main-thread/1.4.2: + resolution: {integrity: sha512-yBMPqmd1yEJo/280PAMkychuaALyQ9Lkb5q1ck3mjJrFuEobIfhnQ4J3mbvBoISmR3SWMWV+cGB/I0lCQee79A==} dependencies: ejs: 2.7.4 magic-string: 0.25.7 dev: true - resolution: - integrity: sha512-yBMPqmd1yEJo/280PAMkychuaALyQ9Lkb5q1ck3mjJrFuEobIfhnQ4J3mbvBoISmR3SWMWV+cGB/I0lCQee79A== + /@svgr/babel-plugin-add-jsx-attribute/5.4.0: + resolution: {integrity: sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg== + /@svgr/babel-plugin-remove-jsx-attribute/5.4.0: + resolution: {integrity: sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg== + /@svgr/babel-plugin-remove-jsx-empty-expression/5.0.1: + resolution: {integrity: sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA== + /@svgr/babel-plugin-replace-jsx-attribute-value/5.0.1: + resolution: {integrity: sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ== + /@svgr/babel-plugin-svg-dynamic-title/5.4.0: + resolution: {integrity: sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg== + /@svgr/babel-plugin-svg-em-dimensions/5.4.0: + resolution: {integrity: sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw== + /@svgr/babel-plugin-transform-react-native-svg/5.4.0: + resolution: {integrity: sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q== + /@svgr/babel-plugin-transform-svg-component/5.5.0: + resolution: {integrity: sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ== + /@svgr/babel-preset/5.5.0: + resolution: {integrity: sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==} + engines: {node: '>=10'} dependencies: '@svgr/babel-plugin-add-jsx-attribute': 5.4.0 '@svgr/babel-plugin-remove-jsx-attribute': 5.4.0 @@ -2124,50 +2531,49 @@ packages: '@svgr/babel-plugin-transform-react-native-svg': 5.4.0 '@svgr/babel-plugin-transform-svg-component': 5.5.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig== + /@svgr/core/5.5.0: + resolution: {integrity: sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==} + engines: {node: '>=10'} dependencies: '@svgr/plugin-jsx': 5.5.0 camelcase: 6.2.0 cosmiconfig: 7.0.0 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ== + /@svgr/hast-util-to-babel-ast/5.5.0: + resolution: {integrity: sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==} + engines: {node: '>=10'} dependencies: '@babel/types': 7.13.13 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ== + /@svgr/plugin-jsx/5.5.0: + resolution: {integrity: sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==} + engines: {node: '>=10'} dependencies: '@babel/core': 7.12.3 '@svgr/babel-preset': 5.5.0 '@svgr/hast-util-to-babel-ast': 5.5.0 svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA== + /@svgr/plugin-svgo/5.5.0: + resolution: {integrity: sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==} + engines: {node: '>=10'} dependencies: cosmiconfig: 7.0.0 deepmerge: 4.2.2 svgo: 1.3.2 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ== + /@svgr/webpack/5.4.0: + resolution: {integrity: sha512-LjepnS/BSAvelnOnnzr6Gg0GcpLmnZ9ThGFK5WJtm1xOqdBE/1IACZU7MMdVzjyUkfFqGz87eRE4hFaSLiUwYg==} + engines: {node: '>=10'} dependencies: '@babel/core': 7.12.3 '@babel/plugin-transform-react-constant-elements': 7.13.13_@babel+core@7.12.3 @@ -2177,12 +2583,13 @@ packages: '@svgr/plugin-jsx': 5.5.0 '@svgr/plugin-svgo': 5.5.0 loader-utils: 2.0.0 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-LjepnS/BSAvelnOnnzr6Gg0GcpLmnZ9ThGFK5WJtm1xOqdBE/1IACZU7MMdVzjyUkfFqGz87eRE4hFaSLiUwYg== + /@testing-library/dom/7.30.1: + resolution: {integrity: sha512-RQUvqqq2lxTCOffhSNxpX/9fCoR+nwuQPmG5uhuuEH5KBAzNf2bK3OzBoWjm5zKM78SLjnGRAKt8hRjQA4E46A==} + engines: {node: '>=10'} dependencies: '@babel/code-frame': 7.12.13 '@babel/runtime': 7.13.10 @@ -2193,11 +2600,10 @@ packages: lz-string: 1.4.4 pretty-format: 26.6.2 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-RQUvqqq2lxTCOffhSNxpX/9fCoR+nwuQPmG5uhuuEH5KBAzNf2bK3OzBoWjm5zKM78SLjnGRAKt8hRjQA4E46A== + /@testing-library/jest-dom/5.11.10: + resolution: {integrity: sha512-FuKiq5xuk44Fqm0000Z9w0hjOdwZRNzgx7xGGxQYepWFZy+OYUMOT/wPI4nLYXCaVltNVpU1W/qmD88wLWDsqQ==} + engines: {node: '>=8', npm: '>=6', yarn: '>=1'} dependencies: '@babel/runtime': 7.13.10 '@types/testing-library__jest-dom': 5.9.5 @@ -2208,46 +2614,61 @@ packages: lodash: 4.17.21 redent: 3.0.0 dev: true - engines: - node: '>=8' - npm: '>=6' - yarn: '>=1' - resolution: - integrity: sha512-FuKiq5xuk44Fqm0000Z9w0hjOdwZRNzgx7xGGxQYepWFZy+OYUMOT/wPI4nLYXCaVltNVpU1W/qmD88wLWDsqQ== - /@testing-library/react/11.2.5_react-dom@17.0.2+react@17.0.2: + + /@testing-library/react-hooks/7.0.2_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + react-test-renderer: '>=16.9.0' + peerDependenciesMeta: + react-dom: + optional: true + react-test-renderer: + optional: true dependencies: - '@babel/runtime': 7.13.10 - '@testing-library/dom': 7.30.1 + '@babel/runtime': 7.14.8 + '@types/react': 16.14.5 + '@types/react-dom': 16.9.12 + '@types/react-test-renderer': 17.0.1 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 + react-error-boundary: 3.1.3_react@17.0.2 dev: true - engines: - node: '>=10' + + /@testing-library/react/11.2.5_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-yEx7oIa/UWLe2F2dqK0FtMF9sJWNXD+2PPtp39BvE0Kh9MJ9Kl0HrZAgEuhUJR+Lx8Di6Xz+rKwSdEPY2UV8ZQ==} + engines: {node: '>=10'} peerDependencies: react: '*' react-dom: '*' - resolution: - integrity: sha512-yEx7oIa/UWLe2F2dqK0FtMF9sJWNXD+2PPtp39BvE0Kh9MJ9Kl0HrZAgEuhUJR+Lx8Di6Xz+rKwSdEPY2UV8ZQ== - /@testing-library/user-event/12.8.3: dependencies: '@babel/runtime': 7.13.10 + '@testing-library/dom': 7.30.1 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 dev: true - engines: - node: '>=10' - npm: '>=6' + + /@testing-library/user-event/12.8.3: + resolution: {integrity: sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==} + engines: {node: '>=10', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' - resolution: - integrity: sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ== + dependencies: + '@babel/runtime': 7.13.10 + dev: true + /@types/anymatch/1.3.1: + resolution: {integrity: sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==} dev: true - resolution: - integrity: sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== + /@types/aria-query/4.2.1: + resolution: {integrity: sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg==} dev: true - resolution: - integrity: sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg== + /@types/babel__core/7.1.14: + resolution: {integrity: sha512-zGZJzzBUVDo/eV6KgbE0f0ZI7dInEYvo12Rb70uNQDshC3SkRMb67ja0GgRHZgAX3Za6rhaWlvbDO8rrGyAb1g==} dependencies: '@babel/parser': 7.13.13 '@babel/types': 7.13.13 @@ -2255,180 +2676,186 @@ packages: '@types/babel__template': 7.4.0 '@types/babel__traverse': 7.11.1 dev: true - resolution: - integrity: sha512-zGZJzzBUVDo/eV6KgbE0f0ZI7dInEYvo12Rb70uNQDshC3SkRMb67ja0GgRHZgAX3Za6rhaWlvbDO8rrGyAb1g== + /@types/babel__generator/7.6.2: + resolution: {integrity: sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ==} dependencies: '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ== + /@types/babel__template/7.4.0: + resolution: {integrity: sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A==} dependencies: '@babel/parser': 7.13.13 '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A== + /@types/babel__traverse/7.11.1: + resolution: {integrity: sha512-Vs0hm0vPahPMYi9tDjtP66llufgO3ST16WXaSTtDGEl9cewAl3AibmxWw6TINOqHPT9z0uABKAYjT9jNSg4npw==} dependencies: '@babel/types': 7.13.13 dev: true - resolution: - integrity: sha512-Vs0hm0vPahPMYi9tDjtP66llufgO3ST16WXaSTtDGEl9cewAl3AibmxWw6TINOqHPT9z0uABKAYjT9jNSg4npw== + + /@types/chart.js/2.9.34: + resolution: {integrity: sha512-CtZVk+kh1IN67dv+fB0CWmCLCRrDJgqOj15qPic2B1VCMovNO6B7Vhf/TgPpNscjhAL1j+qUntDMWb9A4ZmPTg==} + dependencies: + moment: 2.29.1 + dev: true + /@types/classnames/2.2.11: + resolution: {integrity: sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw==} dev: true - resolution: - integrity: sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw== + /@types/d3-array/2.9.0: + resolution: {integrity: sha512-sdBMGfNvLUkBypPMEhOcKcblTQfgHbqbYrUqRE31jOwdDHBJBxz4co2MDAq93S4Cp++phk4UiwoEg/1hK3xXAQ==} dev: false - resolution: - integrity: sha512-sdBMGfNvLUkBypPMEhOcKcblTQfgHbqbYrUqRE31jOwdDHBJBxz4co2MDAq93S4Cp++phk4UiwoEg/1hK3xXAQ== + /@types/d3-axis/2.0.0: + resolution: {integrity: sha512-gUdlEwGBLl3tXGiBnBNmNzph9W3bCfa4tBgWZD60Z1eDQKTY4zyCAcZ3LksignGfKawYatmDYcBdjJ5h/54sqA==} dependencies: '@types/d3-selection': 2.0.0 dev: false - resolution: - integrity: sha512-gUdlEwGBLl3tXGiBnBNmNzph9W3bCfa4tBgWZD60Z1eDQKTY4zyCAcZ3LksignGfKawYatmDYcBdjJ5h/54sqA== + /@types/d3-brush/2.1.0: + resolution: {integrity: sha512-rLQqxQeXWF4ArXi81GlV8HBNwJw9EDpz0jcWvvzv548EDE4tXrayBTOHYi/8Q4FZ/Df8PGXFzxpAVQmJMjOtvQ==} dependencies: '@types/d3-selection': 2.0.0 dev: false - resolution: - integrity: sha512-rLQqxQeXWF4ArXi81GlV8HBNwJw9EDpz0jcWvvzv548EDE4tXrayBTOHYi/8Q4FZ/Df8PGXFzxpAVQmJMjOtvQ== + /@types/d3-chord/2.0.0: + resolution: {integrity: sha512-3nHsLY7lImpZlM/hrPeDqqW2a+lRXXoHsG54QSurDGihZAIE/doQlohs0evoHrWOJqXyn4A4xbSVEtXnMEZZiw==} dev: false - resolution: - integrity: sha512-3nHsLY7lImpZlM/hrPeDqqW2a+lRXXoHsG54QSurDGihZAIE/doQlohs0evoHrWOJqXyn4A4xbSVEtXnMEZZiw== + /@types/d3-color/2.0.1: + resolution: {integrity: sha512-u7LTCL7RnaavFSmob2rIAJLNwu50i6gFwY9cHFr80BrQURYQBRkJ+Yv47nA3Fm7FeRhdWTiVTeqvSeOuMAOzBQ==} dev: false - resolution: - integrity: sha512-u7LTCL7RnaavFSmob2rIAJLNwu50i6gFwY9cHFr80BrQURYQBRkJ+Yv47nA3Fm7FeRhdWTiVTeqvSeOuMAOzBQ== + /@types/d3-contour/2.0.0: + resolution: {integrity: sha512-PS9UO6zBQqwHXsocbpdzZFONgK1oRUgWtjjh/iz2vM06KaXLInLiKZ9e3OLBRerc1cU2uJYpO+8zOnb6frvCGQ==} dependencies: '@types/d3-array': 2.9.0 '@types/geojson': 7946.0.7 dev: false - resolution: - integrity: sha512-PS9UO6zBQqwHXsocbpdzZFONgK1oRUgWtjjh/iz2vM06KaXLInLiKZ9e3OLBRerc1cU2uJYpO+8zOnb6frvCGQ== + /@types/d3-delaunay/5.3.0: + resolution: {integrity: sha512-gJYcGxLu0xDZPccbUe32OUpeaNtd1Lz0NYJtko6ZLMyG2euF4pBzrsQXms67LHZCDFzzszw+dMhSL/QAML3bXw==} dev: false - resolution: - integrity: sha512-gJYcGxLu0xDZPccbUe32OUpeaNtd1Lz0NYJtko6ZLMyG2euF4pBzrsQXms67LHZCDFzzszw+dMhSL/QAML3bXw== + /@types/d3-dispatch/2.0.0: + resolution: {integrity: sha512-Sh0KW6z/d7uxssD7K4s4uCSzlEG/+SP+U47q098NVdOfFvUKNTvKAIV4XqjxsUuhE/854ARAREHOxkr9gQOCyg==} dev: false - resolution: - integrity: sha512-Sh0KW6z/d7uxssD7K4s4uCSzlEG/+SP+U47q098NVdOfFvUKNTvKAIV4XqjxsUuhE/854ARAREHOxkr9gQOCyg== + /@types/d3-drag/2.0.0: + resolution: {integrity: sha512-VaUJPjbMnDn02tcRqsHLRAX5VjcRIzCjBfeXTLGe6QjMn5JccB5Cz4ztMRXMJfkbC45ovgJFWuj6DHvWMX1thA==} dependencies: '@types/d3-selection': 2.0.0 dev: false - resolution: - integrity: sha512-VaUJPjbMnDn02tcRqsHLRAX5VjcRIzCjBfeXTLGe6QjMn5JccB5Cz4ztMRXMJfkbC45ovgJFWuj6DHvWMX1thA== + /@types/d3-dsv/2.0.1: + resolution: {integrity: sha512-wovgiG9Mgkr/SZ/m/c0m+RwrIT4ozsuCWeLxJyoObDWsie2DeQT4wzMdHZPR9Ya5oZLQT3w3uSl0NehG0+0dCA==} dev: false - resolution: - integrity: sha512-wovgiG9Mgkr/SZ/m/c0m+RwrIT4ozsuCWeLxJyoObDWsie2DeQT4wzMdHZPR9Ya5oZLQT3w3uSl0NehG0+0dCA== + /@types/d3-ease/2.0.0: + resolution: {integrity: sha512-6aZrTyX5LG+ptofVHf+gTsThLRY1nhLotJjgY4drYqk1OkJMu2UvuoZRlPw2fffjRHeYepue3/fxTufqKKmvsA==} dev: false - resolution: - integrity: sha512-6aZrTyX5LG+ptofVHf+gTsThLRY1nhLotJjgY4drYqk1OkJMu2UvuoZRlPw2fffjRHeYepue3/fxTufqKKmvsA== + /@types/d3-fetch/2.0.0: + resolution: {integrity: sha512-WnLepGtxepFfXRdPI8I5FTgNiHn9p4vMTTqaNCzJJfAswXx0rOY2jjeolzEU063em3iJmGZ+U79InnEeFOrCRw==} dependencies: '@types/d3-dsv': 2.0.1 dev: false - resolution: - integrity: sha512-WnLepGtxepFfXRdPI8I5FTgNiHn9p4vMTTqaNCzJJfAswXx0rOY2jjeolzEU063em3iJmGZ+U79InnEeFOrCRw== + /@types/d3-force/2.1.1: + resolution: {integrity: sha512-3r+CQv2K/uDTAVg0DGxsbBjV02vgOxb8RhPIv3gd6cp3pdPAZ7wEXpDjUZSoqycAQLSDOxG/AZ54Vx6YXZSbmQ==} dev: false - resolution: - integrity: sha512-3r+CQv2K/uDTAVg0DGxsbBjV02vgOxb8RhPIv3gd6cp3pdPAZ7wEXpDjUZSoqycAQLSDOxG/AZ54Vx6YXZSbmQ== + /@types/d3-format/2.0.0: + resolution: {integrity: sha512-uagdkftxnGkO4pZw5jEYOM5ZnZOEsh7z8j11Qxk85UkB2RzfUUxRl7R9VvvJZHwKn8l+x+rpS77Nusq7FkFmIg==} dev: false - resolution: - integrity: sha512-uagdkftxnGkO4pZw5jEYOM5ZnZOEsh7z8j11Qxk85UkB2RzfUUxRl7R9VvvJZHwKn8l+x+rpS77Nusq7FkFmIg== + /@types/d3-geo/2.0.0: + resolution: {integrity: sha512-DHHgYXW36lnAEQMYU2udKVOxxljHrn2EdOINeSC9jWCAXwOnGn7A19B8sNsHqgpu4F7O2bSD7//cqBXD3W0Deg==} dependencies: '@types/geojson': 7946.0.7 dev: false - resolution: - integrity: sha512-DHHgYXW36lnAEQMYU2udKVOxxljHrn2EdOINeSC9jWCAXwOnGn7A19B8sNsHqgpu4F7O2bSD7//cqBXD3W0Deg== + /@types/d3-hierarchy/2.0.0: + resolution: {integrity: sha512-YxdskUvwzqggpnSnDQj4KVkicgjpkgXn/g/9M9iGsiToLS3nG6Ytjo1FoYhYVAAElV/fJBGVL3cQ9Hb7tcv+lw==} dev: false - resolution: - integrity: sha512-YxdskUvwzqggpnSnDQj4KVkicgjpkgXn/g/9M9iGsiToLS3nG6Ytjo1FoYhYVAAElV/fJBGVL3cQ9Hb7tcv+lw== + /@types/d3-interpolate/2.0.0: + resolution: {integrity: sha512-Wt1v2zTlEN8dSx8hhx6MoOhWQgTkz0Ukj7owAEIOF2QtI0e219paFX9rf/SLOr/UExWb1TcUzatU8zWwFby6gg==} dependencies: '@types/d3-color': 2.0.1 dev: false - resolution: - integrity: sha512-Wt1v2zTlEN8dSx8hhx6MoOhWQgTkz0Ukj7owAEIOF2QtI0e219paFX9rf/SLOr/UExWb1TcUzatU8zWwFby6gg== + /@types/d3-path/1.0.9: + resolution: {integrity: sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==} dev: false - resolution: - integrity: sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ== + /@types/d3-path/2.0.0: + resolution: {integrity: sha512-tXcR/9OtDdeCIsyl6eTNHC3XOAOdyc6ceF3QGBXOd9jTcK+ex/ecr00p9L9362e/op3UEPpxrToi1FHrtTSj7Q==} dev: false - resolution: - integrity: sha512-tXcR/9OtDdeCIsyl6eTNHC3XOAOdyc6ceF3QGBXOd9jTcK+ex/ecr00p9L9362e/op3UEPpxrToi1FHrtTSj7Q== + /@types/d3-polygon/2.0.0: + resolution: {integrity: sha512-fISnMd8ePED1G4aa4V974Jmt+ajHSgPoxMa2D0ULxMybpx0Vw4WEzhQEaMIrL3hM8HVRcKTx669I+dTy/4PhAw==} dev: false - resolution: - integrity: sha512-fISnMd8ePED1G4aa4V974Jmt+ajHSgPoxMa2D0ULxMybpx0Vw4WEzhQEaMIrL3hM8HVRcKTx669I+dTy/4PhAw== + /@types/d3-quadtree/2.0.0: + resolution: {integrity: sha512-YZuJuGBnijD0H+98xMJD4oZXgv/umPXy5deu3IimYTPGH3Kr8Th6iQUff0/6S80oNBD7KtOuIHwHUCymUiRoeQ==} dev: false - resolution: - integrity: sha512-YZuJuGBnijD0H+98xMJD4oZXgv/umPXy5deu3IimYTPGH3Kr8Th6iQUff0/6S80oNBD7KtOuIHwHUCymUiRoeQ== + /@types/d3-random/2.2.0: + resolution: {integrity: sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A==} dev: false - resolution: - integrity: sha512-Hjfj9m68NmYZzushzEG7etPvKH/nj9b9s9+qtkNG3/dbRBjQZQg1XS6nRuHJcCASTjxXlyXZnKu2gDxyQIIu9A== + /@types/d3-scale-chromatic/2.0.0: + resolution: {integrity: sha512-Y62+2clOwZoKua84Ha0xU77w7lePiaBoTjXugT4l8Rd5LAk+Mn/ZDtrgs087a+B5uJ3jYUHHtKw5nuEzp0WBHw==} dev: false - resolution: - integrity: sha512-Y62+2clOwZoKua84Ha0xU77w7lePiaBoTjXugT4l8Rd5LAk+Mn/ZDtrgs087a+B5uJ3jYUHHtKw5nuEzp0WBHw== + /@types/d3-scale/3.2.2: + resolution: {integrity: sha512-qpQe8G02tzUwt9sdWX1h8A/W0Q1+N48wMnYXVOkrzeLUkCfvzJYV9Ee3aORCS4dN4ONRLFmMvaXdziQ29XGLjQ==} dependencies: '@types/d3-time': 2.0.0 dev: false - resolution: - integrity: sha512-qpQe8G02tzUwt9sdWX1h8A/W0Q1+N48wMnYXVOkrzeLUkCfvzJYV9Ee3aORCS4dN4ONRLFmMvaXdziQ29XGLjQ== + /@types/d3-selection/2.0.0: + resolution: {integrity: sha512-EF0lWZ4tg7oDFg4YQFlbOU3936e3a9UmoQ2IXlBy1+cv2c2Pv7knhKUzGlH5Hq2sF/KeDTH1amiRPey2rrLMQA==} dev: false - resolution: - integrity: sha512-EF0lWZ4tg7oDFg4YQFlbOU3936e3a9UmoQ2IXlBy1+cv2c2Pv7knhKUzGlH5Hq2sF/KeDTH1amiRPey2rrLMQA== + /@types/d3-shape/2.0.0: + resolution: {integrity: sha512-NLzD02m5PiD1KLEDjLN+MtqEcFYn4ZL9+Rqc9ZwARK1cpKZXd91zBETbe6wpBB6Ia0D0VZbpmbW3+BsGPGnCpA==} dependencies: '@types/d3-path': 1.0.9 dev: false - resolution: - integrity: sha512-NLzD02m5PiD1KLEDjLN+MtqEcFYn4ZL9+Rqc9ZwARK1cpKZXd91zBETbe6wpBB6Ia0D0VZbpmbW3+BsGPGnCpA== + /@types/d3-time-format/3.0.0: + resolution: {integrity: sha512-UpLg1mn/8PLyjr+J/JwdQJM/GzysMvv2CS8y+WYAL5K0+wbvXv/pPSLEfdNaprCZsGcXTxPsFMy8QtkYv9ueew==} dev: false - resolution: - integrity: sha512-UpLg1mn/8PLyjr+J/JwdQJM/GzysMvv2CS8y+WYAL5K0+wbvXv/pPSLEfdNaprCZsGcXTxPsFMy8QtkYv9ueew== + /@types/d3-time/2.0.0: + resolution: {integrity: sha512-Abz8bTzy8UWDeYs9pCa3D37i29EWDjNTjemdk0ei1ApYVNqulYlGUKip/jLOpogkPSsPz/GvZCYiC7MFlEk0iQ==} dev: false - resolution: - integrity: sha512-Abz8bTzy8UWDeYs9pCa3D37i29EWDjNTjemdk0ei1ApYVNqulYlGUKip/jLOpogkPSsPz/GvZCYiC7MFlEk0iQ== + /@types/d3-timer/2.0.0: + resolution: {integrity: sha512-l6stHr1VD1BWlW6u3pxrjLtJfpPZq9I3XmKIQtq7zHM/s6fwEtI1Yn6Sr5/jQTrUDCC5jkS6gWqlFGCDArDqNg==} dev: false - resolution: - integrity: sha512-l6stHr1VD1BWlW6u3pxrjLtJfpPZq9I3XmKIQtq7zHM/s6fwEtI1Yn6Sr5/jQTrUDCC5jkS6gWqlFGCDArDqNg== + /@types/d3-transition/2.0.0: + resolution: {integrity: sha512-UJDzI98utcZQUJt3uIit/Ho0/eBIANzrWJrTmi4+TaKIyWL2iCu7ShP0o4QajCskhyjOA7C8+4CE3b1YirTzEQ==} dependencies: '@types/d3-selection': 2.0.0 dev: false - resolution: - integrity: sha512-UJDzI98utcZQUJt3uIit/Ho0/eBIANzrWJrTmi4+TaKIyWL2iCu7ShP0o4QajCskhyjOA7C8+4CE3b1YirTzEQ== + /@types/d3-zoom/2.0.0: + resolution: {integrity: sha512-daL0PJm4yT0ISTGa7p2lHX0kvv9FO/IR1ooWbHR/7H4jpbaKiLux5FslyS/OvISPiJ5SXb4sOqYhO6fMB6hKRw==} dependencies: '@types/d3-interpolate': 2.0.0 '@types/d3-selection': 2.0.0 dev: false - resolution: - integrity: sha512-daL0PJm4yT0ISTGa7p2lHX0kvv9FO/IR1ooWbHR/7H4jpbaKiLux5FslyS/OvISPiJ5SXb4sOqYhO6fMB6hKRw== + /@types/d3/6.3.0: + resolution: {integrity: sha512-YILdGsjNTbvkWZKsBasB4cVDwNPnni7ILMJg9keMErQHyuII2yO2jyFdUy5E+7k/HTNP/AucrPddQuu27udbeA==} dependencies: '@types/d3-array': 2.9.0 '@types/d3-axis': 2.0.0 @@ -2461,219 +2888,298 @@ packages: '@types/d3-transition': 2.0.0 '@types/d3-zoom': 2.0.0 dev: false - resolution: - integrity: sha512-YILdGsjNTbvkWZKsBasB4cVDwNPnni7ILMJg9keMErQHyuII2yO2jyFdUy5E+7k/HTNP/AucrPddQuu27udbeA== + + /@types/debounce-promise/3.1.4: + resolution: {integrity: sha512-9SEVY3nsz+uMN2DwDocftB5TAgZe7D0cOzxxRhpotWs6T4QFqRaTXpXbOSzbk31/7iYcfCkJJPwWGzTxyuGhCg==} + dev: true + /@types/eslint/7.2.7: + resolution: {integrity: sha512-EHXbc1z2GoQRqHaAT7+grxlTJ3WE2YNeD6jlpPoRc83cCoThRY+NUWjCUZaYmk51OICkPXn2hhphcWcWXgNW0Q==} dependencies: '@types/estree': 0.0.47 '@types/json-schema': 7.0.7 dev: true - resolution: - integrity: sha512-EHXbc1z2GoQRqHaAT7+grxlTJ3WE2YNeD6jlpPoRc83cCoThRY+NUWjCUZaYmk51OICkPXn2hhphcWcWXgNW0Q== + /@types/estree/0.0.39: + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} dev: true - resolution: - integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== + /@types/estree/0.0.47: + resolution: {integrity: sha512-c5ciR06jK8u9BstrmJyO97m+klJrrhCf9u3rLu3DEAJBirxRqSCvDQoYKmxuYwQI5SZChAWu+tq9oVlGRuzPAg==} dev: true - resolution: - integrity: sha512-c5ciR06jK8u9BstrmJyO97m+klJrrhCf9u3rLu3DEAJBirxRqSCvDQoYKmxuYwQI5SZChAWu+tq9oVlGRuzPAg== + /@types/geojson/7946.0.7: + resolution: {integrity: sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==} dev: false - resolution: - integrity: sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ== + /@types/glob/7.1.3: + resolution: {integrity: sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==} dependencies: '@types/minimatch': 3.0.4 '@types/node': 12.20.7 dev: true - resolution: - integrity: sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w== + /@types/graceful-fs/4.1.5: + resolution: {integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==} dependencies: '@types/node': 12.20.7 dev: true - resolution: - integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw== + + /@types/hast/2.3.4: + resolution: {integrity: sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==} + dependencies: + '@types/unist': 2.0.6 + dev: true + /@types/history/4.7.8: + resolution: {integrity: sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==} dev: true - resolution: - integrity: sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== + /@types/hoist-non-react-statics/3.3.1: + resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==} dependencies: '@types/react': 16.14.5 hoist-non-react-statics: 3.3.2 - resolution: - integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + /@types/html-minifier-terser/5.1.1: + resolution: {integrity: sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA==} dev: true - resolution: - integrity: sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA== + + /@types/http-proxy/1.17.9: + resolution: {integrity: sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==} + dependencies: + '@types/node': 14.17.20 + dev: true + /@types/istanbul-lib-coverage/2.0.3: + resolution: {integrity: sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==} dev: true - resolution: - integrity: sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw== + /@types/istanbul-lib-report/3.0.0: + resolution: {integrity: sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==} dependencies: '@types/istanbul-lib-coverage': 2.0.3 dev: true - resolution: - integrity: sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== + /@types/istanbul-reports/3.0.0: + resolution: {integrity: sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA==} dependencies: '@types/istanbul-lib-report': 3.0.0 dev: true - resolution: - integrity: sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA== + /@types/jest/26.0.22: + resolution: {integrity: sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw==} dependencies: jest-diff: 26.6.2 pretty-format: 26.6.2 dev: true - resolution: - integrity: sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw== + /@types/js-cookie/2.2.6: + resolution: {integrity: sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==} dev: false - resolution: - integrity: sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw== + /@types/json-schema/7.0.7: + resolution: {integrity: sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==} dev: true - resolution: - integrity: sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== + + /@types/json-schema/7.0.8: + resolution: {integrity: sha512-YSBPTLTVm2e2OoQIDYx8HaeWJ5tTToLH67kXR7zYNGupXMEHa2++G8k+DczX2cFVgalypqtyZIcU19AFcmOpmg==} + dev: true + /@types/json5/0.0.29: + resolution: {integrity: sha1-7ihweulOEdK4J7y+UnC86n8+ce4=} dev: true - resolution: - integrity: sha1-7ihweulOEdK4J7y+UnC86n8+ce4= + /@types/keyboardjs/2.5.0: + resolution: {integrity: sha512-tGU6Lz04lDNH+N3AZYIWVeBza2ZSaLlZuSkzi38zSFSuh6DgVqBdqgkX+OS+jg1vwlw5XzS5MASY44fr9C12Yg==} dev: true - resolution: - integrity: sha512-tGU6Lz04lDNH+N3AZYIWVeBza2ZSaLlZuSkzi38zSFSuh6DgVqBdqgkX+OS+jg1vwlw5XzS5MASY44fr9C12Yg== + /@types/less/3.0.2: + resolution: {integrity: sha512-62vfe65cMSzYaWmpmhqCMMNl0khen89w57mByPi1OseGfcV/LV03fO8YVrNj7rFQsRWNJo650WWyh6m7p8vZmA==} + dev: true + + /@types/lodash-es/4.17.4: + resolution: {integrity: sha512-BBz79DCJbD2CVYZH67MBeHZRX++HF+5p8Mo5MzjZi64Wac39S3diedJYHZtScbRVf4DjZyN6LzA0SB0zy+HSSQ==} + dependencies: + '@types/lodash': 4.14.168 dev: true - resolution: - integrity: sha512-62vfe65cMSzYaWmpmhqCMMNl0khen89w57mByPi1OseGfcV/LV03fO8YVrNj7rFQsRWNJo650WWyh6m7p8vZmA== + /@types/lodash/4.14.168: + resolution: {integrity: sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==} + dev: true + + /@types/mathjax/0.0.36: + resolution: {integrity: sha512-TqDJc2GWuTqd/m+G/FbNkN+/TF2OCCHvcawmhIrUaZkdVquMdNZmNiNUkupNg9qctorXXkVLVSogZv1DhmgLmg==} + dev: true + + /@types/mdast/3.0.10: + resolution: {integrity: sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==} + dependencies: + '@types/unist': 2.0.6 dev: true - resolution: - integrity: sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== + /@types/minimatch/3.0.4: + resolution: {integrity: sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==} dev: true - resolution: - integrity: sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA== + /@types/node/12.20.7: + resolution: {integrity: sha512-gWL8VUkg8VRaCAUgG9WmhefMqHmMblxe2rVpMF86nZY/+ZysU+BkAp+3cz03AixWDSSz0ks5WX59yAhv/cDwFA==} dev: true - resolution: - integrity: sha512-gWL8VUkg8VRaCAUgG9WmhefMqHmMblxe2rVpMF86nZY/+ZysU+BkAp+3cz03AixWDSSz0ks5WX59yAhv/cDwFA== + + /@types/node/14.17.20: + resolution: {integrity: sha512-gI5Sl30tmhXsqkNvopFydP7ASc4c2cLfGNQrVKN3X90ADFWFsPEsotm/8JHSUJQKTHbwowAHtcJPeyVhtKv0TQ==} + dev: true + /@types/normalize-package-data/2.4.0: + resolution: {integrity: sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==} dev: true - resolution: - integrity: sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== + /@types/parse-json/4.0.0: + resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} + dev: true + + /@types/parse5/5.0.3: + resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} dev: true - resolution: - integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== + /@types/prettier/2.2.3: + resolution: {integrity: sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA==} dev: true - resolution: - integrity: sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA== + /@types/prop-types/15.7.3: - resolution: - integrity: sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== + resolution: {integrity: sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==} + /@types/pubsub-js/1.8.2: + resolution: {integrity: sha512-cj3ZoAopr2ZmUYwRuXUiq48PlfNj5sBcUIkBnSJunfXlmf6y8o2kx4l70h1X1j0fR3IBorPrPM3B9SoyWwoqLg==} dev: true - resolution: - integrity: sha512-cj3ZoAopr2ZmUYwRuXUiq48PlfNj5sBcUIkBnSJunfXlmf6y8o2kx4l70h1X1j0fR3IBorPrPM3B9SoyWwoqLg== + /@types/q/1.5.4: + resolution: {integrity: sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==} dev: true - resolution: - integrity: sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== + + /@types/qs/6.9.7: + resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} + dev: true + /@types/react-dom/16.9.12: + resolution: {integrity: sha512-i7NPZZpPte3jtVOoW+eLB7G/jsX5OM6GqQnH+lC0nq0rqwlK0x8WcMEvYDgFWqWhWMlTltTimzdMax6wYfZssA==} dependencies: '@types/react': 16.14.5 dev: true - resolution: - integrity: sha512-i7NPZZpPte3jtVOoW+eLB7G/jsX5OM6GqQnH+lC0nq0rqwlK0x8WcMEvYDgFWqWhWMlTltTimzdMax6wYfZssA== + /@types/react-redux/7.1.16: + resolution: {integrity: sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==} dependencies: '@types/hoist-non-react-statics': 3.3.1 '@types/react': 16.14.5 hoist-non-react-statics: 3.3.2 redux: 4.0.5 dev: false - resolution: - integrity: sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw== + + /@types/react-router-config/5.0.2: + resolution: {integrity: sha512-WOSetDV3YPxbkVJAdv/bqExJjmcdCi/vpCJh3NfQOy1X15vHMSiMioXIcGekXDJJYhqGUMDo9e337mh508foAA==} + dependencies: + '@types/history': 4.7.8 + '@types/react': 16.14.5 + '@types/react-router': 5.1.13 + dev: true + /@types/react-router-dom/5.1.7: + resolution: {integrity: sha512-D5mHD6TbdV/DNHYsnwBTv+y73ei+mMjrkGrla86HthE4/PVvL1J94Bu3qABU+COXzpL23T1EZapVVpwHuBXiUg==} dependencies: '@types/history': 4.7.8 '@types/react': 16.14.5 '@types/react-router': 5.1.13 dev: true - resolution: - integrity: sha512-D5mHD6TbdV/DNHYsnwBTv+y73ei+mMjrkGrla86HthE4/PVvL1J94Bu3qABU+COXzpL23T1EZapVVpwHuBXiUg== + + /@types/react-router/5.1.12: + resolution: {integrity: sha512-0bhXQwHYfMeJlCh7mGhc0VJTRm0Gk+Z8T00aiP4702mDUuLs9SMhnd2DitpjWFjdOecx2UXtICK14H9iMnziGA==} + dependencies: + '@types/history': 4.7.8 + '@types/react': 16.14.5 + dev: true + /@types/react-router/5.1.13: + resolution: {integrity: sha512-ZIuaO9Yrln54X6elg8q2Ivp6iK6p4syPsefEYAhRDAoqNh48C8VYUmB9RkXjKSQAJSJV0mbIFCX7I4vZDcHrjg==} dependencies: '@types/history': 4.7.8 '@types/react': 16.14.5 dev: true - resolution: - integrity: sha512-ZIuaO9Yrln54X6elg8q2Ivp6iK6p4syPsefEYAhRDAoqNh48C8VYUmB9RkXjKSQAJSJV0mbIFCX7I4vZDcHrjg== + + /@types/react-test-renderer/17.0.1: + resolution: {integrity: sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==} + dependencies: + '@types/react': 16.14.5 + dev: true + /@types/react/16.14.5: + resolution: {integrity: sha512-YRRv9DNZhaVTVRh9Wmmit7Y0UFhEVqXqCSw3uazRWMxa2x85hWQZ5BN24i7GXZbaclaLXEcodEeIHsjBA8eAMw==} dependencies: '@types/prop-types': 15.7.3 '@types/scheduler': 0.16.1 csstype: 3.0.7 - resolution: - integrity: sha512-YRRv9DNZhaVTVRh9Wmmit7Y0UFhEVqXqCSw3uazRWMxa2x85hWQZ5BN24i7GXZbaclaLXEcodEeIHsjBA8eAMw== + /@types/resolve/0.0.8: + resolution: {integrity: sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==} dependencies: '@types/node': 12.20.7 dev: true - resolution: - integrity: sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ== + + /@types/sax/1.2.3: + resolution: {integrity: sha512-+QSw6Tqvs/KQpZX8DvIl3hZSjNFLW/OqE5nlyHXtTwODaJvioN2rOWpBNEWZp2HZUFhOh+VohmJku/WxEXU2XA==} + dependencies: + '@types/node': 14.17.20 + dev: true + /@types/scheduler/0.16.1: - resolution: - integrity: sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== + resolution: {integrity: sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==} + /@types/source-list-map/0.1.2: + resolution: {integrity: sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==} dev: true - resolution: - integrity: sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== + /@types/stack-utils/2.0.0: + resolution: {integrity: sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==} dev: true - resolution: - integrity: sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== + /@types/styled-components/5.1.9: + resolution: {integrity: sha512-kbEG6YlwK8rucITpKEr6pA4Ho9KSQHUUOzZ9lY3va1mtcjvS3D0wDciFyHEiNHKLL/npZCKDQJqm0x44sPO9oA==} dependencies: '@types/hoist-non-react-statics': 3.3.1 '@types/react': 16.14.5 csstype: 3.0.7 dev: true - resolution: - integrity: sha512-kbEG6YlwK8rucITpKEr6pA4Ho9KSQHUUOzZ9lY3va1mtcjvS3D0wDciFyHEiNHKLL/npZCKDQJqm0x44sPO9oA== + /@types/tapable/1.0.7: + resolution: {integrity: sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ==} dev: true - resolution: - integrity: sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ== + /@types/testing-library__jest-dom/5.9.5: + resolution: {integrity: sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==} dependencies: '@types/jest': 26.0.22 dev: true - resolution: - integrity: sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ== + /@types/uglify-js/3.13.0: + resolution: {integrity: sha512-EGkrJD5Uy+Pg0NUR8uA4bJ5WMfljyad0G+784vLCNUkD+QwOJXUbBYExXfVGf7YtyzdQp3L/XMYcliB987kL5Q==} dependencies: source-map: 0.6.1 dev: true - resolution: - integrity: sha512-EGkrJD5Uy+Pg0NUR8uA4bJ5WMfljyad0G+784vLCNUkD+QwOJXUbBYExXfVGf7YtyzdQp3L/XMYcliB987kL5Q== + + /@types/unist/2.0.6: + resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} + dev: true + /@types/webpack-sources/2.1.0: + resolution: {integrity: sha512-LXn/oYIpBeucgP1EIJbKQ2/4ZmpvRl+dlrFdX7+94SKRUV3Evy3FsfMZY318vGhkWUS5MPhtOM3w1/hCOAOXcg==} dependencies: '@types/node': 12.20.7 '@types/source-list-map': 0.1.2 source-map: 0.7.3 dev: true - resolution: - integrity: sha512-LXn/oYIpBeucgP1EIJbKQ2/4ZmpvRl+dlrFdX7+94SKRUV3Evy3FsfMZY318vGhkWUS5MPhtOM3w1/hCOAOXcg== + /@types/webpack/4.41.27: + resolution: {integrity: sha512-wK/oi5gcHi72VMTbOaQ70VcDxSQ1uX8S2tukBK9ARuGXrYM/+u4ou73roc7trXDNmCxCoerE8zruQqX/wuHszA==} dependencies: '@types/anymatch': 1.3.1 '@types/node': 12.20.7 @@ -2682,19 +3188,27 @@ packages: '@types/webpack-sources': 2.1.0 source-map: 0.6.1 dev: true - resolution: - integrity: sha512-wK/oi5gcHi72VMTbOaQ70VcDxSQ1uX8S2tukBK9ARuGXrYM/+u4ou73roc7trXDNmCxCoerE8zruQqX/wuHszA== + /@types/yargs-parser/20.2.0: + resolution: {integrity: sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==} dev: true - resolution: - integrity: sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA== + /@types/yargs/15.0.13: + resolution: {integrity: sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ==} dependencies: '@types/yargs-parser': 20.2.0 dev: true - resolution: - integrity: sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ== + /@typescript-eslint/eslint-plugin/4.19.0_821acdc8bc493ad1aa2628c9b724d688: + resolution: {integrity: sha512-CRQNQ0mC2Pa7VLwKFbrGVTArfdVDdefS+gTw0oC98vSI98IX5A8EVH4BzJ2FOB0YlCmm8Im36Elad/Jgtvveaw==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + '@typescript-eslint/parser': ^4.0.0 + eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true dependencies: '@typescript-eslint/experimental-utils': 4.19.0_eslint@7.23.0+typescript@4.2.3 '@typescript-eslint/parser': 4.19.0_eslint@7.23.0+typescript@4.2.3 @@ -2707,19 +3221,15 @@ packages: semver: 7.3.2 tsutils: 3.21.0_typescript@4.2.3 typescript: 4.2.3 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: ^10.12.0 || >=12.0.0 - peerDependencies: - '@typescript-eslint/parser': ^4.0.0 - eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - resolution: - integrity: sha512-CRQNQ0mC2Pa7VLwKFbrGVTArfdVDdefS+gTw0oC98vSI98IX5A8EVH4BzJ2FOB0YlCmm8Im36Elad/Jgtvveaw== + /@typescript-eslint/experimental-utils/3.10.1_eslint@7.23.0+typescript@4.2.3: + resolution: {integrity: sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + eslint: '*' dependencies: '@types/json-schema': 7.0.7 '@typescript-eslint/types': 3.10.1 @@ -2727,15 +3237,16 @@ packages: eslint: 7.23.0 eslint-scope: 5.1.1 eslint-utils: 2.1.0 + transitivePeerDependencies: + - supports-color + - typescript dev: true - engines: - node: ^10.12.0 || >=12.0.0 + + /@typescript-eslint/experimental-utils/4.19.0_eslint@7.23.0+typescript@4.2.3: + resolution: {integrity: sha512-9/23F1nnyzbHKuoTqFN1iXwN3bvOm/PRIXSBR3qFAYotK/0LveEOHr5JT1WZSzcD6BESl8kPOG3OoDRKO84bHA==} + engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: eslint: '*' - typescript: '*' - resolution: - integrity: sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw== - /@typescript-eslint/experimental-utils/4.19.0_eslint@7.23.0+typescript@4.2.3: dependencies: '@types/json-schema': 7.0.7 '@typescript-eslint/scope-manager': 4.19.0 @@ -2744,15 +3255,20 @@ packages: eslint: 7.23.0 eslint-scope: 5.1.1 eslint-utils: 2.1.0 + transitivePeerDependencies: + - supports-color + - typescript dev: true - engines: - node: ^10.12.0 || >=12.0.0 + + /@typescript-eslint/parser/4.19.0_eslint@7.23.0+typescript@4.2.3: + resolution: {integrity: sha512-/uabZjo2ZZhm66rdAu21HA8nQebl3lAIDcybUoOxoI7VbZBYavLIwtOOmykKCJy+Xq6Vw6ugkiwn8Js7D6wieA==} + engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: - eslint: '*' + eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 typescript: '*' - resolution: - integrity: sha512-9/23F1nnyzbHKuoTqFN1iXwN3bvOm/PRIXSBR3qFAYotK/0LveEOHr5JT1WZSzcD6BESl8kPOG3OoDRKO84bHA== - /@typescript-eslint/parser/4.19.0_eslint@7.23.0+typescript@4.2.3: + peerDependenciesMeta: + typescript: + optional: true dependencies: '@typescript-eslint/scope-manager': 4.19.0 '@typescript-eslint/types': 4.19.0 @@ -2760,39 +3276,36 @@ packages: debug: 4.3.1 eslint: 7.23.0 typescript: 4.2.3 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: ^10.12.0 || >=12.0.0 - peerDependencies: - eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - resolution: - integrity: sha512-/uabZjo2ZZhm66rdAu21HA8nQebl3lAIDcybUoOxoI7VbZBYavLIwtOOmykKCJy+Xq6Vw6ugkiwn8Js7D6wieA== + /@typescript-eslint/scope-manager/4.19.0: + resolution: {integrity: sha512-GGy4Ba/hLXwJXygkXqMzduqOMc+Na6LrJTZXJWVhRrSuZeXmu8TAnniQVKgj8uTRKe4igO2ysYzH+Np879G75g==} + engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} dependencies: '@typescript-eslint/types': 4.19.0 '@typescript-eslint/visitor-keys': 4.19.0 dev: true - engines: - node: ^8.10.0 || ^10.13.0 || >=11.10.1 - resolution: - integrity: sha512-GGy4Ba/hLXwJXygkXqMzduqOMc+Na6LrJTZXJWVhRrSuZeXmu8TAnniQVKgj8uTRKe4igO2ysYzH+Np879G75g== + /@typescript-eslint/types/3.10.1: + resolution: {integrity: sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==} + engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} dev: true - engines: - node: ^8.10.0 || ^10.13.0 || >=11.10.1 - resolution: - integrity: sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== + /@typescript-eslint/types/4.19.0: + resolution: {integrity: sha512-A4iAlexVvd4IBsSTNxdvdepW0D4uR/fwxDrKUa+iEY9UWvGREu2ZyB8ylTENM1SH8F7bVC9ac9+si3LWNxcBuA==} + engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} dev: true - engines: - node: ^8.10.0 || ^10.13.0 || >=11.10.1 - resolution: - integrity: sha512-A4iAlexVvd4IBsSTNxdvdepW0D4uR/fwxDrKUa+iEY9UWvGREu2ZyB8ylTENM1SH8F7bVC9ac9+si3LWNxcBuA== + /@typescript-eslint/typescript-estree/3.10.1_typescript@4.2.3: + resolution: {integrity: sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true dependencies: '@typescript-eslint/types': 3.10.1 '@typescript-eslint/visitor-keys': 3.10.1 @@ -2803,17 +3316,18 @@ packages: semver: 7.3.2 tsutils: 3.21.0_typescript@4.2.3 typescript: 4.2.3 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: ^10.12.0 || >=12.0.0 + + /@typescript-eslint/typescript-estree/4.19.0_typescript@4.2.3: + resolution: {integrity: sha512-3xqArJ/A62smaQYRv2ZFyTA+XxGGWmlDYrsfZG68zJeNbeqRScnhf81rUVa6QG4UgzHnXw5VnMT5cg75dQGDkA==} + engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true - resolution: - integrity: sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w== - /@typescript-eslint/typescript-estree/4.19.0_typescript@4.2.3: dependencies: '@typescript-eslint/types': 4.19.0 '@typescript-eslint/visitor-keys': 4.19.0 @@ -2823,99 +3337,414 @@ packages: semver: 7.3.2 tsutils: 3.21.0_typescript@4.2.3 typescript: 4.2.3 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: ^10.12.0 || >=12.0.0 - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - resolution: - integrity: sha512-3xqArJ/A62smaQYRv2ZFyTA+XxGGWmlDYrsfZG68zJeNbeqRScnhf81rUVa6QG4UgzHnXw5VnMT5cg75dQGDkA== + /@typescript-eslint/visitor-keys/3.10.1: + resolution: {integrity: sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==} + engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} dependencies: eslint-visitor-keys: 1.3.0 dev: true - engines: - node: ^8.10.0 || ^10.13.0 || >=11.10.1 - resolution: - integrity: sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ== + /@typescript-eslint/visitor-keys/4.19.0: + resolution: {integrity: sha512-aGPS6kz//j7XLSlgpzU2SeTqHPsmRYxFztj2vPuMMFJXZudpRSehE3WCV+BaxwZFvfAqMoSd86TEuM0PQ59E/A==} + engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} dependencies: '@typescript-eslint/types': 4.19.0 eslint-visitor-keys: 2.0.0 dev: true - engines: - node: ^8.10.0 || ^10.13.0 || >=11.10.1 - resolution: - integrity: sha512-aGPS6kz//j7XLSlgpzU2SeTqHPsmRYxFztj2vPuMMFJXZudpRSehE3WCV+BaxwZFvfAqMoSd86TEuM0PQ59E/A== + + /@umijs/ast/3.5.20: + resolution: {integrity: sha512-bDKofiykO9xT6QWLeauv9B8XOd32SUlRDXVGcLEOhR8ZPPWR2LYgJaYZAnpxEbRmCrfVokiB69uZ2/qcvIvjmQ==} + dependencies: + '@umijs/utils': 3.5.20 + dev: true + + /@umijs/babel-plugin-auto-css-modules/3.5.20: + resolution: {integrity: sha512-mN/ueXm7KHCmrfK8nluPqx3JGNftNj/wWPUKpcDiheagVNz+PJ++aIFI9ikfqK8ukHVVBisWltEJwOrDM8QUdQ==} + dependencies: + '@umijs/utils': 3.5.20 + dev: true + + /@umijs/babel-plugin-import-to-await-require/3.5.20: + resolution: {integrity: sha512-cZma+jLAQ0FeHpezTYJLELSyKMMtrYNIjFeTLxDT6Pw5Z1Ei3cJHf8ERYV4kDBzu/rvuUFx1AC5UPGwYmqsVxw==} + dependencies: + '@umijs/utils': 3.5.20 + dev: true + + /@umijs/babel-plugin-lock-core-js-3/3.5.20: + resolution: {integrity: sha512-bbyg0QLSeNXVrnFZIx2TgOalDUMBPVIHtR6G7aeBmSsiUSFRwsCNprjc/NPEBwOFG10J4XmJFETzcgig3hKLoA==} + dependencies: + '@umijs/utils': 3.5.20 + core-js: 3.6.5 + dev: true + + /@umijs/babel-plugin-no-anonymous-default-export/3.5.20: + resolution: {integrity: sha512-ufM+mcDrRJMTWWqP/C73NLqeW7CrgxrXlSKnmJ+CCNTT1GPex5+5Ou2IM6HLqXukm+7W+xdDepMEWrYGmdGQRg==} + dependencies: + '@umijs/utils': 3.5.20 + dev: true + + /@umijs/babel-preset-umi/3.5.20: + resolution: {integrity: sha512-EBvLi2aVkIiKAGmdDXkyx/pW4OJXqxvnSz54Za9r+kVZMbG5kT50ieqMk9yciNa+1JzHGiY+XZl0f0YF9pStAg==} + dependencies: + '@babel/runtime': 7.12.5 + '@umijs/babel-plugin-auto-css-modules': 3.5.20 + '@umijs/babel-plugin-import-to-await-require': 3.5.20 + '@umijs/babel-plugin-lock-core-js-3': 3.5.20 + '@umijs/babel-plugin-no-anonymous-default-export': 3.5.20 + '@umijs/deps': 3.5.20 + dev: true + + /@umijs/bundler-utils/3.5.20_39566ec7cc5fe716a59f91f7330320ef: + resolution: {integrity: sha512-9tg8Dq3ufChaeLVE3RaMNrdtzru7ev/JX9lFUgUXnTladGp4mhPiBHv1bbx0X2I+ZTlRJAplQDKMAq4zkonUXw==} + dependencies: + '@umijs/babel-preset-umi': 3.5.20 + '@umijs/types': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/utils': 3.5.20 + transitivePeerDependencies: + - react + - react-dom + - react-router + dev: true + + /@umijs/bundler-webpack/3.5.20_39566ec7cc5fe716a59f91f7330320ef: + resolution: {integrity: sha512-eGcxaUKTuAXd46uu6d9/B0SwqgR06zaay3iD1AQCle8x3zKilVBT62L+H8IbM0mB/3XFvIXMOxOBGMHPVr+tCQ==} + hasBin: true + dependencies: + '@umijs/bundler-utils': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/deps': 3.5.20 + '@umijs/types': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/utils': 3.5.20 + jest-worker: 26.6.2 + node-libs-browser: 2.2.1 + normalize-url: 1.9.1 + postcss: 7.0.32 + postcss-flexbugs-fixes: 4.2.1 + postcss-loader: 3.0.0 + postcss-preset-env: 6.7.0 + postcss-safe-parser: 4.0.2 + terser: 5.6.0 + webpack-chain: 6.5.1 + transitivePeerDependencies: + - react + - react-dom + - react-router + dev: true + + /@umijs/core/3.5.20: + resolution: {integrity: sha512-XiNHL3cZ8tQAG3FFGkCcIyJGZADM/F53JMgshtKmWmpLWPmd623LUJP5Tz3ifad+4QhxkY3tOKBIoVbnCxwGkA==} + dependencies: + '@umijs/ast': 3.5.20 + '@umijs/babel-preset-umi': 3.5.20 + '@umijs/deps': 3.5.20 + '@umijs/utils': 3.5.20 + dev: true + + /@umijs/deps/3.5.20: + resolution: {integrity: sha512-75iqB0+ITFtxlLb945W2b6lVEgLWRFXaSQZD+wH6c4/WDiagOdYMWX9aiPs2JSzoM/yCtKpMaLeGbmVXsb7y4g==} + dependencies: + '@bloomberg/record-tuple-polyfill': 0.0.3 + chokidar: 3.5.1 + clipboardy: 2.3.0 + esbuild: 0.12.15 + jest-worker: 24.9.0 + prettier: 2.2.1 + dev: true + + /@umijs/plugin-analytics/0.2.2_umi@3.5.20: + resolution: {integrity: sha512-dVDzUfgIdEwdCC6a5IsMYpIPI+bEZjBEqIhAvw9dic6Vk77w9RxQxyRfW11dDmdXLAwWphp22NntQNt1ejZPtg==} + peerDependencies: + umi: 3.x + dependencies: + umi: 3.5.20_react-router@5.2.0 + dev: true + + /@umijs/preset-built-in/3.5.20_react-dom@16.14.0+react@16.14.0: + resolution: {integrity: sha512-4qrYPdEDi0ewZ1gYyfbg9bRmbPtvaNTs3WlA574VPf77ntsZDsMUNNUfJSd/acQTzzbEajte2u8uq8q0yvZ4mA==} + peerDependencies: + react: 16.x || 17.x + dependencies: + '@types/react-router-config': 5.0.2 + '@umijs/babel-preset-umi': 3.5.20 + '@umijs/bundler-webpack': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/deps': 3.5.20 + '@umijs/renderer-mpa': 3.5.20_react-dom@16.14.0+react@16.14.0 + '@umijs/renderer-react': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/runtime': 3.5.20_react@16.14.0 + '@umijs/server': 3.5.20 + '@umijs/types': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/utils': 3.5.20 + ansi-html: 0.0.7 + core-js: 3.6.5 + core-js-pure: 3.9.1 + error-stack-parser: 2.0.6 + es-module-lexer: 0.7.1 + es5-imcompatible-versions: 0.1.73 + history-with-query: 4.10.4 + html-entities: 2.3.2 + mime: 1.3.6 + react: 16.14.0 + react-refresh: 0.10.0 + react-router: 5.2.0_react@16.14.0 + react-router-config: 5.1.1_react-router@5.2.0+react@16.14.0 + react-router-dom: 5.2.0_react@16.14.0 + regenerator-runtime: 0.13.5 + schema-utils: 3.1.0 + transitivePeerDependencies: + - react-dom + dev: true + + /@umijs/preset-dumi/1.1.30_b08c95616290592113c9128c4b0c3f8f: + resolution: {integrity: sha512-DzfSuSSDe/jH9w/CbebtjXurKHo14nSMWfQYv08O8IMuvNqp+Hey+h9nkFteiIdyk8LNWd/FdguKaSiAaXqkBQ==} + peerDependencies: + umi: 3.x + dependencies: + '@babel/core': 7.12.3 + '@babel/generator': 7.13.9 + '@babel/plugin-transform-modules-commonjs': 7.13.8_@babel+core@7.12.3 + '@babel/traverse': 7.13.13 + '@babel/types': 7.13.13 + '@mapbox/hast-util-to-jsx': 1.0.0 + '@umijs/babel-preset-umi': 3.5.20 + '@umijs/plugin-analytics': 0.2.2_umi@3.5.20 + '@umijs/runtime': 3.5.20_react@17.0.2 + '@umijs/types': 3.5.20_1a2589ed57a826879b76fd1635c5e26a + '@umijs/utils': 3.5.20 + copy-text-to-clipboard: 2.2.0 + deepmerge: 4.2.2 + dumi-assets-types: 1.0.0 + dumi-theme-default: 1.1.13_ac48d56268a7095d7c6000b1357273b0 + enhanced-resolve: 4.5.0 + github-slugger: 1.4.0 + hast-util-has-property: 1.0.4 + hast-util-is-element: 1.1.0 + hast-util-raw: 6.1.0 + hast-util-to-html: 7.1.3 + hast-util-to-string: 1.0.4 + hosted-git-info: 3.0.8 + ignore: 5.1.8 + js-yaml: 3.14.1 + lodash.throttle: 4.1.1 + lz-string: 1.4.4 + react-docgen-typescript-dumi-tmp: 1.22.1-0_typescript@4.2.3 + rehype-autolink-headings: 4.0.0 + rehype-mathjax: 3.1.0 + rehype-remove-comments: 4.0.2 + rehype-stringify: 8.0.0 + remark-frontmatter: 3.0.0 + remark-gfm: 1.0.0 + remark-math: 4.0.0 + remark-parse: 9.0.0 + remark-rehype: 8.1.0 + remark-stringify: 9.0.1 + sitemap: 6.4.0 + slash2: 2.0.0 + terser: 5.6.1 + umi: 3.5.20_react-router@5.2.0 + unified: 8.4.2 + unist-util-visit: 2.0.3 + unist-util-visit-parents: 3.1.1 + transitivePeerDependencies: + - bufferutil + - canvas + - react + - react-dom + - react-router + - supports-color + - typescript + - utf-8-validate + dev: true + + /@umijs/renderer-mpa/3.5.20_react-dom@16.14.0+react@16.14.0: + resolution: {integrity: sha512-lE1EA8kciz8YTpeRGPd2ZY+owH1lAuj6nkhvID9QRR8qTPRRunU0lZHMAW0C8NcnQ9kECbHblkPPbDG3NayZZA==} + peerDependencies: + react: 16.x || 17.x + react-dom: 16.x || 17.x + dependencies: + '@types/react': 16.14.5 + '@types/react-dom': 16.9.12 + '@umijs/runtime': 3.5.20_react@16.14.0 + react: 16.14.0 + react-dom: 16.14.0_react@16.14.0 + dev: true + + /@umijs/renderer-react/3.5.20_1a2589ed57a826879b76fd1635c5e26a: + resolution: {integrity: sha512-8ZEHxMmF0Rm5il9RjTbzn4DLd7FU8KkqAwRGcpNhMcsPxXkbieTfnQC+a0D7nvor0+vakygfCG7ZgXDkcO1W0Q==} + peerDependencies: + react: 16.x || 17.x + react-dom: 16.x || 17.x + dependencies: + '@types/react': 16.14.5 + '@types/react-dom': 16.9.12 + '@types/react-router-config': 5.0.2 + '@umijs/runtime': 3.5.20_react@17.0.2 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + react-router-config: 5.1.1_react-router@5.2.0+react@17.0.2 + transitivePeerDependencies: + - react-router + dev: true + + /@umijs/renderer-react/3.5.20_39566ec7cc5fe716a59f91f7330320ef: + resolution: {integrity: sha512-8ZEHxMmF0Rm5il9RjTbzn4DLd7FU8KkqAwRGcpNhMcsPxXkbieTfnQC+a0D7nvor0+vakygfCG7ZgXDkcO1W0Q==} + peerDependencies: + react: 16.x || 17.x + react-dom: 16.x || 17.x + dependencies: + '@types/react': 16.14.5 + '@types/react-dom': 16.9.12 + '@types/react-router-config': 5.0.2 + '@umijs/runtime': 3.5.20_react@16.14.0 + react: 16.14.0 + react-dom: 16.14.0_react@16.14.0 + react-router-config: 5.1.1_react-router@5.2.0+react@16.14.0 + transitivePeerDependencies: + - react-router + dev: true + + /@umijs/runtime/3.5.20_react@16.14.0: + resolution: {integrity: sha512-AADBzjzbydjMBpaA9nw1vNsaBA7YoUTV45tmUAf47Mn3sPlEknbUFOPryhZEsOFfkBvag7GysRNzgCJPxYiWIQ==} + peerDependencies: + react: 16.x || 17.x + dependencies: + '@types/react-router': 5.1.12 + '@types/react-router-dom': 5.1.7 + history-with-query: 4.10.4 + react: 16.14.0 + react-router: 5.2.0_react@16.14.0 + react-router-dom: 5.2.0_react@16.14.0 + use-subscription: 1.5.1_react@16.14.0 + dev: true + + /@umijs/runtime/3.5.20_react@17.0.2: + resolution: {integrity: sha512-AADBzjzbydjMBpaA9nw1vNsaBA7YoUTV45tmUAf47Mn3sPlEknbUFOPryhZEsOFfkBvag7GysRNzgCJPxYiWIQ==} + peerDependencies: + react: 16.x || 17.x + dependencies: + '@types/react-router': 5.1.12 + '@types/react-router-dom': 5.1.7 + history-with-query: 4.10.4 + react: 17.0.2 + react-router: 5.2.0_react@17.0.2 + react-router-dom: 5.2.0_react@17.0.2 + use-subscription: 1.5.1_react@17.0.2 + dev: true + + /@umijs/server/3.5.20: + resolution: {integrity: sha512-upIahEP5+Xb4e9GIjFWWF5boHRvINuVgtn21ySt9335JkT24wIRsInMgrQJ6WvqCxckSg0g0/hpxIJWCcIhIkw==} + dependencies: + '@umijs/deps': 3.5.20 + '@umijs/utils': 3.5.20 + dev: true + + /@umijs/types/3.5.20_1a2589ed57a826879b76fd1635c5e26a: + resolution: {integrity: sha512-g2Eesf6tLfKdS6lRNDG4YYG3cEYKYsD5FWcziM12fpAV9u8wEmxxhpc+GeSgtQ5s/FT7B7A1WoX8YmSJgupM/w==} + dependencies: + '@umijs/babel-preset-umi': 3.5.20 + '@umijs/core': 3.5.20 + '@umijs/deps': 3.5.20 + '@umijs/renderer-react': 3.5.20_1a2589ed57a826879b76fd1635c5e26a + '@umijs/server': 3.5.20 + '@umijs/utils': 3.5.20 + webpack-chain: 6.5.1 + transitivePeerDependencies: + - react + - react-dom + - react-router + dev: true + + /@umijs/types/3.5.20_39566ec7cc5fe716a59f91f7330320ef: + resolution: {integrity: sha512-g2Eesf6tLfKdS6lRNDG4YYG3cEYKYsD5FWcziM12fpAV9u8wEmxxhpc+GeSgtQ5s/FT7B7A1WoX8YmSJgupM/w==} + dependencies: + '@umijs/babel-preset-umi': 3.5.20 + '@umijs/core': 3.5.20 + '@umijs/deps': 3.5.20 + '@umijs/renderer-react': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/server': 3.5.20 + '@umijs/utils': 3.5.20 + webpack-chain: 6.5.1 + transitivePeerDependencies: + - react + - react-dom + - react-router + dev: true + + /@umijs/utils/3.5.20: + resolution: {integrity: sha512-Y0i27zZTCKoqdHHyTuebO/GOIY4gGLUwDFs1eoH+m4etPn+uRq0iax9KJOkelmax2K3YLsT4KbRwM1enlSsv3A==} + dependencies: + '@umijs/deps': 3.5.20 + dev: true + /@webassemblyjs/ast/1.9.0: + resolution: {integrity: sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==} dependencies: '@webassemblyjs/helper-module-context': 1.9.0 '@webassemblyjs/helper-wasm-bytecode': 1.9.0 '@webassemblyjs/wast-parser': 1.9.0 dev: true - resolution: - integrity: sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA== + /@webassemblyjs/floating-point-hex-parser/1.9.0: + resolution: {integrity: sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==} dev: true - resolution: - integrity: sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA== + /@webassemblyjs/helper-api-error/1.9.0: + resolution: {integrity: sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==} dev: true - resolution: - integrity: sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw== + /@webassemblyjs/helper-buffer/1.9.0: + resolution: {integrity: sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==} dev: true - resolution: - integrity: sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA== + /@webassemblyjs/helper-code-frame/1.9.0: + resolution: {integrity: sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==} dependencies: '@webassemblyjs/wast-printer': 1.9.0 dev: true - resolution: - integrity: sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA== + /@webassemblyjs/helper-fsm/1.9.0: + resolution: {integrity: sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==} dev: true - resolution: - integrity: sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw== + /@webassemblyjs/helper-module-context/1.9.0: + resolution: {integrity: sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==} dependencies: '@webassemblyjs/ast': 1.9.0 dev: true - resolution: - integrity: sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g== + /@webassemblyjs/helper-wasm-bytecode/1.9.0: + resolution: {integrity: sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==} dev: true - resolution: - integrity: sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw== + /@webassemblyjs/helper-wasm-section/1.9.0: + resolution: {integrity: sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==} dependencies: '@webassemblyjs/ast': 1.9.0 '@webassemblyjs/helper-buffer': 1.9.0 '@webassemblyjs/helper-wasm-bytecode': 1.9.0 '@webassemblyjs/wasm-gen': 1.9.0 dev: true - resolution: - integrity: sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw== + /@webassemblyjs/ieee754/1.9.0: + resolution: {integrity: sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==} dependencies: '@xtuc/ieee754': 1.2.0 dev: true - resolution: - integrity: sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg== + /@webassemblyjs/leb128/1.9.0: + resolution: {integrity: sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==} dependencies: '@xtuc/long': 4.2.2 dev: true - resolution: - integrity: sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw== + /@webassemblyjs/utf8/1.9.0: + resolution: {integrity: sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==} dev: true - resolution: - integrity: sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w== + /@webassemblyjs/wasm-edit/1.9.0: + resolution: {integrity: sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==} dependencies: '@webassemblyjs/ast': 1.9.0 '@webassemblyjs/helper-buffer': 1.9.0 @@ -2926,9 +3755,9 @@ packages: '@webassemblyjs/wasm-parser': 1.9.0 '@webassemblyjs/wast-printer': 1.9.0 dev: true - resolution: - integrity: sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw== + /@webassemblyjs/wasm-gen/1.9.0: + resolution: {integrity: sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==} dependencies: '@webassemblyjs/ast': 1.9.0 '@webassemblyjs/helper-wasm-bytecode': 1.9.0 @@ -2936,18 +3765,18 @@ packages: '@webassemblyjs/leb128': 1.9.0 '@webassemblyjs/utf8': 1.9.0 dev: true - resolution: - integrity: sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA== + /@webassemblyjs/wasm-opt/1.9.0: + resolution: {integrity: sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==} dependencies: '@webassemblyjs/ast': 1.9.0 '@webassemblyjs/helper-buffer': 1.9.0 '@webassemblyjs/wasm-gen': 1.9.0 '@webassemblyjs/wasm-parser': 1.9.0 dev: true - resolution: - integrity: sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A== + /@webassemblyjs/wasm-parser/1.9.0: + resolution: {integrity: sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==} dependencies: '@webassemblyjs/ast': 1.9.0 '@webassemblyjs/helper-api-error': 1.9.0 @@ -2956,9 +3785,9 @@ packages: '@webassemblyjs/leb128': 1.9.0 '@webassemblyjs/utf8': 1.9.0 dev: true - resolution: - integrity: sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA== + /@webassemblyjs/wast-parser/1.9.0: + resolution: {integrity: sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==} dependencies: '@webassemblyjs/ast': 1.9.0 '@webassemblyjs/floating-point-hex-parser': 1.9.0 @@ -2967,369 +3796,295 @@ packages: '@webassemblyjs/helper-fsm': 1.9.0 '@xtuc/long': 4.2.2 dev: true - resolution: - integrity: sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw== + /@webassemblyjs/wast-printer/1.9.0: + resolution: {integrity: sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==} dependencies: '@webassemblyjs/ast': 1.9.0 '@webassemblyjs/wast-parser': 1.9.0 '@xtuc/long': 4.2.2 dev: true - resolution: - integrity: sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA== + /@webpack-cli/configtest/1.0.2_webpack-cli@4.6.0+webpack@4.44.2: - dependencies: - webpack: 4.44.2_webpack-cli@4.6.0 - webpack-cli: 4.6.0_e3222a4926c3b7d4c1aa5becb19e445f - dev: true + resolution: {integrity: sha512-3OBzV2fBGZ5TBfdW50cha1lHDVf9vlvRXnjpVbJBa20pSZQaSkMJZiwA8V2vD9ogyeXn8nU5s5A6mHyf5jhMzA==} peerDependencies: webpack: 4.x.x || 5.x.x webpack-cli: 4.x.x - resolution: - integrity: sha512-3OBzV2fBGZ5TBfdW50cha1lHDVf9vlvRXnjpVbJBa20pSZQaSkMJZiwA8V2vD9ogyeXn8nU5s5A6mHyf5jhMzA== - /@webpack-cli/info/1.2.3_webpack-cli@4.6.0: dependencies: - envinfo: 7.7.4 + webpack: 4.44.2_webpack-cli@4.6.0 webpack-cli: 4.6.0_e3222a4926c3b7d4c1aa5becb19e445f dev: true + + /@webpack-cli/info/1.2.3_webpack-cli@4.6.0: + resolution: {integrity: sha512-lLek3/T7u40lTqzCGpC6CAbY6+vXhdhmwFRxZLMnRm6/sIF/7qMpT8MocXCRQfz0JAh63wpbXLMnsQ5162WS7Q==} peerDependencies: webpack-cli: 4.x.x - resolution: - integrity: sha512-lLek3/T7u40lTqzCGpC6CAbY6+vXhdhmwFRxZLMnRm6/sIF/7qMpT8MocXCRQfz0JAh63wpbXLMnsQ5162WS7Q== - /@webpack-cli/serve/1.3.1_6ea2aad37093f4611e49e6674f4decdc: dependencies: + envinfo: 7.7.4 webpack-cli: 4.6.0_e3222a4926c3b7d4c1aa5becb19e445f - webpack-dev-server: 3.11.0_webpack-cli@4.6.0+webpack@4.44.2 dev: true + + /@webpack-cli/serve/1.3.1_6ea2aad37093f4611e49e6674f4decdc: + resolution: {integrity: sha512-0qXvpeYO6vaNoRBI52/UsbcaBydJCggoBBnIo/ovQQdn6fug0BgwsjorV1hVS7fMqGVTZGcVxv8334gjmbj5hw==} peerDependencies: webpack-cli: 4.x.x webpack-dev-server: '*' peerDependenciesMeta: webpack-dev-server: optional: true - resolution: - integrity: sha512-0qXvpeYO6vaNoRBI52/UsbcaBydJCggoBBnIo/ovQQdn6fug0BgwsjorV1hVS7fMqGVTZGcVxv8334gjmbj5hw== - /@welldone-software/why-did-you-render/6.1.1_react@17.0.2: dependencies: - lodash: 4.17.21 - react: 17.0.2 - dev: false - peerDependencies: - react: ^16 || ^17 - resolution: - integrity: sha512-BMFp33T4MC27qvCWsI1SqwZCxIlxoQXsPQFdGLDsPSg7sgoWX4Gzj0+hlKVrWrCBiIxi7gP2JcS9IK6CZzk8mg== + webpack-cli: 4.6.0_e3222a4926c3b7d4c1aa5becb19e445f + webpack-dev-server: 3.11.0_webpack-cli@4.6.0+webpack@4.44.2 + dev: true + /@xobotyi/scrollbar-width/1.9.5: + resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} dev: false - resolution: - integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ== + /@xtuc/ieee754/1.2.0: + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} dev: true - resolution: - integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + /@xtuc/long/4.2.2: + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} dev: true - resolution: - integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + /abab/2.0.5: + resolution: {integrity: sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==} dev: true - resolution: - integrity: sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== + /accepts/1.3.7: + resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==} + engines: {node: '>= 0.6'} dependencies: mime-types: 2.1.29 negotiator: 0.6.2 dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== + /acorn-globals/6.0.0: + resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==} dependencies: acorn: 7.4.1 acorn-walk: 7.2.0 dev: true - resolution: - integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg== + /acorn-jsx/5.3.1_acorn@7.4.1: + resolution: {integrity: sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: acorn: 7.4.1 dev: true - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - resolution: - integrity: sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== + /acorn-walk/7.2.0: + resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} + engines: {node: '>=0.4.0'} dev: true - engines: - node: '>=0.4.0' - resolution: - integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== + /acorn/6.4.2: - dev: true - engines: - node: '>=0.4.0' + resolution: {integrity: sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==} + engines: {node: '>=0.4.0'} hasBin: true - resolution: - integrity: sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ== - /acorn/7.4.1: dev: true - engines: - node: '>=0.4.0' + + /acorn/7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} hasBin: true - resolution: - integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== - /acorn/8.1.0: dev: true - engines: - node: '>=0.4.0' + + /acorn/8.1.0: + resolution: {integrity: sha512-LWCF/Wn0nfHOmJ9rzQApGnxnvgfROzGilS8936rqN/lfcYkY9MYZzdMqN+2NJ4SlTc+m5HiSa+kNfDtI64dwUA==} + engines: {node: '>=0.4.0'} hasBin: true - resolution: - integrity: sha512-LWCF/Wn0nfHOmJ9rzQApGnxnvgfROzGilS8936rqN/lfcYkY9MYZzdMqN+2NJ4SlTc+m5HiSa+kNfDtI64dwUA== + dev: true + /address/1.1.2: + resolution: {integrity: sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==} + engines: {node: '>= 0.12.0'} dev: false - engines: - node: '>= 0.12.0' - resolution: - integrity: sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== + /adjust-sourcemap-loader/3.0.0: + resolution: {integrity: sha512-YBrGyT2/uVQ/c6Rr+t6ZJXniY03YtHGMJQYal368burRGYKqhx9qGTWqcBU5s1CwYY9E/ri63RYyG1IacMZtqw==} + engines: {node: '>=8.9'} dependencies: loader-utils: 2.0.0 regex-parser: 2.2.11 dev: true - engines: - node: '>=8.9' - resolution: - integrity: sha512-YBrGyT2/uVQ/c6Rr+t6ZJXniY03YtHGMJQYal368burRGYKqhx9qGTWqcBU5s1CwYY9E/ri63RYyG1IacMZtqw== + /aggregate-error/3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} dependencies: clean-stack: 2.2.0 indent-string: 4.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + /ajv-errors/1.0.1_ajv@6.12.6: - dependencies: - ajv: 6.12.6 - dev: true + resolution: {integrity: sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==} peerDependencies: ajv: '>=5.0.0' - resolution: - integrity: sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== - /ajv-keywords/3.5.2_ajv@6.12.6: dependencies: ajv: 6.12.6 dev: true + + /ajv-keywords/3.5.2_ajv@6.12.6: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: ajv: ^6.9.1 - resolution: - integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== + dependencies: + ajv: 6.12.6 + dev: true + /ajv/6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 dev: true - resolution: - integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + /ajv/8.0.1: + resolution: {integrity: sha512-46ZA4TalFcLLqX1dEU3dhdY38wAtDydJ4e7QQTVekLUTzXkb1LfqU6VOBXC/a9wiv4T094WURqJH6ZitF92Kqw==} dependencies: fast-deep-equal: 3.1.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 uri-js: 4.4.1 dev: true - resolution: - integrity: sha512-46ZA4TalFcLLqX1dEU3dhdY38wAtDydJ4e7QQTVekLUTzXkb1LfqU6VOBXC/a9wiv4T094WURqJH6ZitF92Kqw== + /alphanum-sort/1.0.2: + resolution: {integrity: sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=} dev: true - resolution: - integrity: sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= + /ansi-colors/3.2.4: + resolution: {integrity: sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== + /ansi-colors/4.1.1: + resolution: {integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + /ansi-escapes/4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} dependencies: type-fest: 0.21.3 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + /ansi-html/0.0.7: - dev: true - engines: - '0': node >= 0.8.0 + resolution: {integrity: sha1-gTWEAhliqenm/QOflA0S9WynhZ4=} + engines: {'0': node >= 0.8.0} hasBin: true - resolution: - integrity: sha1-gTWEAhliqenm/QOflA0S9WynhZ4= + dev: true + /ansi-regex/2.1.1: + resolution: {integrity: sha1-w7M6te42DYbg5ijwRorn7yfWVN8=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + /ansi-regex/4.1.0: + resolution: {integrity: sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + /ansi-regex/5.0.0: - engines: - node: '>=8' - resolution: - integrity: sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + resolution: {integrity: sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==} + engines: {node: '>=8'} + /ansi-styles/3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} dependencies: color-convert: 1.9.3 - engines: - node: '>=4' - resolution: - integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + /ansi-styles/4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} dependencies: color-convert: 2.0.1 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - /antd-dayjs-webpack-plugin/1.0.6_dayjs@1.10.4: - dependencies: - dayjs: 1.10.4 - dev: true - peerDependencies: - dayjs: '*' - resolution: - integrity: sha512-UlK3BfA0iE2c5+Zz/Bd2iPAkT6cICtrKG4/swSik5MZweBHtgmu1aUQCHvICdiv39EAShdZy/edfP6mlkS/xXg== - /antd/4.14.1_2235c505ed33ea6efd93d3050f896208: - dependencies: - '@ant-design/colors': 6.0.0 - '@ant-design/icons': 4.6.2_react-dom@17.0.2+react@17.0.2 - '@ant-design/react-slick': 0.28.2 - '@babel/runtime': 7.13.10 - array-tree-filter: 2.1.0 - classnames: 2.2.6 - copy-to-clipboard: 3.3.1 - lodash: 4.17.21 - moment: 2.29.1 - rc-cascader: 1.4.2_react-dom@17.0.2+react@17.0.2 - rc-checkbox: 2.3.2_react-dom@17.0.2+react@17.0.2 - rc-collapse: 3.1.0_react-dom@17.0.2+react@17.0.2 - rc-dialog: 8.5.2_react-dom@17.0.2+react@17.0.2 - rc-drawer: 4.3.1_react-dom@17.0.2+react@17.0.2 - rc-dropdown: 3.2.0_react-dom@17.0.2+react@17.0.2 - rc-field-form: 1.20.0_react-dom@17.0.2+react@17.0.2 - rc-image: 5.2.4_react-dom@17.0.2+react@17.0.2 - rc-input-number: 7.0.3_react-dom@17.0.2+react@17.0.2 - rc-mentions: 1.5.3_react-dom@17.0.2+react@17.0.2 - rc-menu: 8.10.6_react-dom@17.0.2+react@17.0.2 - rc-motion: 2.4.1_react-dom@17.0.2+react@17.0.2 - rc-notification: 4.5.5_react-dom@17.0.2+react@17.0.2 - rc-pagination: 3.1.6_react-dom@17.0.2+react@17.0.2 - rc-picker: 2.5.10_2235c505ed33ea6efd93d3050f896208 - rc-progress: 3.1.3_react-dom@17.0.2+react@17.0.2 - rc-rate: 2.9.1_react-dom@17.0.2+react@17.0.2 - rc-resize-observer: 1.0.0_react-dom@17.0.2+react@17.0.2 - rc-select: 12.1.7_react-dom@17.0.2+react@17.0.2 - rc-slider: 9.7.2_react-dom@17.0.2+react@17.0.2 - rc-steps: 4.1.3_react-dom@17.0.2+react@17.0.2 - rc-switch: 3.2.2_react-dom@17.0.2+react@17.0.2 - rc-table: 7.13.3_react-dom@17.0.2+react@17.0.2 - rc-tabs: 11.7.3_react-dom@17.0.2+react@17.0.2 - rc-textarea: 0.3.4_react-dom@17.0.2+react@17.0.2 - rc-tooltip: 5.1.0_react-dom@17.0.2+react@17.0.2 - rc-tree: 4.1.5_react-dom@17.0.2+react@17.0.2 - rc-tree-select: 4.3.1_react-dom@17.0.2+react@17.0.2 - rc-trigger: 5.2.3_react-dom@17.0.2+react@17.0.2 - rc-upload: 4.2.0_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - scroll-into-view-if-needed: 2.2.28 - warning: 4.0.3 - dev: false - peerDependencies: - dayjs: '*' - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-984zBd4EtsBfCC4dUmDAZfaCphjcm7+ldKBWJHPyheUZL5S3X7ZSz+Ld75XGNFj4pLjcGMi2SwGOr/4hmByNsg== + /anymatch/2.0.0: + resolution: {integrity: sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==} dependencies: micromatch: 3.1.10 normalize-path: 2.1.1 dev: true - resolution: - integrity: sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + /anymatch/3.1.1: + resolution: {integrity: sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==} + engines: {node: '>= 8'} dependencies: normalize-path: 3.0.0 picomatch: 2.2.2 dev: true - engines: - node: '>= 8' - resolution: - integrity: sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + optional: true + /aproba/1.2.0: + resolution: {integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==} dev: true - resolution: - integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + + /arch/2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + dev: true + + /arg/5.0.1: + resolution: {integrity: sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==} + dev: true + /argparse/1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} dependencies: sprintf-js: 1.0.3 dev: true - resolution: - integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + /aria-query/4.2.2: + resolution: {integrity: sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==} + engines: {node: '>=6.0'} dependencies: '@babel/runtime': 7.13.10 '@babel/runtime-corejs3': 7.13.10 dev: true - engines: - node: '>=6.0' - resolution: - integrity: sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA== + /arity-n/1.0.4: + resolution: {integrity: sha1-2edrEXM+CFacCEeuezmyhgswt0U=} dev: true - resolution: - integrity: sha1-2edrEXM+CFacCEeuezmyhgswt0U= + /arr-diff/4.0.0: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + resolution: {integrity: sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=} + engines: {node: '>=0.10.0'} + /arr-flatten/1.1.0: - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + resolution: {integrity: sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==} + engines: {node: '>=0.10.0'} + /arr-union/3.1.0: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + resolution: {integrity: sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=} + engines: {node: '>=0.10.0'} + /array-flatten/1.1.1: + resolution: {integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=} dev: true - resolution: - integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= + /array-flatten/2.1.2: + resolution: {integrity: sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==} dev: true - resolution: - integrity: sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== + /array-includes/3.1.3: + resolution: {integrity: sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 @@ -3337,147 +4092,126 @@ packages: get-intrinsic: 1.1.1 is-string: 1.0.5 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A== - /array-tree-filter/2.1.0: - dev: false - resolution: - integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw== + /array-union/1.0.2: + resolution: {integrity: sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=} + engines: {node: '>=0.10.0'} dependencies: array-uniq: 1.0.3 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk= + /array-union/2.1.0: - engines: - node: '>=8' - resolution: - integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + /array-uniq/1.0.3: + resolution: {integrity: sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= + /array-unique/0.3.2: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + resolution: {integrity: sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=} + engines: {node: '>=0.10.0'} + /array.prototype.flat/1.2.4: + resolution: {integrity: sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 es-abstract: 1.18.0 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg== + /array.prototype.flatmap/1.2.4: + resolution: {integrity: sha512-r9Z0zYoxqHz60vvQbWEdXIEtCwHF0yxaWfno9qzXeNHvfyl3BZqygmGzb84dsubyaXLH4husF+NFgMSdpZhk2Q==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 es-abstract: 1.18.0 function-bind: 1.1.1 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-r9Z0zYoxqHz60vvQbWEdXIEtCwHF0yxaWfno9qzXeNHvfyl3BZqygmGzb84dsubyaXLH4husF+NFgMSdpZhk2Q== + /arrify/2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== + /asap/2.0.6: + resolution: {integrity: sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=} dev: false - resolution: - integrity: sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + /asn1.js/5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} dependencies: bn.js: 4.12.0 inherits: 2.0.4 minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 dev: true - resolution: - integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + /asn1/0.2.4: + resolution: {integrity: sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==} dependencies: safer-buffer: 2.1.2 dev: true - resolution: - integrity: sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + /assert-plus/1.0.0: + resolution: {integrity: sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=} + engines: {node: '>=0.8'} dev: true - engines: - node: '>=0.8' - resolution: - integrity: sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + /assert/1.5.0: + resolution: {integrity: sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==} dependencies: object-assign: 4.1.1 util: 0.10.3 dev: true - resolution: - integrity: sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA== + /assign-symbols/1.0.0: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + resolution: {integrity: sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=} + engines: {node: '>=0.10.0'} + /ast-types-flow/0.0.7: + resolution: {integrity: sha1-9wtzXGvKGlycItmCw+Oef+ujva0=} dev: true - resolution: - integrity: sha1-9wtzXGvKGlycItmCw+Oef+ujva0= + /astral-regex/2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + /async-each/1.0.3: + resolution: {integrity: sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==} dev: true - resolution: - integrity: sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== + /async-limiter/1.0.1: + resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} dev: true - resolution: - integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== - /async-validator/3.5.1: - dev: false - resolution: - integrity: sha512-DDmKA7sdSAJtTVeNZHrnr2yojfFaoeW8MfQN8CeuXg8DDQHTqKk9Fdv38dSvnesHoO8MUwMI2HphOeSyIF+wmQ== + /async/2.6.3: + resolution: {integrity: sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==} dependencies: lodash: 4.17.21 dev: true - resolution: - integrity: sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== + /asynckit/0.4.0: + resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=} dev: true - resolution: - integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k= + /at-least-node/1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} dev: true - engines: - node: '>= 4.0.0' - resolution: - integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + /atob/2.1.2: - engines: - node: '>= 4.5.0' + resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} + engines: {node: '>= 4.5.0'} hasBin: true - resolution: - integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + /autoprefixer/9.8.6: + resolution: {integrity: sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg==} + hasBin: true dependencies: browserslist: 4.16.3 caniuse-lite: 1.0.30001204 @@ -3487,34 +4221,46 @@ packages: postcss: 7.0.35 postcss-value-parser: 4.1.0 dev: true - hasBin: true - resolution: - integrity: sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg== + /aws-sign2/0.7.0: + resolution: {integrity: sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=} dev: true - resolution: - integrity: sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + /aws4/1.11.0: + resolution: {integrity: sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==} dev: true - resolution: - integrity: sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + /axe-core/4.1.3: + resolution: {integrity: sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ== + /axios/0.21.1: + resolution: {integrity: sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==} dependencies: follow-redirects: 1.13.3 + transitivePeerDependencies: + - debug dev: false - resolution: - integrity: sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + /axobject-query/2.2.0: + resolution: {integrity: sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==} dev: true - resolution: - integrity: sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA== + + /b-tween/0.3.3: + resolution: {integrity: sha512-oEHegcRpA7fAuc9KC4nktucuZn2aS8htymCPcP3qkEGPqiBH+GfqtqoG2l7LxHngg6O0HFM7hOeOYExl1Oz4ZA==} + dev: false + + /b-validate/1.4.4: + resolution: {integrity: sha512-E2tnSnxxKDyxP1G+TMTbVHA8XajfHHOJKeWm9YVRISSPtzTL7ZP/7tIYp01b+O83L5R/6i31+Su+vCOJBnQWFQ==} + dev: false + /babel-eslint/10.1.0_eslint@7.23.0: + resolution: {integrity: sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==} + engines: {node: '>=6'} + deprecated: babel-eslint is now @babel/eslint-parser. This package will no longer receive updates. + peerDependencies: + eslint: '>= 4.12.1' dependencies: '@babel/code-frame': 7.12.13 '@babel/parser': 7.13.13 @@ -3523,23 +4269,22 @@ packages: eslint: 7.23.0 eslint-visitor-keys: 1.3.0 resolve: 1.18.1 - deprecated: babel-eslint is now @babel/eslint-parser. This package will no longer receive updates. + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=6' - peerDependencies: - eslint: '>= 4.12.1' - resolution: - integrity: sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg== + /babel-extract-comments/1.0.0: + resolution: {integrity: sha512-qWWzi4TlddohA91bFwgt6zO/J0X+io7Qp184Fw0m2JYRSTZnJbFR8+07KmzudHCZgOiKRCrjhylwv9Xd8gfhVQ==} + engines: {node: '>=4'} dependencies: babylon: 6.18.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-qWWzi4TlddohA91bFwgt6zO/J0X+io7Qp184Fw0m2JYRSTZnJbFR8+07KmzudHCZgOiKRCrjhylwv9Xd8gfhVQ== + /babel-jest/26.6.3_@babel+core@7.12.3: + resolution: {integrity: sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==} + engines: {node: '>= 10.14.2'} + peerDependencies: + '@babel/core': ^7.0.0 dependencies: '@babel/core': 7.12.3 '@jest/transform': 26.6.2 @@ -3550,14 +4295,16 @@ packages: chalk: 4.1.0 graceful-fs: 4.2.6 slash: 3.0.0 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>= 10.14.2' + + /babel-loader/8.1.0_427212bc1158d185e577033f19ca0757: + resolution: {integrity: sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw==} + engines: {node: '>= 6.9'} peerDependencies: '@babel/core': ^7.0.0 - resolution: - integrity: sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA== - /babel-loader/8.1.0_427212bc1158d185e577033f19ca0757: + webpack: '>=2' dependencies: '@babel/core': 7.12.3 find-cache-dir: 2.1.0 @@ -3567,89 +4314,98 @@ packages: schema-utils: 2.7.1 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>= 6.9' - peerDependencies: - '@babel/core': ^7.0.0 - webpack: '>=2' - resolution: - integrity: sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw== + /babel-plugin-dynamic-import-node/2.3.3: + resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} dependencies: object.assign: 4.1.2 dev: true - resolution: - integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ== + + /babel-plugin-import/1.13.6: + resolution: {integrity: sha512-N7FYnGh0DFsvDRkAPsvFq/metVfVD7P2h1rokOPpEH4cZbdRHCW+2jbXt0nnuqowkm/xhh2ww1anIdEpfYa7ZA==} + dependencies: + '@babel/helper-module-imports': 7.18.6 + dev: true + /babel-plugin-istanbul/6.0.0: + resolution: {integrity: sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ==} + engines: {node: '>=8'} dependencies: '@babel/helper-plugin-utils': 7.13.0 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 4.0.3 test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ== + /babel-plugin-jest-hoist/26.6.2: + resolution: {integrity: sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw==} + engines: {node: '>= 10.14.2'} dependencies: '@babel/template': 7.12.13 '@babel/types': 7.13.13 '@types/babel__core': 7.1.14 '@types/babel__traverse': 7.11.1 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw== + /babel-plugin-macros/2.8.0: + resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==} dependencies: '@babel/runtime': 7.12.1 cosmiconfig: 6.0.0 resolve: 1.18.1 dev: true - resolution: - integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg== + /babel-plugin-named-asset-import/0.3.7_@babel+core@7.12.3: + resolution: {integrity: sha512-squySRkf+6JGnvjoUtDEjSREJEBirnXi9NqP6rjSYsylxQxqBTz+pkmf395i9E2zsvmYUaI40BHo6SqZUdydlw==} + peerDependencies: + '@babel/core': ^7.1.0 dependencies: '@babel/core': 7.12.3 dev: true - peerDependencies: - '@babel/core': ^7.1.0 - resolution: - integrity: sha512-squySRkf+6JGnvjoUtDEjSREJEBirnXi9NqP6rjSYsylxQxqBTz+pkmf395i9E2zsvmYUaI40BHo6SqZUdydlw== + /babel-plugin-polyfill-corejs2/0.1.10_@babel+core@7.12.3: + resolution: {integrity: sha512-DO95wD4g0A8KRaHKi0D51NdGXzvpqVLnLu5BTvDlpqUEpTmeEtypgC1xqesORaWmiUOQI14UHKlzNd9iZ2G3ZA==} + peerDependencies: + '@babel/core': ^7.0.0-0 dependencies: '@babel/compat-data': 7.13.12 '@babel/core': 7.12.3 '@babel/helper-define-polyfill-provider': 0.1.5_@babel+core@7.12.3 semver: 6.3.0 + transitivePeerDependencies: + - supports-color dev: true + + /babel-plugin-polyfill-corejs3/0.1.7_@babel+core@7.12.3: + resolution: {integrity: sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-DO95wD4g0A8KRaHKi0D51NdGXzvpqVLnLu5BTvDlpqUEpTmeEtypgC1xqesORaWmiUOQI14UHKlzNd9iZ2G3ZA== - /babel-plugin-polyfill-corejs3/0.1.7_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-define-polyfill-provider': 0.1.5_@babel+core@7.12.3 core-js-compat: 3.9.1 + transitivePeerDependencies: + - supports-color dev: true + + /babel-plugin-polyfill-regenerator/0.1.6_@babel+core@7.12.3: + resolution: {integrity: sha512-OUrYG9iKPKz8NxswXbRAdSwF0GhRdIEMTloQATJi4bDuFqrXaXcCUT/VGNrr8pBcjMh1RxZ7Xt9cytVJTJfvMg==} peerDependencies: '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw== - /babel-plugin-polyfill-regenerator/0.1.6_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 '@babel/helper-define-polyfill-provider': 0.1.5_@babel+core@7.12.3 + transitivePeerDependencies: + - supports-color dev: true - peerDependencies: - '@babel/core': ^7.0.0-0 - resolution: - integrity: sha512-OUrYG9iKPKz8NxswXbRAdSwF0GhRdIEMTloQATJi4bDuFqrXaXcCUT/VGNrr8pBcjMh1RxZ7Xt9cytVJTJfvMg== + /babel-plugin-styled-components/1.12.0_styled-components@5.2.1: + resolution: {integrity: sha512-FEiD7l5ZABdJPpLssKXjBUJMYqzbcNzBowfXDCdJhOpbhWiewapUaY+LZGT8R4Jg2TwOjGjG4RKeyrO5p9sBkA==} + peerDependencies: + styled-components: '>= 2' dependencies: '@babel/helper-annotate-as-pure': 7.12.13 '@babel/helper-module-imports': 7.13.12 @@ -3657,30 +4413,30 @@ packages: lodash: 4.17.21 styled-components: 5.2.1_react-dom@17.0.2+react@17.0.2 dev: false - peerDependencies: - styled-components: '>= 2' - resolution: - integrity: sha512-FEiD7l5ZABdJPpLssKXjBUJMYqzbcNzBowfXDCdJhOpbhWiewapUaY+LZGT8R4Jg2TwOjGjG4RKeyrO5p9sBkA== + /babel-plugin-syntax-jsx/6.18.0: + resolution: {integrity: sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=} dev: false - resolution: - integrity: sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY= + /babel-plugin-syntax-object-rest-spread/6.13.0: + resolution: {integrity: sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=} dev: true - resolution: - integrity: sha1-/WU28rzhODb/o6VFjEkDpZe7O/U= + /babel-plugin-transform-object-rest-spread/6.26.0: + resolution: {integrity: sha1-DzZpLVD+9rfi1LOsFHgTepY7ewY=} dependencies: babel-plugin-syntax-object-rest-spread: 6.13.0 babel-runtime: 6.26.0 dev: true - resolution: - integrity: sha1-DzZpLVD+9rfi1LOsFHgTepY7ewY= + /babel-plugin-transform-react-remove-prop-types/0.4.24: + resolution: {integrity: sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==} dev: true - resolution: - integrity: sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== + /babel-preset-current-node-syntax/1.0.1_@babel+core@7.12.3: + resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} + peerDependencies: + '@babel/core': ^7.0.0 dependencies: '@babel/core': 7.12.3 '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.12.3 @@ -3696,23 +4452,20 @@ packages: '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.12.3 '@babel/plugin-syntax-top-level-await': 7.12.13_@babel+core@7.12.3 dev: true + + /babel-preset-jest/26.6.2_@babel+core@7.12.3: + resolution: {integrity: sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ==} + engines: {node: '>= 10.14.2'} peerDependencies: '@babel/core': ^7.0.0 - resolution: - integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== - /babel-preset-jest/26.6.2_@babel+core@7.12.3: dependencies: '@babel/core': 7.12.3 babel-plugin-jest-hoist: 26.6.2 babel-preset-current-node-syntax: 1.0.1_@babel+core@7.12.3 dev: true - engines: - node: '>= 10.14.2' - peerDependencies: - '@babel/core': ^7.0.0 - resolution: - integrity: sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ== + /babel-preset-react-app/10.0.0: + resolution: {integrity: sha512-itL2z8v16khpuKutx5IH8UdCdSTuzrOhRFTEdIhveZ2i1iBKDrVE0ATa4sFVy+02GLucZNVBWtoarXBy0Msdpg==} dependencies: '@babel/core': 7.12.3 '@babel/plugin-proposal-class-properties': 7.12.1_@babel+core@7.12.3 @@ -3729,24 +4482,32 @@ packages: '@babel/runtime': 7.12.1 babel-plugin-macros: 2.8.0 babel-plugin-transform-react-remove-prop-types: 0.4.24 + transitivePeerDependencies: + - supports-color dev: true - resolution: - integrity: sha512-itL2z8v16khpuKutx5IH8UdCdSTuzrOhRFTEdIhveZ2i1iBKDrVE0ATa4sFVy+02GLucZNVBWtoarXBy0Msdpg== + /babel-runtime/6.26.0: + resolution: {integrity: sha1-llxwWGaOgrVde/4E/yM3vItWR/4=} dependencies: core-js: 2.6.12 regenerator-runtime: 0.11.1 - resolution: - integrity: sha1-llxwWGaOgrVde/4E/yM3vItWR/4= - /babylon/6.18.0: dev: true + + /babylon/6.18.0: + resolution: {integrity: sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==} hasBin: true - resolution: - integrity: sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== + dev: true + + /bail/1.0.5: + resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==} + dev: true + /balanced-match/1.0.0: - resolution: - integrity: sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + resolution: {integrity: sha1-ibTRmasr7kneFk6gK4nORi1xt2c=} + /base/0.11.2: + resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==} + engines: {node: '>=0.10.0'} dependencies: cache-base: 1.0.1 class-utils: 0.3.6 @@ -3755,77 +4516,71 @@ packages: isobject: 3.0.1 mixin-deep: 1.3.2 pascalcase: 0.1.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + /base64-js/1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: true - resolution: - integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + /batch/0.6.1: + resolution: {integrity: sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=} dev: true - resolution: - integrity: sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY= + /bcrypt-pbkdf/1.0.2: + resolution: {integrity: sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=} dependencies: tweetnacl: 0.14.5 dev: true - resolution: - integrity: sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + /bfj/7.0.2: + resolution: {integrity: sha512-+e/UqUzwmzJamNF50tBV6tZPTORow7gQ96iFow+8b562OdMpEK0BcJEq2OSPEDmAbSMBQ7PKZ87ubFkgxpYWgw==} + engines: {node: '>= 8.0.0'} dependencies: bluebird: 3.7.2 check-types: 11.1.2 hoopy: 0.1.4 tryer: 1.0.1 dev: true - engines: - node: '>= 8.0.0' - resolution: - integrity: sha512-+e/UqUzwmzJamNF50tBV6tZPTORow7gQ96iFow+8b562OdMpEK0BcJEq2OSPEDmAbSMBQ7PKZ87ubFkgxpYWgw== + /big-integer/1.6.48: + resolution: {integrity: sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==} + engines: {node: '>=0.6'} dev: false - engines: - node: '>=0.6' - resolution: - integrity: sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w== + /big.js/5.2.2: - resolution: - integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + /binary-extensions/1.13.1: + resolution: {integrity: sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - optional: true - resolution: - integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + /bindings/1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} dependencies: file-uri-to-path: 1.0.0 dev: true optional: true - resolution: - integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + /bluebird/3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} dev: true - resolution: - integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + /bn.js/4.12.0: + resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==} dev: true - resolution: - integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== + /bn.js/5.2.0: + resolution: {integrity: sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==} dev: true - resolution: - integrity: sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw== + /body-parser/1.19.0: + resolution: {integrity: sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==} + engines: {node: '>= 0.8'} dependencies: bytes: 3.1.0 content-type: 1.0.4 @@ -3838,11 +4593,9 @@ packages: raw-body: 2.4.0 type-is: 1.6.18 dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== + /bonjour/3.5.0: + resolution: {integrity: sha1-jokKGD2O6aI5OzhExpGkK897yfU=} dependencies: array-flatten: 2.1.2 deep-equal: 1.1.1 @@ -3851,19 +4604,20 @@ packages: multicast-dns: 6.2.3 multicast-dns-service-types: 1.1.0 dev: true - resolution: - integrity: sha1-jokKGD2O6aI5OzhExpGkK897yfU= + /boolbase/1.0.0: + resolution: {integrity: sha1-aN/1++YMUes3cl6p4+0xDcwed24=} dev: true - resolution: - integrity: sha1-aN/1++YMUes3cl6p4+0xDcwed24= + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: balanced-match: 1.0.0 concat-map: 0.0.1 - resolution: - integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + /braces/2.3.2: + resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} + engines: {node: '>=0.10.0'} dependencies: arr-flatten: 1.1.0 array-unique: 0.3.2 @@ -3875,18 +4629,15 @@ packages: snapdragon-node: 2.1.1 split-string: 3.1.0 to-regex: 3.0.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} dependencies: fill-range: 7.0.1 - engines: - node: '>=8' - resolution: - integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + /broadcast-channel/3.5.3: + resolution: {integrity: sha512-OLOXfwReZa2AAAh9yOUyiALB3YxBe0QpThwwuyRHLgpl8bSznSDmV6Mz7LeBJg1VZsMcDcNMy7B53w12qHrIhQ==} dependencies: '@babel/runtime': 7.13.10 detect-node: 2.0.5 @@ -3896,17 +4647,17 @@ packages: rimraf: 3.0.2 unload: 2.2.0 dev: false - resolution: - integrity: sha512-OLOXfwReZa2AAAh9yOUyiALB3YxBe0QpThwwuyRHLgpl8bSznSDmV6Mz7LeBJg1VZsMcDcNMy7B53w12qHrIhQ== + /brorand/1.1.0: + resolution: {integrity: sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=} dev: true - resolution: - integrity: sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= + /browser-process-hrtime/1.0.0: + resolution: {integrity: sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==} dev: true - resolution: - integrity: sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== + /browserify-aes/1.2.0: + resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} dependencies: buffer-xor: 1.0.3 cipher-base: 1.0.4 @@ -3915,33 +4666,33 @@ packages: inherits: 2.0.4 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + /browserify-cipher/1.0.1: + resolution: {integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==} dependencies: browserify-aes: 1.2.0 browserify-des: 1.0.2 evp_bytestokey: 1.0.3 dev: true - resolution: - integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== + /browserify-des/1.0.2: + resolution: {integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==} dependencies: cipher-base: 1.0.4 des.js: 1.0.1 inherits: 2.0.4 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== + /browserify-rsa/4.1.0: + resolution: {integrity: sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==} dependencies: bn.js: 5.2.0 randombytes: 2.1.0 dev: true - resolution: - integrity: sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== + /browserify-sign/4.2.1: + resolution: {integrity: sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==} dependencies: bn.js: 5.2.0 browserify-rsa: 4.1.0 @@ -3953,27 +4704,28 @@ packages: readable-stream: 3.6.0 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg== + /browserify-zlib/0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} dependencies: pako: 1.0.11 dev: true - resolution: - integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== + /browserslist/4.14.2: + resolution: {integrity: sha512-HI4lPveGKUR0x2StIz+2FXfDk9SfVMrxn6PLh1JeGUwcuoDkdKZebWiyLRJ68iIPDpMI4JLVDf7S7XzslgWOhw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true dependencies: caniuse-lite: 1.0.30001204 electron-to-chromium: 1.3.701 escalade: 3.1.1 node-releases: 1.1.71 dev: false - engines: - node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 - hasBin: true - resolution: - integrity: sha512-HI4lPveGKUR0x2StIz+2FXfDk9SfVMrxn6PLh1JeGUwcuoDkdKZebWiyLRJ68iIPDpMI4JLVDf7S7XzslgWOhw== + /browserslist/4.16.3: + resolution: {integrity: sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true dependencies: caniuse-lite: 1.0.30001204 colorette: 1.2.2 @@ -3981,60 +4733,54 @@ packages: escalade: 3.1.1 node-releases: 1.1.71 dev: true - engines: - node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 - hasBin: true - resolution: - integrity: sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw== + /bser/2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} dependencies: node-int64: 0.4.0 dev: true - resolution: - integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + /buffer-from/1.1.1: + resolution: {integrity: sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==} dev: true - resolution: - integrity: sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + /buffer-indexof/1.1.1: + resolution: {integrity: sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==} dev: true - resolution: - integrity: sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== + /buffer-xor/1.0.3: + resolution: {integrity: sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=} dev: true - resolution: - integrity: sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= + /buffer/4.9.2: + resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==} dependencies: base64-js: 1.5.1 ieee754: 1.2.1 isarray: 1.0.0 dev: true - resolution: - integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== + /builtin-modules/3.2.0: + resolution: {integrity: sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA== + /builtin-status-codes/3.0.0: + resolution: {integrity: sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=} dev: true - resolution: - integrity: sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= + /bytes/3.0.0: + resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=} + engines: {node: '>= 0.8'} dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= + /bytes/3.1.0: + resolution: {integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==} + engines: {node: '>= 0.8'} dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== + /cacache/12.0.4: + resolution: {integrity: sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==} dependencies: bluebird: 3.7.2 chownr: 1.1.4 @@ -4052,9 +4798,10 @@ packages: unique-filename: 1.1.1 y18n: 4.0.1 dev: true - resolution: - integrity: sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ== + /cacache/15.0.6: + resolution: {integrity: sha512-g1WYDMct/jzW+JdWEyjaX2zoBkZ6ZT9VpOyp2I/VMtDsNLffNat3kqPFfi1eDRSK9/SuKGyORDHcQMcPF8sQ/w==} + engines: {node: '>= 10'} dependencies: '@npmcli/move-file': 1.1.2 chownr: 2.0.0 @@ -4074,11 +4821,10 @@ packages: tar: 6.1.0 unique-filename: 1.1.1 dev: true - engines: - node: '>= 10' - resolution: - integrity: sha512-g1WYDMct/jzW+JdWEyjaX2zoBkZ6ZT9VpOyp2I/VMtDsNLffNat3kqPFfi1eDRSK9/SuKGyORDHcQMcPF8sQ/w== + /cache-base/1.0.1: + resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==} + engines: {node: '>=0.10.0'} dependencies: collection-visit: 1.0.0 component-emitter: 1.3.0 @@ -4089,147 +4835,158 @@ packages: to-object-path: 0.3.0 union-value: 1.0.1 unset-value: 1.0.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + /call-bind/1.0.2: + resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: function-bind: 1.1.1 get-intrinsic: 1.1.1 - dev: true - resolution: - integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + /caller-callsite/2.0.0: + resolution: {integrity: sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=} + engines: {node: '>=4'} dependencies: callsites: 2.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= + /caller-path/2.0.0: + resolution: {integrity: sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=} + engines: {node: '>=4'} dependencies: caller-callsite: 2.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= + /callsites/2.0.0: + resolution: {integrity: sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + /callsites/3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - /camel-case/3.0.0: - dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 - dev: false - resolution: - integrity: sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M= + /camel-case/4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} dependencies: pascal-case: 3.1.2 - tslib: 2.1.0 + tslib: 2.4.1 + + /camelcase-css/2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} dev: true - resolution: - integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== + /camelcase/5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + /camelcase/6.2.0: + resolution: {integrity: sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== + /camelize/1.0.0: + resolution: {integrity: sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=} dev: false - resolution: - integrity: sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= + /caniuse-api/3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} dependencies: browserslist: 4.16.3 caniuse-lite: 1.0.30001204 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 dev: true - resolution: - integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== + /caniuse-lite/1.0.30001204: - resolution: - integrity: sha512-JUdjWpcxfJ9IPamy2f5JaRDCaqJOxDzOSKtbdx4rH9VivMd1vIzoPumsJa9LoMIi4Fx2BV2KZOxWhNkBjaYivQ== + resolution: {integrity: sha512-JUdjWpcxfJ9IPamy2f5JaRDCaqJOxDzOSKtbdx4rH9VivMd1vIzoPumsJa9LoMIi4Fx2BV2KZOxWhNkBjaYivQ==} + /capture-exit/2.0.0: + resolution: {integrity: sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==} + engines: {node: 6.* || 8.* || >= 10.*} dependencies: rsvp: 4.8.5 dev: true - engines: - node: 6.* || 8.* || >= 10.* - resolution: - integrity: sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g== + /case-sensitive-paths-webpack-plugin/2.3.0: + resolution: {integrity: sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ== + /caseless/0.12.0: + resolution: {integrity: sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=} + dev: true + + /ccount/1.1.0: + resolution: {integrity: sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==} dev: true - resolution: - integrity: sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + /chalk/2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} dependencies: ansi-styles: 3.2.1 escape-string-regexp: 1.0.5 supports-color: 5.5.0 - engines: - node: '>=4' - resolution: - integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + /chalk/3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + /chalk/4.1.0: + resolution: {integrity: sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /chalk/4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== + /char-regex/1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== - /chart.js/3.2.1: - dev: false - resolution: - integrity: sha512-XsNDf3854RGZkLCt+5vWAXGAtUdKP2nhfikLGZqud6G4CvRE2ts64TIxTTfspOin2kEZvPgomE29E6oU02dYjQ== + + /character-entities-html4/1.1.4: + resolution: {integrity: sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g==} + dev: true + + /character-entities-legacy/1.1.4: + resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} + dev: true + + /character-entities/1.2.4: + resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} + dev: true + + /character-reference-invalid/1.1.4: + resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} + dev: true + + /chart.js/3.5.0: + resolution: {integrity: sha512-J1a4EAb1Gi/KbhwDRmoovHTRuqT8qdF0kZ4XgwxpGethJHUdDrkqyPYwke0a+BuvSeUxPf8Cos6AX2AB8H8GLA==} + dev: true + /check-types/11.1.2: + resolution: {integrity: sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ==} dev: true - resolution: - integrity: sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ== + /chokidar/2.1.8: + resolution: {integrity: sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==} + deprecated: Chokidar 2 will break on node v14+. Upgrade to chokidar 3 with 15x less dependencies. dependencies: anymatch: 2.0.0 async-each: 1.0.3 @@ -4242,13 +4999,13 @@ packages: path-is-absolute: 1.0.1 readdirp: 2.2.1 upath: 1.2.0 - deprecated: Chokidar 2 will break on node v14+. Upgrade to chokidar 3 with 15x less dependencies. - dev: true optionalDependencies: fsevents: 1.2.13 - resolution: - integrity: sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== + dev: true + /chokidar/3.5.1: + resolution: {integrity: sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==} + engines: {node: '>= 8.10.0'} dependencies: anymatch: 3.1.1 braces: 3.0.2 @@ -4257,247 +5014,271 @@ packages: is-glob: 4.0.1 normalize-path: 3.0.0 readdirp: 3.5.0 + optionalDependencies: + fsevents: 2.3.2 dev: true - engines: - node: '>= 8.10.0' - optional: true + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.1 + normalize-path: 3.0.0 + readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.2 - resolution: - integrity: sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== + dev: true + optional: true + /chownr/1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} dev: true - resolution: - integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + /chownr/2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + /chrome-trace-event/1.0.2: + resolution: {integrity: sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==} + engines: {node: '>=6.0'} dependencies: tslib: 1.14.1 dev: true - engines: - node: '>=6.0' - resolution: - integrity: sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ== + /ci-info/2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} dev: true - resolution: - integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + /cipher-base/1.0.4: + resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==} dependencies: inherits: 2.0.4 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + /cjs-module-lexer/0.6.0: + resolution: {integrity: sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==} dev: true - resolution: - integrity: sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw== + /class-utils/0.3.6: + resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==} + engines: {node: '>=0.10.0'} dependencies: arr-union: 3.1.0 define-property: 0.2.5 isobject: 3.0.1 static-extend: 0.1.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + /classcat/5.0.3: + resolution: {integrity: sha512-6dK2ke4VEJZOFx2ZfdDAl5OhEL8lvkl6EHF92IfRePfHxQTqir5NlcNVUv+2idjDqCX2NDc8m8YSAI5NI975ZQ==} dev: false - resolution: - integrity: sha512-6dK2ke4VEJZOFx2ZfdDAl5OhEL8lvkl6EHF92IfRePfHxQTqir5NlcNVUv+2idjDqCX2NDc8m8YSAI5NI975ZQ== + /classnames/2.2.6: + resolution: {integrity: sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==} + + /classnames/2.3.1: + resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==} dev: false - resolution: - integrity: sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== + /clean-css/4.2.3: + resolution: {integrity: sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==} + engines: {node: '>= 4.0'} dependencies: source-map: 0.6.1 dev: true - engines: - node: '>= 4.0' - resolution: - integrity: sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA== + /clean-stack/2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + /cli-cursor/3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} dependencies: restore-cursor: 3.1.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + /cli-truncate/2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} dependencies: slice-ansi: 3.0.0 string-width: 4.2.2 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== + + /clipboardy/2.3.0: + resolution: {integrity: sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ==} + engines: {node: '>=8'} + dependencies: + arch: 2.2.0 + execa: 1.0.0 + is-wsl: 2.2.0 + dev: true + /cliui/5.0.0: + resolution: {integrity: sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==} dependencies: string-width: 3.1.0 strip-ansi: 5.2.0 wrap-ansi: 5.1.0 dev: true - resolution: - integrity: sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + /cliui/6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} dependencies: string-width: 4.2.2 strip-ansi: 6.0.0 wrap-ansi: 6.2.0 dev: true - resolution: - integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + /clone-deep/4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} dependencies: is-plain-object: 2.0.4 kind-of: 6.0.3 shallow-clone: 3.0.1 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== + /co/4.6.0: + resolution: {integrity: sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} dev: true - engines: - iojs: '>= 1.0.0' - node: '>= 0.12.0' - resolution: - integrity: sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + /coa/2.0.2: + resolution: {integrity: sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==} + engines: {node: '>= 4.0'} dependencies: '@types/q': 1.5.4 chalk: 2.4.2 q: 1.5.1 dev: true - engines: - node: '>= 4.0' - resolution: - integrity: sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA== + /collect-v8-coverage/1.0.1: + resolution: {integrity: sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==} dev: true - resolution: - integrity: sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg== + /collection-visit/1.0.0: + resolution: {integrity: sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=} + engines: {node: '>=0.10.0'} dependencies: map-visit: 1.0.0 object-visit: 1.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + /color-convert/1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: color-name: 1.1.3 - resolution: - integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + /color-convert/2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} dependencies: color-name: 1.1.4 dev: true - engines: - node: '>=7.0.0' - resolution: - integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + /color-name/1.1.3: - resolution: - integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + /color-name/1.1.4: - dev: true - resolution: - integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + /color-string/1.5.5: + resolution: {integrity: sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==} dependencies: color-name: 1.1.4 simple-swizzle: 0.2.2 dev: true - resolution: - integrity: sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg== + + /color-string/1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + dev: false + /color/3.1.3: + resolution: {integrity: sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==} dependencies: color-convert: 1.9.3 color-string: 1.5.5 dev: true - resolution: - integrity: sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ== + + /color/3.2.1: + resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + dependencies: + color-convert: 1.9.3 + color-string: 1.9.1 + dev: false + /colorette/1.2.2: + resolution: {integrity: sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==} dev: true - resolution: - integrity: sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== + /combined-stream/1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} dependencies: delayed-stream: 1.0.0 dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + + /comma-separated-tokens/1.0.8: + resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} + dev: true + /commander/2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} dev: true - resolution: - integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + /commander/4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + /commander/6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + /commander/7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} dev: true - engines: - node: '>= 10' - resolution: - integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + /common-tags/1.8.0: + resolution: {integrity: sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==} + engines: {node: '>=4.0.0'} dev: true - engines: - node: '>=4.0.0' - resolution: - integrity: sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw== + /commondir/1.0.1: + resolution: {integrity: sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=} dev: true - resolution: - integrity: sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + /component-emitter/1.3.0: - resolution: - integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==} + /compose-function/3.0.3: + resolution: {integrity: sha1-ntZ18TzFRQHTCVCkhv9qe6OrGF8=} dependencies: arity-n: 1.0.4 dev: true - resolution: - integrity: sha1-ntZ18TzFRQHTCVCkhv9qe6OrGF8= + /compressible/2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} dependencies: mime-db: 1.46.0 dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + /compression/1.7.4: + resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==} + engines: {node: '>= 0.8.0'} dependencies: accepts: 1.3.7 bytes: 3.0.0 @@ -4507,97 +5288,89 @@ packages: safe-buffer: 5.1.2 vary: 1.1.2 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== - /compute-scroll-into-view/1.0.17: + + /compute-scroll-into-view/1.0.11: + resolution: {integrity: sha512-uUnglJowSe0IPmWOdDtrlHXof5CTIJitfJEyITHBW6zDVOGu9Pjk5puaLM73SLcwak0L4hEjO7Td88/a6P5i7A==} dev: false - resolution: - integrity: sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg== + + /compute-scroll-into-view/1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + dev: false + /concat-map/0.0.1: - resolution: - integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + /concat-stream/1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} dependencies: buffer-from: 1.1.1 inherits: 2.0.4 readable-stream: 2.3.7 typedarray: 0.0.6 dev: true - engines: - '0': node >= 0.8 - resolution: - integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + /confusing-browser-globals/1.0.10: + resolution: {integrity: sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==} dev: true - resolution: - integrity: sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA== + /connect-history-api-fallback/1.6.0: + resolution: {integrity: sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==} + engines: {node: '>=0.8'} dev: true - engines: - node: '>=0.8' - resolution: - integrity: sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== + /console-browserify/1.2.0: + resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==} dev: true - resolution: - integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== + /constants-browserify/1.0.0: + resolution: {integrity: sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=} dev: true - resolution: - integrity: sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= + /contains-path/0.1.0: + resolution: {integrity: sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= + /content-disposition/0.5.3: + resolution: {integrity: sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==} + engines: {node: '>= 0.6'} dependencies: safe-buffer: 5.1.2 dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== + /content-type/1.0.4: + resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== + /convert-source-map/0.3.5: + resolution: {integrity: sha1-8dgClQr33SYxof6+BZZVDIarMZA=} dev: true - resolution: - integrity: sha1-8dgClQr33SYxof6+BZZVDIarMZA= + /convert-source-map/1.7.0: + resolution: {integrity: sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==} dependencies: safe-buffer: 5.1.2 dev: true - resolution: - integrity: sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== + /cookie-signature/1.0.6: + resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=} dev: true - resolution: - integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw= + /cookie/0.4.0: + resolution: {integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== - /cool-path/0.1.32: - dev: false - resolution: - integrity: sha512-u8xxIqRoOP12uML/H3prx6kOhg+XxyIumz7nn6Kn2k/6sZ8BD4YyomPGGiiZhk0OObjY7QijmZJPskzUaSA8GA== + /copy-anything/2.0.3: + resolution: {integrity: sha512-GK6QUtisv4fNS+XcI7shX0Gx9ORg7QqIznyfho79JTnX1XhLiyZHfftvGiziqzRiEi/Bjhgpi+D2o7HxJFPnDQ==} dependencies: is-what: 3.14.1 dev: true - resolution: - integrity: sha512-GK6QUtisv4fNS+XcI7shX0Gx9ORg7QqIznyfho79JTnX1XhLiyZHfftvGiziqzRiEi/Bjhgpi+D2o7HxJFPnDQ== + /copy-concurrently/1.0.5: + resolution: {integrity: sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==} dependencies: aproba: 1.2.0 fs-write-stream-atomic: 1.0.10 @@ -4606,57 +5379,67 @@ packages: rimraf: 2.7.1 run-queue: 1.0.3 dev: true - resolution: - integrity: sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A== + /copy-descriptor/0.1.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + resolution: {integrity: sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=} + engines: {node: '>=0.10.0'} + + /copy-text-to-clipboard/2.2.0: + resolution: {integrity: sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ==} + engines: {node: '>=6'} + dev: true + /copy-to-clipboard/3.3.1: + resolution: {integrity: sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw==} dependencies: toggle-selection: 1.0.6 dev: false - resolution: - integrity: sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw== + /core-js-compat/3.9.1: + resolution: {integrity: sha512-jXAirMQxrkbiiLsCx9bQPJFA6llDadKMpYrBJQJ3/c4/vsPP/fAf29h24tviRlvwUL6AmY5CHLu2GvjuYviQqA==} dependencies: browserslist: 4.16.3 semver: 7.0.0 dev: true - resolution: - integrity: sha512-jXAirMQxrkbiiLsCx9bQPJFA6llDadKMpYrBJQJ3/c4/vsPP/fAf29h24tviRlvwUL6AmY5CHLu2GvjuYviQqA== + /core-js-pure/3.9.1: - dev: true + resolution: {integrity: sha512-laz3Zx0avrw9a4QEIdmIblnVuJz8W51leY9iLThatCsFawWxC3sE4guASC78JbCin+DkwMpCdp1AVAuzL/GN7A==} requiresBuild: true - resolution: - integrity: sha512-laz3Zx0avrw9a4QEIdmIblnVuJz8W51leY9iLThatCsFawWxC3sE4guASC78JbCin+DkwMpCdp1AVAuzL/GN7A== + dev: true + /core-js/2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} deprecated: core-js@<3 is no longer maintained and not recommended for usage due to the number of issues. Please, upgrade your dependencies to the actual version of core-js@3. requiresBuild: true - resolution: - integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== + dev: true + + /core-js/3.6.5: + resolution: {integrity: sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==} + requiresBuild: true + dev: true + /core-js/3.9.1: - dev: false + resolution: {integrity: sha512-gSjRvzkxQc1zjM/5paAmL4idJBFzuJoo+jDjF1tStYFMV2ERfD02HhahhCGXUyHxQRG4yFKVSdO6g62eoRMcDg==} requiresBuild: true - resolution: - integrity: sha512-gSjRvzkxQc1zjM/5paAmL4idJBFzuJoo+jDjF1tStYFMV2ERfD02HhahhCGXUyHxQRG4yFKVSdO6g62eoRMcDg== + dev: false + /core-util-is/1.0.2: + resolution: {integrity: sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=} dev: true - resolution: - integrity: sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + /cosmiconfig/5.2.1: + resolution: {integrity: sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==} + engines: {node: '>=4'} dependencies: import-fresh: 2.0.0 is-directory: 0.3.1 js-yaml: 3.14.1 parse-json: 4.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== + /cosmiconfig/6.0.0: + resolution: {integrity: sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==} + engines: {node: '>=8'} dependencies: '@types/parse-json': 4.0.0 import-fresh: 3.3.0 @@ -4664,11 +5447,10 @@ packages: path-type: 4.0.0 yaml: 1.10.2 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== + /cosmiconfig/7.0.0: + resolution: {integrity: sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==} + engines: {node: '>=10'} dependencies: '@types/parse-json': 4.0.0 import-fresh: 3.3.0 @@ -4676,18 +5458,16 @@ packages: path-type: 4.0.0 yaml: 1.10.2 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA== + /create-ecdh/4.0.4: + resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} dependencies: bn.js: 4.12.0 elliptic: 6.5.4 dev: true - resolution: - integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A== + /create-hash/1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} dependencies: cipher-base: 1.0.4 inherits: 2.0.4 @@ -4695,9 +5475,9 @@ packages: ripemd160: 2.0.2 sha.js: 2.4.11 dev: true - resolution: - integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + /create-hmac/1.1.7: + resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} dependencies: cipher-base: 1.0.4 create-hash: 1.2.0 @@ -4706,9 +5486,18 @@ packages: safe-buffer: 5.2.1 sha.js: 2.4.11 dev: true - resolution: - integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + + /cross-env/7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + dependencies: + cross-spawn: 7.0.3 + dev: true + /cross-spawn/6.0.5: + resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==} + engines: {node: '>=4.8'} dependencies: nice-try: 1.0.5 path-key: 2.0.1 @@ -4716,20 +5505,17 @@ packages: shebang-command: 1.2.0 which: 1.3.1 dev: true - engines: - node: '>=4.8' - resolution: - integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + /cross-spawn/7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - engines: - node: '>= 8' - resolution: - integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + /crypto-browserify/3.12.0: + resolution: {integrity: sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==} dependencies: browserify-cipher: 1.0.1 browserify-sign: 4.2.1 @@ -4743,60 +5529,58 @@ packages: randombytes: 2.1.0 randomfill: 1.0.4 dev: true - resolution: - integrity: sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== + /crypto-random-string/1.0.0: + resolution: {integrity: sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= + /css-blank-pseudo/0.1.4: + resolution: {integrity: sha512-LHz35Hr83dnFeipc7oqFDmsjHdljj3TQtxGGiNWSOsTLIAubSm4TEz8qCaKFpk7idaQ1GfWscF4E6mgpBysA1w==} + engines: {node: '>=6.0.0'} + hasBin: true dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - hasBin: true - resolution: - integrity: sha512-LHz35Hr83dnFeipc7oqFDmsjHdljj3TQtxGGiNWSOsTLIAubSm4TEz8qCaKFpk7idaQ1GfWscF4E6mgpBysA1w== + /css-color-keywords/1.0.0: + resolution: {integrity: sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU=} + engines: {node: '>=4'} dev: false - engines: - node: '>=4' - resolution: - integrity: sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU= + /css-color-names/0.0.4: + resolution: {integrity: sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=} dev: true - resolution: - integrity: sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= + /css-declaration-sorter/4.0.1: + resolution: {integrity: sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA==} + engines: {node: '>4'} dependencies: postcss: 7.0.35 timsort: 0.3.0 dev: true - engines: - node: '>4' - resolution: - integrity: sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA== + /css-has-pseudo/0.10.0: + resolution: {integrity: sha512-Z8hnfsZu4o/kt+AuFzeGpLVhFOGO9mluyHBaA2bA8aCGTwah5sT3WV/fTHH8UNZUytOIImuGPrl/prlb4oX4qQ==} + engines: {node: '>=6.0.0'} + hasBin: true dependencies: postcss: 7.0.35 postcss-selector-parser: 5.0.0 dev: true - engines: - node: '>=6.0.0' - hasBin: true - resolution: - integrity: sha512-Z8hnfsZu4o/kt+AuFzeGpLVhFOGO9mluyHBaA2bA8aCGTwah5sT3WV/fTHH8UNZUytOIImuGPrl/prlb4oX4qQ== + /css-in-js-utils/2.0.1: + resolution: {integrity: sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==} dependencies: hyphenate-style-name: 1.0.4 isobject: 3.0.1 dev: false - resolution: - integrity: sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA== + /css-loader/4.3.0_webpack@4.44.2: + resolution: {integrity: sha512-rdezjCjScIrsL8BSYszgT4s476IcNKt6yX69t0pHjJVnPUTDpn4WfIpDQTN3wCJvUvfsz/mFjuGOekf3PY3NUg==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.27.0 || ^5.0.0 dependencies: camelcase: 6.2.0 cssesc: 3.0.0 @@ -4812,105 +5596,96 @@ packages: semver: 7.3.2 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>= 10.13.0' - peerDependencies: - webpack: ^4.27.0 || ^5.0.0 - resolution: - integrity: sha512-rdezjCjScIrsL8BSYszgT4s476IcNKt6yX69t0pHjJVnPUTDpn4WfIpDQTN3wCJvUvfsz/mFjuGOekf3PY3NUg== + /css-prefers-color-scheme/3.1.1: + resolution: {integrity: sha512-MTu6+tMs9S3EUqzmqLXEcgNRbNkkD/TGFvowpeoWJn5Vfq7FMgsmRQs9X5NXAURiOBmOxm/lLjsDNXDE6k9bhg==} + engines: {node: '>=6.0.0'} + hasBin: true dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - hasBin: true - resolution: - integrity: sha512-MTu6+tMs9S3EUqzmqLXEcgNRbNkkD/TGFvowpeoWJn5Vfq7FMgsmRQs9X5NXAURiOBmOxm/lLjsDNXDE6k9bhg== + /css-select-base-adapter/0.1.1: + resolution: {integrity: sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==} dev: true - resolution: - integrity: sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== + /css-select/2.1.0: + resolution: {integrity: sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==} dependencies: boolbase: 1.0.0 css-what: 3.4.2 domutils: 1.7.0 nth-check: 1.0.2 dev: true - resolution: - integrity: sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ== + /css-to-react-native/3.0.0: + resolution: {integrity: sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==} dependencies: camelize: 1.0.0 css-color-keywords: 1.0.0 postcss-value-parser: 4.1.0 dev: false - resolution: - integrity: sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ== + /css-tree/1.0.0-alpha.37: + resolution: {integrity: sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==} + engines: {node: '>=8.0.0'} dependencies: mdn-data: 2.0.4 source-map: 0.6.1 dev: true - engines: - node: '>=8.0.0' - resolution: - integrity: sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg== + /css-tree/1.1.2: + resolution: {integrity: sha512-wCoWush5Aeo48GLhfHPbmvZs59Z+M7k5+B1xDnXbdWNcEF423DoFdqSWE0PM5aNk5nI5cp1q7ms36zGApY/sKQ==} + engines: {node: '>=8.0.0'} dependencies: mdn-data: 2.0.14 source-map: 0.6.1 - engines: - node: '>=8.0.0' - resolution: - integrity: sha512-wCoWush5Aeo48GLhfHPbmvZs59Z+M7k5+B1xDnXbdWNcEF423DoFdqSWE0PM5aNk5nI5cp1q7ms36zGApY/sKQ== + /css-what/3.4.2: + resolution: {integrity: sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==} + engines: {node: '>= 6'} dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== + /css.escape/1.5.1: + resolution: {integrity: sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=} dev: true - resolution: - integrity: sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= + /css/2.2.4: + resolution: {integrity: sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==} dependencies: inherits: 2.0.4 source-map: 0.6.1 source-map-resolve: 0.5.3 urix: 0.1.0 dev: true - resolution: - integrity: sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw== + /css/3.0.0: + resolution: {integrity: sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==} dependencies: inherits: 2.0.4 source-map: 0.6.1 source-map-resolve: 0.6.0 dev: true - resolution: - integrity: sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== + /cssdb/4.4.0: + resolution: {integrity: sha512-LsTAR1JPEM9TpGhl/0p3nQecC2LJ0kD8X5YARu1hk/9I1gril5vDtMZyNxcEpxxDj34YNck/ucjuoUd66K03oQ==} dev: true - resolution: - integrity: sha512-LsTAR1JPEM9TpGhl/0p3nQecC2LJ0kD8X5YARu1hk/9I1gril5vDtMZyNxcEpxxDj34YNck/ucjuoUd66K03oQ== + /cssesc/2.0.0: - dev: true - engines: - node: '>=4' + resolution: {integrity: sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg==} + engines: {node: '>=4'} hasBin: true - resolution: - integrity: sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg== - /cssesc/3.0.0: dev: true - engines: - node: '>=4' + + /cssesc/3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} hasBin: true - resolution: - integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + dev: true + /cssnano-preset-default/4.0.7: + resolution: {integrity: sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA==} + engines: {node: '>=6.9.0'} dependencies: css-declaration-sorter: 4.0.1 cssnano-util-raw-cache: 4.0.1 @@ -4943,119 +5718,116 @@ packages: postcss-svgo: 4.0.2 postcss-unique-selectors: 4.0.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA== + /cssnano-util-get-arguments/4.0.0: + resolution: {integrity: sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8=} + engines: {node: '>=6.9.0'} dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= + /cssnano-util-get-match/4.0.0: + resolution: {integrity: sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0=} + engines: {node: '>=6.9.0'} dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= + /cssnano-util-raw-cache/4.0.1: + resolution: {integrity: sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA== + /cssnano-util-same-parent/4.0.1: + resolution: {integrity: sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q==} + engines: {node: '>=6.9.0'} dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== + /cssnano/4.1.10: + resolution: {integrity: sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ==} + engines: {node: '>=6.9.0'} dependencies: cosmiconfig: 5.2.1 cssnano-preset-default: 4.0.7 is-resolvable: 1.1.0 postcss: 7.0.35 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ== + /csso/4.2.0: + resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} + engines: {node: '>=8.0.0'} dependencies: css-tree: 1.1.2 dev: true - engines: - node: '>=8.0.0' - resolution: - integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== + /cssom/0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} dev: true - resolution: - integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== + /cssom/0.4.4: + resolution: {integrity: sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==} dev: true - resolution: - integrity: sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== + /cssstyle/2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} dependencies: cssom: 0.3.8 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== + /csstype/3.0.7: - resolution: - integrity: sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g== + resolution: {integrity: sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g==} + + /csstype/3.1.1: + resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==} + dev: false + /cyclist/1.0.1: + resolution: {integrity: sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=} dev: true - resolution: - integrity: sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= + /d/1.0.1: + resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==} dependencies: es5-ext: 0.10.53 type: 1.2.0 dev: true - resolution: - integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== + /d3-color/2.0.0: + resolution: {integrity: sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==} dev: false - resolution: - integrity: sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== + /d3-dispatch/2.0.0: + resolution: {integrity: sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==} dev: false - resolution: - integrity: sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA== + /d3-drag/2.0.0: + resolution: {integrity: sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==} dependencies: d3-dispatch: 2.0.0 d3-selection: 2.0.0 dev: false - resolution: - integrity: sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w== + /d3-ease/2.0.0: + resolution: {integrity: sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==} dev: false - resolution: - integrity: sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ== + /d3-interpolate/2.0.1: + resolution: {integrity: sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==} dependencies: d3-color: 2.0.0 dev: false - resolution: - integrity: sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ== + /d3-selection/2.0.0: + resolution: {integrity: sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==} dev: false - resolution: - integrity: sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA== + /d3-timer/2.0.0: + resolution: {integrity: sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==} dev: false - resolution: - integrity: sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA== + /d3-transition/2.0.0_d3-selection@2.0.0: + resolution: {integrity: sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==} + peerDependencies: + d3-selection: '2' dependencies: d3-color: 2.0.0 d3-dispatch: 2.0.0 @@ -5064,11 +5836,9 @@ packages: d3-selection: 2.0.0 d3-timer: 2.0.0 dev: false - peerDependencies: - d3-selection: '2' - resolution: - integrity: sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog== + /d3-zoom/2.0.0: + resolution: {integrity: sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==} dependencies: d3-dispatch: 2.0.0 d3-drag: 2.0.0 @@ -5076,112 +5846,119 @@ packages: d3-selection: 2.0.0 d3-transition: 2.0.0_d3-selection@2.0.0 dev: false - resolution: - integrity: sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw== + /damerau-levenshtein/1.0.6: + resolution: {integrity: sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug==} dev: true - resolution: - integrity: sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug== + /dashdash/1.14.1: + resolution: {integrity: sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=} + engines: {node: '>=0.10'} dependencies: assert-plus: 1.0.0 dev: true - engines: - node: '>=0.10' - resolution: - integrity: sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + /data-urls/2.0.0: + resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==} + engines: {node: '>=10'} dependencies: abab: 2.0.5 whatwg-mimetype: 2.3.0 whatwg-url: 8.5.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== - /date-fns/2.19.0: + + /dayjs/1.10.8: + resolution: {integrity: sha512-wbNwDfBHHur9UOzNUjeKUOJ0fCb0a52Wx0xInmQ7Y8FstyajiV1NmK1e00cxsr9YrE9r7yAChE0VvpuY5Rnlow==} dev: false - engines: - node: '>=0.11' - resolution: - integrity: sha512-X3bf2iTPgCAQp9wvjOQytnf5vO5rESYRXlPIVcgSbtT5OTScPcsf9eZU+B/YIkKAtYr5WeCii58BgATrNitlWg== - /dayjs/1.10.4: + + /dayjs/1.11.7: + resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==} dev: false - resolution: - integrity: sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== + + /debounce-promise/3.1.2: + resolution: {integrity: sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg==} + dev: false + /debug/2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} dependencies: ms: 2.0.0 - resolution: - integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + /debug/3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} dependencies: ms: 2.1.3 dev: true - resolution: - integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + /debug/4.3.1: + resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: ms: 2.1.2 dev: true - engines: - node: '>=6.0' + + /debug/4.3.1_supports-color@5.5.0: + resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} + engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - resolution: - integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== - /debug/4.3.1_supports-color@5.5.0: dependencies: ms: 2.1.2 supports-color: 5.5.0 dev: false - engines: - node: '>=6.0' + + /debug/4.3.1_supports-color@6.1.0: + resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} + engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - resolution: - integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== - /debug/4.3.1_supports-color@6.1.0: dependencies: ms: 2.1.2 supports-color: 6.1.0 dev: true - engines: - node: '>=6.0' + + /debug/4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} peerDependencies: supports-color: '*' peerDependenciesMeta: supports-color: optional: true - resolution: - integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms: 2.1.2 + dev: true + /decamelize/1.2.0: + resolution: {integrity: sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + /decimal.js/10.2.1: + resolution: {integrity: sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==} dev: true - resolution: - integrity: sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw== + /decode-uri-component/0.2.0: - engines: - node: '>=0.10' - resolution: - integrity: sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + resolution: {integrity: sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=} + engines: {node: '>=0.10'} + /dedent/0.7.0: + resolution: {integrity: sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=} dev: true - resolution: - integrity: sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= + /deep-equal/1.1.1: + resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==} dependencies: is-arguments: 1.1.0 is-date-object: 1.0.2 @@ -5190,58 +5967,58 @@ packages: object-keys: 1.1.1 regexp.prototype.flags: 1.3.1 dev: true - resolution: - integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== + /deep-is/0.1.3: + resolution: {integrity: sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=} + dev: true + + /deepmerge/1.5.2: + resolution: {integrity: sha512-95k0GDqvBjZavkuvzx/YqVLv/6YYa17fz6ILMSf7neqQITCPbnfEnQvEgMPNjH4kgobe7+WIL0yJEHku+H3qtQ==} + engines: {node: '>=0.10.0'} dev: true - resolution: - integrity: sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + /deepmerge/4.2.2: + resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== + /default-gateway/4.2.0: + resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==} + engines: {node: '>=6'} dependencies: execa: 1.0.0 ip-regex: 2.1.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA== + /define-properties/1.1.3: + resolution: {integrity: sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==} + engines: {node: '>= 0.4'} dependencies: object-keys: 1.1.1 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + /define-property/0.2.5: + resolution: {integrity: sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=} + engines: {node: '>=0.10.0'} dependencies: is-descriptor: 0.1.6 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + /define-property/1.0.0: + resolution: {integrity: sha1-dp66rz9KY6rTr56NMEybvnm/sOY=} + engines: {node: '>=0.10.0'} dependencies: is-descriptor: 1.0.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + /define-property/2.0.2: + resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==} + engines: {node: '>=0.10.0'} dependencies: is-descriptor: 1.0.2 isobject: 3.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + /del/4.1.1: + resolution: {integrity: sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==} + engines: {node: '>=6'} dependencies: '@types/glob': 7.1.3 globby: 6.1.0 @@ -5251,232 +6028,264 @@ packages: pify: 4.0.1 rimraf: 2.7.1 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ== + /delayed-stream/1.0.0: + resolution: {integrity: sha1-3zrhmayt+31ECqrgsp4icrJOxhk=} + engines: {node: '>=0.4.0'} dev: true - engines: - node: '>=0.4.0' - resolution: - integrity: sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + /depd/1.1.2: + resolution: {integrity: sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + /des.js/1.0.1: + resolution: {integrity: sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==} dependencies: inherits: 2.0.4 minimalistic-assert: 1.0.1 dev: true - resolution: - integrity: sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA== + /destroy/1.0.4: + resolution: {integrity: sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=} dev: true - resolution: - integrity: sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= + /detect-newline/3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + + /detect-node-es/1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dev: false + /detect-node/2.0.5: - resolution: - integrity: sha512-qi86tE6hRcFHy8jI1m2VG+LaPUR1LhqDa5G8tVjuUXmOrpuAgqsA1pN0+ldgr3aKUH+QLI9hCY/OcRYisERejw== + resolution: {integrity: sha512-qi86tE6hRcFHy8jI1m2VG+LaPUR1LhqDa5G8tVjuUXmOrpuAgqsA1pN0+ldgr3aKUH+QLI9hCY/OcRYisERejw==} + /detect-port-alt/1.1.6: + resolution: {integrity: sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==} + engines: {node: '>= 4.2.1'} + hasBin: true dependencies: address: 1.1.2 debug: 2.6.9 dev: false - engines: - node: '>= 4.2.1' - hasBin: true - resolution: - integrity: sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q== + /diff-sequences/26.6.2: + resolution: {integrity: sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==} + engines: {node: '>= 10.14.2'} dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== + /diffie-hellman/5.0.3: + resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} dependencies: bn.js: 4.12.0 miller-rabin: 4.0.1 randombytes: 2.1.0 dev: true - resolution: - integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== + /dir-glob/3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} dependencies: path-type: 4.0.0 - engines: - node: '>=8' - resolution: - integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + /dns-equal/1.0.0: + resolution: {integrity: sha1-s55/HabrCnW6nBcySzR1PEfgZU0=} dev: true - resolution: - integrity: sha1-s55/HabrCnW6nBcySzR1PEfgZU0= + /dns-packet/1.3.1: + resolution: {integrity: sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg==} dependencies: ip: 1.1.5 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg== + /dns-txt/2.0.2: + resolution: {integrity: sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY=} dependencies: buffer-indexof: 1.1.1 dev: true - resolution: - integrity: sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY= + /doctrine/1.5.0: + resolution: {integrity: sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=} + engines: {node: '>=0.10.0'} dependencies: esutils: 2.0.3 isarray: 1.0.0 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-N53Ocw9hZvds76TmcHoVmwLFpvo= + /doctrine/2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} dependencies: esutils: 2.0.3 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + /doctrine/3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} dependencies: esutils: 2.0.3 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + /dom-accessibility-api/0.5.4: + resolution: {integrity: sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==} dev: true - resolution: - integrity: sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ== + /dom-align/1.12.0: - dev: false - resolution: - integrity: sha512-YkoezQuhp3SLFGdOlr5xkqZ640iXrnHAwVYcDg8ZKRUtO7mSzSC2BA5V0VuyAwPSJA4CLIc6EDDJh4bEsD2+zA== + resolution: {integrity: sha512-YkoezQuhp3SLFGdOlr5xkqZ640iXrnHAwVYcDg8ZKRUtO7mSzSC2BA5V0VuyAwPSJA4CLIc6EDDJh4bEsD2+zA==} + /dom-converter/0.2.0: + resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} dependencies: utila: 0.4.0 dev: true - resolution: - integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== + + /dom-helpers/5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dependencies: + '@babel/runtime': 7.20.13 + csstype: 3.1.1 + dev: false + /dom-serializer/0.2.2: + resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} dependencies: domelementtype: 2.1.0 entities: 2.2.0 dev: true - resolution: - integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g== + + /dom-walk/0.1.2: + resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} + dev: true + /domain-browser/1.2.0: + resolution: {integrity: sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==} + engines: {node: '>=0.4', npm: '>=1.2'} dev: true - engines: - node: '>=0.4' - npm: '>=1.2' - resolution: - integrity: sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== + /domelementtype/1.3.1: + resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} dev: true - resolution: - integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + /domelementtype/2.1.0: + resolution: {integrity: sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w==} dev: true - resolution: - integrity: sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== + /domexception/2.0.1: + resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==} + engines: {node: '>=8'} dependencies: webidl-conversions: 5.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== + /domhandler/2.4.2: + resolution: {integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==} dependencies: domelementtype: 1.3.1 dev: true - resolution: - integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + /domutils/1.7.0: + resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==} dependencies: dom-serializer: 0.2.2 domelementtype: 1.3.1 dev: true - resolution: - integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + /dot-case/3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: no-case: 3.0.4 - tslib: 2.1.0 - dev: true - resolution: - integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== + tslib: 2.4.1 + /dot-prop/5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} dependencies: is-obj: 2.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== + /dotenv-expand/5.1.0: + resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==} dev: true - resolution: - integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== + /dotenv/8.2.0: + resolution: {integrity: sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==} + engines: {node: '>=8'} + dev: true + + /dumi-assets-types/1.0.0: + resolution: {integrity: sha512-7nhSeWM15vybbUAMPLZsdls2jKoHB2UU4P1RM6kLPucuS8eC/HSmufquFqTTYtX4oIDLHGtil/dVtMreNGwhdA==} + dev: true + + /dumi-theme-default/1.1.13_ac48d56268a7095d7c6000b1357273b0: + resolution: {integrity: sha512-vTjjzcfVko4EslgiEcEAECi/MLas35/oD/Sfs7Eehn10SbzX4DhtEQwwMZVzoc+nuStPmKXsczYAtiWndX6Aig==} + peerDependencies: + '@umijs/preset-dumi': 1.x + react: ^16.13.1 || ^17.0.0 + dependencies: + '@umijs/preset-dumi': 1.1.30_b08c95616290592113c9128c4b0c3f8f + prism-react-renderer: 1.2.1_react@17.0.2 + prismjs: 1.25.0 + rc-tabs: 11.7.3_react-dom@17.0.2+react@17.0.2 + react: 17.0.2 + transitivePeerDependencies: + - react-dom dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== + + /dumi/1.1.30_ab15ddca82409ccfb2f88ffa3dfddc1b: + resolution: {integrity: sha512-qJz4S/K/Ghkmwt8fejFeRYOhRy/98vOL555XuxbV1X03KFfWNWYJR9s2/CNt9vWvW2iunAnZOqp2QCRjgpPRQg==} + hasBin: true + dependencies: + '@umijs/preset-dumi': 1.1.30_b08c95616290592113c9128c4b0c3f8f + umi: 3.5.20_react-router@5.2.0 + transitivePeerDependencies: + - bufferutil + - canvas + - react + - react-dom + - react-router + - supports-color + - typescript + - utf-8-validate + dev: true + /duplexer/0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} dev: false - resolution: - integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== + /duplexify/3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} dependencies: end-of-stream: 1.4.4 inherits: 2.0.4 readable-stream: 2.3.7 stream-shift: 1.0.1 dev: true - resolution: - integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g== + /ecc-jsbn/0.1.2: + resolution: {integrity: sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=} dependencies: jsbn: 0.1.1 safer-buffer: 2.1.2 dev: true - resolution: - integrity: sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + /ee-first/1.1.1: + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} dev: true - resolution: - integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + /ejs/2.7.4: - dev: true - engines: - node: '>=0.10.0' + resolution: {integrity: sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==} + engines: {node: '>=0.10.0'} requiresBuild: true - resolution: - integrity: sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== + dev: true + /electron-to-chromium/1.3.701: - resolution: - integrity: sha512-Zd9ofdIMYHYhG1gvnejQDvC/kqSeXQvtXF0yRURGxgwGqDZm9F9Fm3dYFnm5gyuA7xpXfBlzVLN1sz0FjxpKfw== + resolution: {integrity: sha512-Zd9ofdIMYHYhG1gvnejQDvC/kqSeXQvtXF0yRURGxgwGqDZm9F9Fm3dYFnm5gyuA7xpXfBlzVLN1sz0FjxpKfw==} + /elliptic/6.5.4: + resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} dependencies: bn.js: 4.12.0 brorand: 1.1.0 @@ -5486,110 +6295,103 @@ packages: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 dev: true - resolution: - integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== + /emittery/0.7.2: + resolution: {integrity: sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ== + /emoji-regex/7.0.3: + resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==} dev: true - resolution: - integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + /emoji-regex/8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: true - resolution: - integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + /emoji-regex/9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} dev: true - resolution: - integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + /emojis-list/2.1.0: + resolution: {integrity: sha1-TapNnbAPmBmIDHn6RXrlsJof04k=} + engines: {node: '>= 0.10'} dev: true - engines: - node: '>= 0.10' - resolution: - integrity: sha1-TapNnbAPmBmIDHn6RXrlsJof04k= + /emojis-list/3.0.0: - engines: - node: '>= 4' - resolution: - integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} + engines: {node: '>= 4'} + /encodeurl/1.0.2: + resolution: {integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=} + engines: {node: '>= 0.8'} dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + /end-of-stream/1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} dependencies: once: 1.4.0 dev: true - resolution: - integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + /enhanced-resolve/4.5.0: + resolution: {integrity: sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==} + engines: {node: '>=6.9.0'} dependencies: graceful-fs: 4.2.6 memory-fs: 0.5.0 tapable: 1.1.3 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg== + /enhanced-resolve/5.7.0: + resolution: {integrity: sha512-6njwt/NsZFUKhM6j9U8hzVyD4E4r0x7NQzhTCbcWOJ0IQjNSAoalWmb0AE51Wn+fwan5qVESWi7t2ToBxs9vrw==} + engines: {node: '>=10.13.0'} dependencies: graceful-fs: 4.2.6 tapable: 2.2.0 dev: true - engines: - node: '>=10.13.0' - resolution: - integrity: sha512-6njwt/NsZFUKhM6j9U8hzVyD4E4r0x7NQzhTCbcWOJ0IQjNSAoalWmb0AE51Wn+fwan5qVESWi7t2ToBxs9vrw== + /enquirer/2.3.6: + resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} + engines: {node: '>=8.6'} dependencies: ansi-colors: 4.1.1 dev: true - engines: - node: '>=8.6' - resolution: - integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== + /entities/1.1.2: + resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==} dev: true - resolution: - integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + /entities/2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} dev: true - resolution: - integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + /envinfo/7.7.4: - dev: true - engines: - node: '>=4' + resolution: {integrity: sha512-TQXTYFVVwwluWSFis6K2XKxgrD22jEv0FTuLCQI+OjH7rn93+iY0fSSFM5lrSxFY+H1+B0/cvvlamr3UsBivdQ==} + engines: {node: '>=4'} hasBin: true - resolution: - integrity: sha512-TQXTYFVVwwluWSFis6K2XKxgrD22jEv0FTuLCQI+OjH7rn93+iY0fSSFM5lrSxFY+H1+B0/cvvlamr3UsBivdQ== + dev: true + /errno/0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true dependencies: prr: 1.0.1 dev: true - hasBin: true - resolution: - integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== + /error-ex/1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: is-arrayish: 0.2.1 dev: true - resolution: - integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + /error-stack-parser/2.0.6: + resolution: {integrity: sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==} dependencies: stackframe: 1.2.0 - resolution: - integrity: sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ== + /es-abstract/1.18.0: + resolution: {integrity: sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 es-to-primitive: 1.2.1 @@ -5608,103 +6410,100 @@ packages: string.prototype.trimstart: 1.0.4 unbox-primitive: 1.0.1 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw== + + /es-module-lexer/0.7.1: + resolution: {integrity: sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw==} + dev: true + /es-to-primitive/1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} dependencies: is-callable: 1.2.3 is-date-object: 1.0.2 is-symbol: 1.0.3 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + /es5-ext/0.10.53: + resolution: {integrity: sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==} dependencies: es6-iterator: 2.0.3 es6-symbol: 3.1.3 next-tick: 1.0.0 dev: true - resolution: - integrity: sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q== + + /es5-imcompatible-versions/0.1.73: + resolution: {integrity: sha512-P0SgLrYl9iqlrt0h6n/iz5z5P1uuhnfHp9BA/tcLfqgVIWHNvY4Rm+jtSvnh1ADK4DJOYDwJvxlrHMRoLQMgmQ==} + dev: true + /es6-iterator/2.0.3: + resolution: {integrity: sha1-p96IkUGgWpSwhUQDstCg+/qY87c=} dependencies: d: 1.0.1 es5-ext: 0.10.53 es6-symbol: 3.1.3 dev: true - resolution: - integrity: sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + /es6-symbol/3.1.3: + resolution: {integrity: sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==} dependencies: d: 1.0.1 ext: 1.4.0 dev: true - resolution: - integrity: sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA== + + /esbuild/0.12.15: + resolution: {integrity: sha512-72V4JNd2+48eOVCXx49xoSWHgC3/cCy96e7mbXKY+WOWghN00cCmlGnwVLRhRHorvv0dgCyuMYBZlM2xDM5OQw==} + hasBin: true + requiresBuild: true + dev: true + /escalade/3.1.1: - engines: - node: '>=6' - resolution: - integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + /escape-html/1.0.3: + resolution: {integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=} dev: true - resolution: - integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + /escape-string-regexp/1.0.5: - engines: - node: '>=0.8.0' - resolution: - integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + /escape-string-regexp/2.0.0: - engines: - node: '>=8' - resolution: - integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + /escape-string-regexp/4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + dev: true + /escodegen/2.0.0: + resolution: {integrity: sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==} + engines: {node: '>=6.0'} + hasBin: true dependencies: esprima: 4.0.1 estraverse: 5.2.0 esutils: 2.0.3 optionator: 0.8.3 - dev: true - engines: - node: '>=6.0' - hasBin: true optionalDependencies: source-map: 0.6.1 - resolution: - integrity: sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== - /eslint-config-prettier/6.15.0_eslint@7.23.0: - dependencies: - eslint: 7.23.0 - get-stdin: 6.0.0 dev: true + + /eslint-config-prettier/6.15.0_eslint@7.23.0: + resolution: {integrity: sha512-a1+kOYLR8wMGustcgAjdydMsQ2A/2ipRPwRKUmfYaSxc9ZPcrku080Ctl6zrZzZNs/U82MjSv+qKREkoq3bJaw==} hasBin: true peerDependencies: eslint: '>=3.14.1' - resolution: - integrity: sha512-a1+kOYLR8wMGustcgAjdydMsQ2A/2ipRPwRKUmfYaSxc9ZPcrku080Ctl6zrZzZNs/U82MjSv+qKREkoq3bJaw== - /eslint-config-react-app/6.0.0_2fb64cc94b95fae32741d239fe65ddca: dependencies: - '@typescript-eslint/eslint-plugin': 4.19.0_821acdc8bc493ad1aa2628c9b724d688 - '@typescript-eslint/parser': 4.19.0_eslint@7.23.0+typescript@4.2.3 - babel-eslint: 10.1.0_eslint@7.23.0 - confusing-browser-globals: 1.0.10 eslint: 7.23.0 - eslint-plugin-flowtype: 5.4.0_eslint@7.23.0 - eslint-plugin-import: 2.22.1_eslint@7.23.0 - eslint-plugin-jest: 24.3.2_c42078cdfffa5b71bbb788f736a64691 - eslint-plugin-jsx-a11y: 6.4.1_eslint@7.23.0 - eslint-plugin-react: 7.23.1_eslint@7.23.0 - eslint-plugin-react-hooks: 4.2.0_eslint@7.23.0 - eslint-plugin-testing-library: 3.10.2_eslint@7.23.0+typescript@4.2.3 + get-stdin: 6.0.0 dev: true - engines: - node: ^10.12.0 || >=12.0.0 + + /eslint-config-react-app/6.0.0_2fb64cc94b95fae32741d239fe65ddca: + resolution: {integrity: sha512-bpoAAC+YRfzq0dsTk+6v9aHm/uqnDwayNAXleMypGl6CpxI9oXXscVHo4fk3eJPIn+rsbtNetB4r/ZIidFIE8A==} + engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: '@typescript-eslint/eslint-plugin': ^4.0.0 '@typescript-eslint/parser': ^4.0.0 @@ -5722,37 +6521,52 @@ packages: optional: true eslint-plugin-testing-library: optional: true - resolution: - integrity: sha512-bpoAAC+YRfzq0dsTk+6v9aHm/uqnDwayNAXleMypGl6CpxI9oXXscVHo4fk3eJPIn+rsbtNetB4r/ZIidFIE8A== + dependencies: + '@typescript-eslint/eslint-plugin': 4.19.0_821acdc8bc493ad1aa2628c9b724d688 + '@typescript-eslint/parser': 4.19.0_eslint@7.23.0+typescript@4.2.3 + babel-eslint: 10.1.0_eslint@7.23.0 + confusing-browser-globals: 1.0.10 + eslint: 7.23.0 + eslint-plugin-flowtype: 5.4.0_eslint@7.23.0 + eslint-plugin-import: 2.22.1_eslint@7.23.0 + eslint-plugin-jest: 24.3.2_c42078cdfffa5b71bbb788f736a64691 + eslint-plugin-jsx-a11y: 6.4.1_eslint@7.23.0 + eslint-plugin-react: 7.23.1_eslint@7.23.0 + eslint-plugin-react-hooks: 4.2.0_eslint@7.23.0 + eslint-plugin-testing-library: 3.10.2_eslint@7.23.0+typescript@4.2.3 + dev: true + /eslint-import-resolver-node/0.3.4: + resolution: {integrity: sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==} dependencies: debug: 2.6.9 resolve: 1.18.1 dev: true - resolution: - integrity: sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA== + /eslint-module-utils/2.6.0: + resolution: {integrity: sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==} + engines: {node: '>=4'} dependencies: debug: 2.6.9 pkg-dir: 2.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA== + /eslint-plugin-flowtype/5.4.0_eslint@7.23.0: + resolution: {integrity: sha512-O0s0iTT5UxYuoOpHMLSIO2qZMyvrb9shhk1EM5INNGtJ2CffrfUmsnh6TVsnoT41fkXIEndP630WNovhoO87xQ==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + eslint: ^7.1.0 dependencies: eslint: 7.23.0 lodash: 4.17.21 string-natural-compare: 3.0.1 dev: true - engines: - node: ^10.12.0 || >=12.0.0 - peerDependencies: - eslint: ^7.1.0 - resolution: - integrity: sha512-O0s0iTT5UxYuoOpHMLSIO2qZMyvrb9shhk1EM5INNGtJ2CffrfUmsnh6TVsnoT41fkXIEndP630WNovhoO87xQ== + /eslint-plugin-import/2.22.1_eslint@7.23.0: + resolution: {integrity: sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 dependencies: array-includes: 3.1.3 array.prototype.flat: 1.2.4 @@ -5769,30 +6583,30 @@ packages: resolve: 1.18.1 tsconfig-paths: 3.9.0 dev: true - engines: - node: '>=4' - peerDependencies: - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 - resolution: - integrity: sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw== + /eslint-plugin-jest/24.3.2_c42078cdfffa5b71bbb788f736a64691: - dependencies: - '@typescript-eslint/eslint-plugin': 4.19.0_821acdc8bc493ad1aa2628c9b724d688 - '@typescript-eslint/experimental-utils': 4.19.0_eslint@7.23.0+typescript@4.2.3 - eslint: 7.23.0 - dev: true - engines: - node: '>=10' + resolution: {integrity: sha512-cicWDr+RvTAOKS3Q/k03+Z3odt3VCiWamNUHWd6QWbVQWcYJyYgUTu8x0mx9GfeDEimawU5kQC+nQ3MFxIM6bw==} + engines: {node: '>=10'} peerDependencies: '@typescript-eslint/eslint-plugin': '>= 4' eslint: '>=5' - typescript: '*' peerDependenciesMeta: '@typescript-eslint/eslint-plugin': optional: true - resolution: - integrity: sha512-cicWDr+RvTAOKS3Q/k03+Z3odt3VCiWamNUHWd6QWbVQWcYJyYgUTu8x0mx9GfeDEimawU5kQC+nQ3MFxIM6bw== + dependencies: + '@typescript-eslint/eslint-plugin': 4.19.0_821acdc8bc493ad1aa2628c9b724d688 + '@typescript-eslint/experimental-utils': 4.19.0_eslint@7.23.0+typescript@4.2.3 + eslint: 7.23.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /eslint-plugin-jsx-a11y/6.4.1_eslint@7.23.0: + resolution: {integrity: sha512-0rGPJBbwHoGNPU73/QCLP/vveMlM1b1Z9PponxO87jfr6tuH5ligXbDT6nHSSzBC8ovX2Z+BQu7Bk5D/Xgq9zg==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 dependencies: '@babel/runtime': 7.13.10 aria-query: 4.2.2 @@ -5807,21 +6621,10 @@ packages: jsx-ast-utils: 3.2.0 language-tags: 1.0.5 dev: true - engines: - node: '>=4.0' - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 - resolution: - integrity: sha512-0rGPJBbwHoGNPU73/QCLP/vveMlM1b1Z9PponxO87jfr6tuH5ligXbDT6nHSSzBC8ovX2Z+BQu7Bk5D/Xgq9zg== + /eslint-plugin-prettier/3.3.1_9b658f06c6707d3d08f34c93bae76087: - dependencies: - eslint: 7.23.0 - eslint-config-prettier: 6.15.0_eslint@7.23.0 - prettier: 2.2.1 - prettier-linter-helpers: 1.0.0 - dev: true - engines: - node: '>=6.0.0' + resolution: {integrity: sha512-Rq3jkcFY8RYeQLgk2cCwuc0P7SEFwDravPhsJZOQ5N4YI4DSg50NyqJ/9gdZHzQlHf8MvafSesbNJCcP/FF6pQ==} + engines: {node: '>=6.0.0'} peerDependencies: eslint: '>=5.0.0' eslint-config-prettier: '*' @@ -5829,19 +6632,27 @@ packages: peerDependenciesMeta: eslint-config-prettier: optional: true - resolution: - integrity: sha512-Rq3jkcFY8RYeQLgk2cCwuc0P7SEFwDravPhsJZOQ5N4YI4DSg50NyqJ/9gdZHzQlHf8MvafSesbNJCcP/FF6pQ== - /eslint-plugin-react-hooks/4.2.0_eslint@7.23.0: dependencies: eslint: 7.23.0 + eslint-config-prettier: 6.15.0_eslint@7.23.0 + prettier: 2.2.1 + prettier-linter-helpers: 1.0.0 dev: true - engines: - node: '>=10' + + /eslint-plugin-react-hooks/4.2.0_eslint@7.23.0: + resolution: {integrity: sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==} + engines: {node: '>=10'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - resolution: - integrity: sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ== + dependencies: + eslint: 7.23.0 + dev: true + /eslint-plugin-react/7.23.1_eslint@7.23.0: + resolution: {integrity: sha512-MvFGhZjI8Z4HusajmSw0ougGrq3Gs4vT/0WgwksZgf5RrLrRa2oYAw56okU4tZJl8+j7IYNuTM+2RnFEuTSdRQ==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 dependencies: array-includes: 3.1.3 array.prototype.flatmap: 1.2.4 @@ -5857,64 +6668,59 @@ packages: resolve: 2.0.0-next.3 string.prototype.matchall: 4.0.4 dev: true - engines: - node: '>=4' - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 - resolution: - integrity: sha512-MvFGhZjI8Z4HusajmSw0ougGrq3Gs4vT/0WgwksZgf5RrLrRa2oYAw56okU4tZJl8+j7IYNuTM+2RnFEuTSdRQ== + /eslint-plugin-testing-library/3.10.2_eslint@7.23.0+typescript@4.2.3: + resolution: {integrity: sha512-WAmOCt7EbF1XM8XfbCKAEzAPnShkNSwcIsAD2jHdsMUT9mZJPjLCG7pMzbcC8kK366NOuGip8HKLDC+Xk4yIdA==} + engines: {node: ^10.12.0 || >=12.0.0, npm: '>=6'} + peerDependencies: + eslint: ^5 || ^6 || ^7 dependencies: '@typescript-eslint/experimental-utils': 3.10.1_eslint@7.23.0+typescript@4.2.3 eslint: 7.23.0 + transitivePeerDependencies: + - supports-color + - typescript dev: true - engines: - node: ^10.12.0 || >=12.0.0 - npm: '>=6' - peerDependencies: - eslint: ^5 || ^6 || ^7 - typescript: '*' - resolution: - integrity: sha512-WAmOCt7EbF1XM8XfbCKAEzAPnShkNSwcIsAD2jHdsMUT9mZJPjLCG7pMzbcC8kK366NOuGip8HKLDC+Xk4yIdA== + /eslint-scope/4.0.3: + resolution: {integrity: sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==} + engines: {node: '>=4.0.0'} dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 dev: true - engines: - node: '>=4.0.0' - resolution: - integrity: sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== + /eslint-scope/5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 dev: true - engines: - node: '>=8.0.0' - resolution: - integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + /eslint-utils/2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} dependencies: eslint-visitor-keys: 1.3.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== + /eslint-visitor-keys/1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== + /eslint-visitor-keys/2.0.0: + resolution: {integrity: sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== + /eslint-webpack-plugin/2.5.3_eslint@7.23.0+webpack@4.44.2: + resolution: {integrity: sha512-LewNevZf9ghDCxCGT6QltNWVi8KIYWc4LKcin8K9Azh1hypG7YAmobUDIU67fAPa+eMjRnU4rjEkLbYI1w5/UA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + eslint: ^7.0.0 + webpack: ^4.0.0 || ^5.0.0 dependencies: '@types/eslint': 7.2.7 arrify: 2.0.1 @@ -5924,14 +6730,11 @@ packages: schema-utils: 3.0.0 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>= 10.13.0' - peerDependencies: - eslint: ^7.0.0 - webpack: ^4.0.0 || ^5.0.0 - resolution: - integrity: sha512-LewNevZf9ghDCxCGT6QltNWVi8KIYWc4LKcin8K9Azh1hypG7YAmobUDIU67fAPa+eMjRnU4rjEkLbYI1w5/UA== + /eslint/7.23.0: + resolution: {integrity: sha512-kqvNVbdkjzpFy0XOszNwjkKzZ+6TcwCQ/h+ozlcIWwaimBBuhlQ4nN6kbiM2L+OjDcznkTJxzYfRFH92sx4a0Q==} + engines: {node: ^10.12.0 || >=12.0.0} + hasBin: true dependencies: '@babel/code-frame': 7.12.11 '@eslint/eslintrc': 0.4.0 @@ -5970,107 +6773,102 @@ packages: table: 6.0.8 text-table: 0.2.0 v8-compile-cache: 2.3.0 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: ^10.12.0 || >=12.0.0 - hasBin: true - resolution: - integrity: sha512-kqvNVbdkjzpFy0XOszNwjkKzZ+6TcwCQ/h+ozlcIWwaimBBuhlQ4nN6kbiM2L+OjDcznkTJxzYfRFH92sx4a0Q== + + /esm/3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + dev: true + /espree/7.3.1: + resolution: {integrity: sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==} + engines: {node: ^10.12.0 || >=12.0.0} dependencies: acorn: 7.4.1 acorn-jsx: 5.3.1_acorn@7.4.1 eslint-visitor-keys: 1.3.0 dev: true - engines: - node: ^10.12.0 || >=12.0.0 - resolution: - integrity: sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== + /esprima/4.0.1: - dev: true - engines: - node: '>=4' + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} hasBin: true - resolution: - integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + dev: true + /esquery/1.4.0: + resolution: {integrity: sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==} + engines: {node: '>=0.10'} dependencies: estraverse: 5.2.0 dev: true - engines: - node: '>=0.10' - resolution: - integrity: sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== + /esrecurse/4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} dependencies: estraverse: 5.2.0 dev: true - engines: - node: '>=4.0' - resolution: - integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + /estraverse/4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} dev: true - engines: - node: '>=4.0' - resolution: - integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + /estraverse/5.2.0: + resolution: {integrity: sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==} + engines: {node: '>=4.0'} dev: true - engines: - node: '>=4.0' - resolution: - integrity: sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== + /estree-walker/0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} dev: true - resolution: - integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== + /estree-walker/1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} dev: true - resolution: - integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== + /esutils/2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + /etag/1.8.1: + resolution: {integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + /eventemitter3/4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} dev: true - resolution: - integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + /events/3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} dev: true - engines: - node: '>=0.8.x' - resolution: - integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + /eventsource/1.1.0: + resolution: {integrity: sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==} + engines: {node: '>=0.12.0'} dependencies: original: 1.0.2 dev: true - engines: - node: '>=0.12.0' - resolution: - integrity: sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg== + /evp_bytestokey/1.0.3: + resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} dependencies: md5.js: 1.3.5 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + /exec-sh/0.3.6: + resolution: {integrity: sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==} dev: true - resolution: - integrity: sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w== + /execa/1.0.0: + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} dependencies: cross-spawn: 6.0.5 get-stream: 4.1.0 @@ -6080,11 +6878,10 @@ packages: signal-exit: 3.0.3 strip-eof: 1.0.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + /execa/4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} dependencies: cross-spawn: 7.0.3 get-stream: 5.2.0 @@ -6096,11 +6893,10 @@ packages: signal-exit: 3.0.3 strip-final-newline: 2.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== + /execa/5.0.0: + resolution: {integrity: sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==} + engines: {node: '>=10'} dependencies: cross-spawn: 7.0.3 get-stream: 6.0.0 @@ -6112,17 +6908,15 @@ packages: signal-exit: 3.0.3 strip-final-newline: 2.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ== + /exit/0.1.2: + resolution: {integrity: sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=} + engines: {node: '>= 0.8.0'} dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= + /expand-brackets/2.1.4: + resolution: {integrity: sha1-t3c14xXOMPa27/D4OwQVGiJEliI=} + engines: {node: '>=0.10.0'} dependencies: debug: 2.6.9 define-property: 0.2.5 @@ -6131,11 +6925,10 @@ packages: regex-not: 1.0.2 snapdragon: 0.8.2 to-regex: 3.0.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + /expect/26.6.2: + resolution: {integrity: sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 ansi-styles: 4.3.0 @@ -6144,11 +6937,10 @@ packages: jest-message-util: 26.6.2 jest-regex-util: 26.0.0 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA== + /express/4.17.1: + resolution: {integrity: sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==} + engines: {node: '>= 0.10.0'} dependencies: accepts: 1.3.7 array-flatten: 1.1.1 @@ -6181,36 +6973,33 @@ packages: utils-merge: 1.0.1 vary: 1.1.2 dev: true - engines: - node: '>= 0.10.0' - resolution: - integrity: sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== + /ext/1.4.0: + resolution: {integrity: sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==} dependencies: type: 2.5.0 dev: true - resolution: - integrity: sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A== + /extend-shallow/2.0.1: + resolution: {integrity: sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=} + engines: {node: '>=0.10.0'} dependencies: is-extendable: 0.1.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + /extend-shallow/3.0.2: + resolution: {integrity: sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=} + engines: {node: '>=0.10.0'} dependencies: assign-symbols: 1.0.0 is-extendable: 1.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + /extend/3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} dev: true - resolution: - integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + /extglob/2.0.4: + resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==} + engines: {node: '>=0.10.0'} dependencies: array-unique: 0.3.2 define-property: 1.0.0 @@ -6220,24 +7009,22 @@ packages: regex-not: 1.0.2 snapdragon: 0.8.2 to-regex: 3.0.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + /extsprintf/1.3.0: + resolution: {integrity: sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=} + engines: {'0': node >=0.6.0} dev: true - engines: - '0': node >=0.6.0 - resolution: - integrity: sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + /fast-deep-equal/3.1.3: - resolution: - integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + /fast-diff/1.2.0: + resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==} dev: true - resolution: - integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== + /fast-glob/3.2.5: + resolution: {integrity: sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==} + engines: {node: '>=8'} dependencies: '@nodelib/fs.stat': 2.0.4 '@nodelib/fs.walk': 1.2.6 @@ -6245,118 +7032,124 @@ packages: merge2: 1.4.1 micromatch: 4.0.2 picomatch: 2.2.2 - engines: - node: '>=8' - resolution: - integrity: sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg== + /fast-json-stable-stringify/2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true - resolution: - integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + /fast-levenshtein/2.0.6: + resolution: {integrity: sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=} dev: true - resolution: - integrity: sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + + /fast-memoize/2.5.2: + resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==} + dev: false + /fast-shallow-equal/1.0.0: + resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==} dev: false - resolution: - integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== + /fastest-levenshtein/1.0.12: + resolution: {integrity: sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==} dev: true - resolution: - integrity: sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow== + /fastest-stable-stringify/2.0.2: + resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} dev: false - resolution: - integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q== + /fastq/1.11.0: + resolution: {integrity: sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==} dependencies: reusify: 1.0.4 - resolution: - integrity: sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g== + + /fault/1.0.4: + resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} + dependencies: + format: 0.2.2 + dev: true + /faye-websocket/0.10.0: + resolution: {integrity: sha1-TkkvjQTftviQA1B/btvy1QHnxvQ=} + engines: {node: '>=0.4.0'} dependencies: websocket-driver: 0.6.5 dev: true - engines: - node: '>=0.4.0' - resolution: - integrity: sha1-TkkvjQTftviQA1B/btvy1QHnxvQ= + /faye-websocket/0.11.3: + resolution: {integrity: sha512-D2y4bovYpzziGgbHYtGCMjlJM36vAl/y+xUyn1C+FVx8szd1E+86KwVw6XvYSzOP8iMpm1X0I4xJD+QtUb36OA==} + engines: {node: '>=0.8.0'} dependencies: websocket-driver: 0.7.4 dev: true - engines: - node: '>=0.8.0' - resolution: - integrity: sha512-D2y4bovYpzziGgbHYtGCMjlJM36vAl/y+xUyn1C+FVx8szd1E+86KwVw6XvYSzOP8iMpm1X0I4xJD+QtUb36OA== + /fb-watchman/2.0.1: + resolution: {integrity: sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==} dependencies: bser: 2.1.1 dev: true - resolution: - integrity: sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg== + /figgy-pudding/3.5.2: + resolution: {integrity: sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==} dev: true - resolution: - integrity: sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== + /figures/3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} dependencies: escape-string-regexp: 1.0.5 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + /file-entry-cache/6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} dependencies: flat-cache: 3.0.4 dev: true - engines: - node: ^10.12.0 || >=12.0.0 - resolution: - integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + /file-loader/6.1.1_webpack@4.44.2: + resolution: {integrity: sha512-Klt8C4BjWSXYQAfhpYYkG4qHNTna4toMHEbWrI5IuVoxbU6uiDKeKAP99R8mmbJi3lvewn/jQBOgU4+NS3tDQw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 dependencies: loader-utils: 2.0.0 schema-utils: 3.0.0 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>= 10.13.0' - peerDependencies: - webpack: ^4.0.0 || ^5.0.0 - resolution: - integrity: sha512-Klt8C4BjWSXYQAfhpYYkG4qHNTna4toMHEbWrI5IuVoxbU6uiDKeKAP99R8mmbJi3lvewn/jQBOgU4+NS3tDQw== + /file-uri-to-path/1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} dev: true optional: true - resolution: - integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + /filesize/6.1.0: + resolution: {integrity: sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==} + engines: {node: '>= 0.4.0'} dev: false - engines: - node: '>= 0.4.0' - resolution: - integrity: sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg== + /fill-range/4.0.0: + resolution: {integrity: sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=} + engines: {node: '>=0.10.0'} dependencies: extend-shallow: 2.0.1 is-number: 3.0.0 repeat-string: 1.6.1 to-regex-range: 2.1.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - engines: - node: '>=8' - resolution: - integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + + /filter-obj/1.1.0: + resolution: {integrity: sha1-mzERErxsYSehbgFsbF1/GeCAXFs=} + engines: {node: '>=0.10.0'} + dev: true + /finalhandler/1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} dependencies: debug: 2.6.9 encodeurl: 1.0.2 @@ -6366,97 +7159,107 @@ packages: statuses: 1.5.0 unpipe: 1.0.0 dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + /find-cache-dir/2.1.0: + resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} + engines: {node: '>=6'} dependencies: commondir: 1.0.1 make-dir: 2.1.0 pkg-dir: 3.0.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== + /find-cache-dir/3.3.1: + resolution: {integrity: sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==} + engines: {node: '>=8'} dependencies: commondir: 1.0.1 make-dir: 3.1.0 pkg-dir: 4.2.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== + /find-up/2.1.0: + resolution: {integrity: sha1-RdG35QbHF93UgndaK3eSCjwMV6c=} + engines: {node: '>=4'} dependencies: locate-path: 2.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-RdG35QbHF93UgndaK3eSCjwMV6c= + /find-up/3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} dependencies: locate-path: 3.0.0 - engines: - node: '>=6' - resolution: - integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + /find-up/4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} dependencies: locate-path: 5.0.0 path-exists: 4.0.0 - engines: - node: '>=8' - resolution: - integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + /flat-cache/3.0.4: + resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} + engines: {node: ^10.12.0 || >=12.0.0} dependencies: flatted: 3.1.1 rimraf: 3.0.2 dev: true - engines: - node: ^10.12.0 || >=12.0.0 - resolution: - integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== + /flatted/3.1.1: + resolution: {integrity: sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==} dev: true - resolution: - integrity: sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== + /flatten/1.0.3: + resolution: {integrity: sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==} dev: true - resolution: - integrity: sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg== + /flush-write-stream/1.1.1: + resolution: {integrity: sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==} dependencies: inherits: 2.0.4 readable-stream: 2.3.7 dev: true - resolution: - integrity: sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w== + + /focus-lock/0.11.5: + resolution: {integrity: sha512-1mTr6pl9HBpJ8CqY7hRc38MCrcuTZIeYAkBD1gBTzbx5/to+bRBaBYtJ68iDq7ryTzAAbKrG3dVKjkrWTaaEaw==} + engines: {node: '>=10'} + dependencies: + tslib: 2.5.0 + dev: false + /follow-redirects/1.13.3: - engines: - node: '>=4.0' + resolution: {integrity: sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + /follow-redirects/1.13.3_debug@4.3.1: + resolution: {integrity: sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==} + engines: {node: '>=4.0'} peerDependencies: debug: '*' peerDependenciesMeta: debug: optional: true - resolution: - integrity: sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA== + dependencies: + debug: 4.3.1_supports-color@6.1.0 + dev: true + /for-in/1.0.2: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + resolution: {integrity: sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=} + engines: {node: '>=0.10.0'} + /forever-agent/0.6.1: + resolution: {integrity: sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=} dev: true - resolution: - integrity: sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + /fork-ts-checker-webpack-plugin/4.1.6: + resolution: {integrity: sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw==} + engines: {node: '>=6.11.5', yarn: '>=1.0.0'} dependencies: '@babel/code-frame': 7.10.4 chalk: 2.4.2 @@ -6466,213 +7269,197 @@ packages: tapable: 1.1.3 worker-rpc: 0.1.1 dev: false - engines: - node: '>=6.11.5' - yarn: '>=1.0.0' - resolution: - integrity: sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw== + /form-data/2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 mime-types: 2.1.29 dev: true - engines: - node: '>= 0.12' - resolution: - integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + + /format/0.2.2: + resolution: {integrity: sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=} + engines: {node: '>=0.4.x'} + dev: true + /forwarded/0.1.2: + resolution: {integrity: sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= + /fragment-cache/0.2.1: + resolution: {integrity: sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=} + engines: {node: '>=0.10.0'} dependencies: map-cache: 0.2.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + /fresh/0.5.2: + resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= + /from2/2.3.0: + resolution: {integrity: sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=} dependencies: inherits: 2.0.4 readable-stream: 2.3.7 dev: true - resolution: - integrity: sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= + /fs-extra/7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} dependencies: graceful-fs: 4.2.6 jsonfile: 4.0.0 universalify: 0.1.2 dev: true - engines: - node: '>=6 <7 || >=8' - resolution: - integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== + /fs-extra/8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} dependencies: graceful-fs: 4.2.6 jsonfile: 4.0.0 universalify: 0.1.2 dev: true - engines: - node: '>=6 <7 || >=8' - resolution: - integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + /fs-extra/9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} dependencies: at-least-node: 1.0.0 graceful-fs: 4.2.6 jsonfile: 6.1.0 universalify: 2.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + /fs-minipass/2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} dependencies: minipass: 3.1.3 dev: true - engines: - node: '>= 8' - resolution: - integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + /fs-write-stream-atomic/1.0.10: + resolution: {integrity: sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=} dependencies: graceful-fs: 4.2.6 iferr: 0.1.5 imurmurhash: 0.1.4 readable-stream: 2.3.7 dev: true - resolution: - integrity: sha1-tH31NJPvkR33VzHnCp3tAYnbQMk= + /fs.realpath/1.0.0: - resolution: - integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} + /fsevents/1.2.13: + resolution: {integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==} + engines: {node: '>= 4.0'} + os: [darwin] + deprecated: fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fsevents 2. + requiresBuild: true dependencies: bindings: 1.5.0 nan: 2.14.2 - deprecated: fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fsevents 2. dev: true - engines: - node: '>= 4.0' optional: true - os: - - darwin - requiresBuild: true - resolution: - integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw== + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true dev: true - engines: - node: ^8.16.0 || ^10.6.0 || >=11.0.0 optional: true - os: - - darwin - resolution: - integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + /function-bind/1.1.1: - dev: true - resolution: - integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + /functional-red-black-tree/1.0.1: + resolution: {integrity: sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=} dev: true - resolution: - integrity: sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= + /gensync/1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + /get-caller-file/2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} dev: true - engines: - node: 6.* || 8.* || >= 10.* - resolution: - integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + /get-intrinsic/1.1.1: + resolution: {integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==} dependencies: function-bind: 1.1.1 has: 1.0.3 has-symbols: 1.0.2 - dev: true - resolution: - integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== + /get-own-enumerable-property-symbols/3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} dev: true - resolution: - integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== + /get-package-type/0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} dev: true - engines: - node: '>=8.0.0' - resolution: - integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + /get-stdin/6.0.0: + resolution: {integrity: sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== + /get-stream/4.1.0: + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} dependencies: pump: 3.0.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + /get-stream/5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} dependencies: pump: 3.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + /get-stream/6.0.0: + resolution: {integrity: sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== + /get-value/2.0.6: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + resolution: {integrity: sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=} + engines: {node: '>=0.10.0'} + /getpass/0.1.7: + resolution: {integrity: sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=} dependencies: assert-plus: 1.0.0 dev: true - resolution: - integrity: sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + + /github-slugger/1.4.0: + resolution: {integrity: sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==} + dev: true + /glob-parent/3.1.0: + resolution: {integrity: sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=} dependencies: is-glob: 3.1.0 path-dirname: 1.0.2 dev: true - resolution: - integrity: sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} dependencies: is-glob: 4.0.1 - engines: - node: '>= 6' - resolution: - integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + /glob/7.1.6: + resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -6680,48 +7467,51 @@ packages: minimatch: 3.0.4 once: 1.4.0 path-is-absolute: 1.0.1 - resolution: - integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + /global-modules/2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} dependencies: global-prefix: 3.0.0 dev: false - engines: - node: '>=6' - resolution: - integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== + /global-prefix/3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} dependencies: ini: 1.3.8 kind-of: 6.0.3 which: 1.3.1 dev: false - engines: - node: '>=6' - resolution: - integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== + + /global/4.4.0: + resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==} + dependencies: + min-document: 2.19.0 + process: 0.11.10 + dev: true + /globals/11.12.0: - engines: - node: '>=4' - resolution: - integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + /globals/12.4.0: + resolution: {integrity: sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==} + engines: {node: '>=8'} dependencies: type-fest: 0.8.1 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg== + /globals/13.7.0: + resolution: {integrity: sha512-Aipsz6ZKRxa/xQkZhNg0qIWXT6x6rD46f6x/PCnBomlttdIyAPak4YD9jTmKpZ72uROSMU87qJtcgpgHaVchiA==} + engines: {node: '>=8'} dependencies: type-fest: 0.20.2 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-Aipsz6ZKRxa/xQkZhNg0qIWXT6x6rD46f6x/PCnBomlttdIyAPak4YD9jTmKpZ72uROSMU87qJtcgpgHaVchiA== + /globby/11.0.1: + resolution: {integrity: sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==} + engines: {node: '>=10'} dependencies: array-union: 2.1.0 dir-glob: 3.0.1 @@ -6730,11 +7520,10 @@ packages: merge2: 1.4.1 slash: 3.0.0 dev: false - engines: - node: '>=10' - resolution: - integrity: sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== + /globby/11.0.3: + resolution: {integrity: sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==} + engines: {node: '>=10'} dependencies: array-union: 2.1.0 dir-glob: 3.0.1 @@ -6743,11 +7532,10 @@ packages: merge2: 1.4.1 slash: 3.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg== + /globby/6.1.0: + resolution: {integrity: sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=} + engines: {node: '>=0.10.0'} dependencies: array-union: 1.0.2 glob: 7.1.6 @@ -6755,210 +7543,338 @@ packages: pify: 2.3.0 pinkie-promise: 2.0.1 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-9abXDoOV4hyFj7BInWTfAkJNUGw= + /graceful-fs/4.2.6: + resolution: {integrity: sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==} + dev: true + + /graceful-fs/4.2.9: + resolution: {integrity: sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==} dev: true - resolution: - integrity: sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== + optional: true + /growly/1.3.0: + resolution: {integrity: sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=} dev: true optional: true - resolution: - integrity: sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= + /gzip-size/5.1.1: + resolution: {integrity: sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA==} + engines: {node: '>=6'} dependencies: duplexer: 0.1.2 pify: 4.0.1 dev: false - engines: - node: '>=6' - resolution: - integrity: sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA== + + /hamt_plus/1.0.2: + resolution: {integrity: sha1-4hwlKWjH4zsg9qGwlM2FeHomVgE=} + dev: false + /handle-thing/2.0.1: + resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} dev: true - resolution: - integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== + /har-schema/2.0.0: + resolution: {integrity: sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + /har-validator/5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported dependencies: ajv: 6.12.6 har-schema: 2.0.0 - deprecated: this library is no longer supported dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== + /harmony-reflect/1.6.1: + resolution: {integrity: sha512-WJTeyp0JzGtHcuMsi7rw2VwtkvLa+JyfEKJCFyfcS0+CDkjQ5lHPu7zEhFZP+PDSRrEgXa5Ah0l1MbgbE41XjA==} dev: true - resolution: - integrity: sha512-WJTeyp0JzGtHcuMsi7rw2VwtkvLa+JyfEKJCFyfcS0+CDkjQ5lHPu7zEhFZP+PDSRrEgXa5Ah0l1MbgbE41XjA== + /has-bigints/1.0.1: + resolution: {integrity: sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==} dev: true - resolution: - integrity: sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== + /has-flag/3.0.0: - engines: - node: '>=4' - resolution: - integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + resolution: {integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0=} + engines: {node: '>=4'} + /has-flag/4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + /has-symbols/1.0.2: - dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== + resolution: {integrity: sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==} + engines: {node: '>= 0.4'} + /has-value/0.3.1: + resolution: {integrity: sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=} + engines: {node: '>=0.10.0'} dependencies: get-value: 2.0.6 has-values: 0.1.4 isobject: 2.1.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + /has-value/1.0.0: + resolution: {integrity: sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=} + engines: {node: '>=0.10.0'} dependencies: get-value: 2.0.6 has-values: 1.0.0 isobject: 3.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + /has-values/0.1.4: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-bWHeldkd/Km5oCCJrThL/49it3E= + resolution: {integrity: sha1-bWHeldkd/Km5oCCJrThL/49it3E=} + engines: {node: '>=0.10.0'} + /has-values/1.0.0: + resolution: {integrity: sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=} + engines: {node: '>=0.10.0'} dependencies: is-number: 3.0.0 kind-of: 4.0.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} dependencies: function-bind: 1.1.1 - dev: true - engines: - node: '>= 0.4.0' - resolution: - integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + /hash-base/3.1.0: + resolution: {integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==} + engines: {node: '>=4'} dependencies: inherits: 2.0.4 readable-stream: 3.6.0 safe-buffer: 5.2.1 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== + /hash.js/1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} dependencies: inherits: 2.0.4 minimalistic-assert: 1.0.1 dev: true - resolution: - integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== - /he/1.2.0: + + /hast-to-hyperscript/9.0.1: + resolution: {integrity: sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==} + dependencies: + '@types/unist': 2.0.6 + comma-separated-tokens: 1.0.8 + property-information: 5.6.0 + space-separated-tokens: 1.1.5 + style-to-object: 0.3.0 + unist-util-is: 4.1.0 + web-namespaces: 1.1.4 + dev: true + + /hast-util-from-dom/3.0.0: + resolution: {integrity: sha512-4vQuGiD5Y/wlD7fZiY4mZML/6oh0GOnH38UNyeDFcSTE4AHF0zjKHZfbd+ekVwPvsZXRl8choc99INHUwSPJlg==} + dependencies: + hastscript: 6.0.0 + web-namespaces: 1.1.4 + dev: true + + /hast-util-from-parse5/6.0.1: + resolution: {integrity: sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==} + dependencies: + '@types/parse5': 5.0.3 + hastscript: 6.0.0 + property-information: 5.6.0 + vfile: 4.2.1 + vfile-location: 3.2.0 + web-namespaces: 1.1.4 + dev: true + + /hast-util-has-property/1.0.4: + resolution: {integrity: sha512-ghHup2voGfgFoHMGnaLHOjbYFACKrRh9KFttdCzMCbFoBMJXiNi2+XTrPP8+q6cDJM/RSqlCfVWrjp1H201rZg==} + dev: true + + /hast-util-is-conditional-comment/1.0.4: + resolution: {integrity: sha512-rtULxWWknVeSuU/vsJ9tHo+M3ExyaOrZcWvLxqY2nUfCHbDcq60EJzSJC5zNm6ZlbxbJ8l7Ej8C1Kzsi5PJS1A==} + dev: true + + /hast-util-is-element/1.1.0: + resolution: {integrity: sha512-oUmNua0bFbdrD/ELDSSEadRVtWZOf3iF6Lbv81naqsIV99RnSCieTbWuWCY8BAeEfKJTKl0gRdokv+dELutHGQ==} + dev: true + + /hast-util-parse-selector/2.2.5: + resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==} + dev: true + + /hast-util-raw/6.1.0: + resolution: {integrity: sha512-5FoZLDHBpka20OlZZ4I/+RBw5piVQ8iI1doEvffQhx5CbCyTtP8UCq8Tw6NmTAMtXgsQxmhW7Ly8OdFre5/YMQ==} + dependencies: + '@types/hast': 2.3.4 + hast-util-from-parse5: 6.0.1 + hast-util-to-parse5: 6.0.0 + html-void-elements: 1.0.5 + parse5: 6.0.1 + unist-util-position: 3.1.0 + unist-util-visit: 2.0.3 + vfile: 4.2.1 + web-namespaces: 1.1.4 + xtend: 4.0.2 + zwitch: 1.0.5 + dev: true + + /hast-util-to-html/7.1.3: + resolution: {integrity: sha512-yk2+1p3EJTEE9ZEUkgHsUSVhIpCsL/bvT8E5GzmWc+N1Po5gBw+0F8bo7dpxXR0nu0bQVxVZGX2lBGF21CmeDw==} + dependencies: + ccount: 1.1.0 + comma-separated-tokens: 1.0.8 + hast-util-is-element: 1.1.0 + hast-util-whitespace: 1.0.4 + html-void-elements: 1.0.5 + property-information: 5.6.0 + space-separated-tokens: 1.1.5 + stringify-entities: 3.1.0 + unist-util-is: 4.1.0 + xtend: 4.0.2 + dev: true + + /hast-util-to-parse5/6.0.0: + resolution: {integrity: sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==} + dependencies: + hast-to-hyperscript: 9.0.1 + property-information: 5.6.0 + web-namespaces: 1.1.4 + xtend: 4.0.2 + zwitch: 1.0.5 + dev: true + + /hast-util-to-string/1.0.4: + resolution: {integrity: sha512-eK0MxRX47AV2eZ+Lyr18DCpQgodvaS3fAQO2+b9Two9F5HEoRPhiUMNzoXArMJfZi2yieFzUBMRl3HNJ3Jus3w==} + dev: true + + /hast-util-to-text/2.0.1: + resolution: {integrity: sha512-8nsgCARfs6VkwH2jJU9b8LNTuR4700na+0h3PqCaEk4MAnMDeu5P0tP8mjk9LLNGxIeQRLbiDbZVw6rku+pYsQ==} + dependencies: + hast-util-is-element: 1.1.0 + repeat-string: 1.6.1 + unist-util-find-after: 3.0.0 + dev: true + + /hast-util-whitespace/1.0.4: + resolution: {integrity: sha512-I5GTdSfhYfAPNztx2xJRQpG8cuDSNt599/7YUn7Gx/WxNMsG+a835k97TDkFgk123cwjfwINaZknkKkphx/f2A==} + dev: true + + /hastscript/6.0.0: + resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==} + dependencies: + '@types/hast': 2.3.4 + comma-separated-tokens: 1.0.8 + hast-util-parse-selector: 2.2.5 + property-information: 5.6.0 + space-separated-tokens: 1.1.5 dev: true + + /he/1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - resolution: - integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + dev: true + /hex-color-regex/1.1.0: + resolution: {integrity: sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==} dev: true - resolution: - integrity: sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== - /history/4.10.1: + + /history-with-query/4.10.4: + resolution: {integrity: sha512-JnskQK8X+PbRFHSdDAExhoJyhLnlLZL+UuHQuQhys+Se9/ukRDRBWU4JVTjsiIfbv1fcEmR3oqKW56OYmk5M5w==} dependencies: - '@babel/runtime': 7.13.10 + '@babel/runtime': 7.14.8 loose-envify: 1.4.0 + query-string: 6.14.1 resolve-pathname: 3.0.0 tiny-invariant: 1.1.0 tiny-warning: 1.0.3 value-equal: 1.0.1 - dev: false - resolution: - integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dev: true + + /history/4.10.1: + resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} + dependencies: + '@babel/runtime': 7.13.10 + loose-envify: 1.4.0 + resolve-pathname: 3.0.0 + tiny-invariant: 1.1.0 + tiny-warning: 1.0.3 + value-equal: 1.0.1 + /hmac-drbg/1.0.1: + resolution: {integrity: sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=} dependencies: hash.js: 1.1.7 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 dev: true - resolution: - integrity: sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= + /hoist-non-react-statics/3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} dependencies: react-is: 16.13.1 - resolution: - integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + /hoopy/0.1.4: + resolution: {integrity: sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==} + engines: {node: '>= 6.0.0'} dev: true - engines: - node: '>= 6.0.0' - resolution: - integrity: sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ== + /hosted-git-info/2.8.8: + resolution: {integrity: sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==} + dev: true + + /hosted-git-info/3.0.8: + resolution: {integrity: sha512-aXpmwoOhRBrw6X3j0h5RloK4x1OzsxMPyxqIHyNfSe2pypkVTZFpEiRoSipPEPlMrh0HW/XsjkJ5WgnCirpNUw==} + engines: {node: '>=10'} + dependencies: + lru-cache: 6.0.0 dev: true - resolution: - integrity: sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + /hpack.js/2.1.6: + resolution: {integrity: sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=} dependencies: inherits: 2.0.4 obuf: 1.1.2 readable-stream: 2.3.7 wbuf: 1.7.3 dev: true - resolution: - integrity: sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI= + /hsl-regex/1.0.0: + resolution: {integrity: sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4=} dev: true - resolution: - integrity: sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= + /hsla-regex/1.0.0: + resolution: {integrity: sha1-wc56MWjIxmFAM6S194d/OyJfnDg=} dev: true - resolution: - integrity: sha1-wc56MWjIxmFAM6S194d/OyJfnDg= + /html-comment-regex/1.1.2: + resolution: {integrity: sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==} dev: true - resolution: - integrity: sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== + /html-encoding-sniffer/2.0.1: + resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==} + engines: {node: '>=10'} dependencies: whatwg-encoding: 1.0.5 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ== + /html-entities/1.4.0: + resolution: {integrity: sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==} + dev: true + + /html-entities/2.3.2: + resolution: {integrity: sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==} dev: true - resolution: - integrity: sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA== + /html-escaper/2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true - resolution: - integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + /html-minifier-terser/5.1.1: + resolution: {integrity: sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==} + engines: {node: '>=6'} + hasBin: true dependencies: camel-case: 4.1.2 clean-css: 4.2.3 @@ -6968,18 +7884,22 @@ packages: relateurl: 0.2.7 terser: 4.8.0 dev: true - engines: - node: '>=6' - hasBin: true - resolution: - integrity: sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg== + /html-parse-stringify2/2.0.1: + resolution: {integrity: sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o=} dependencies: void-elements: 2.0.1 dev: false - resolution: - integrity: sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o= + + /html-void-elements/1.0.5: + resolution: {integrity: sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==} + dev: true + /html-webpack-plugin/4.5.0_webpack@4.44.2: + resolution: {integrity: sha512-MouoXEYSjTzCrjIxWwg8gxL5fE2X2WZJLmBYXlaJhQUH5K/b5OrqmV7T4dB7iu0xkmJ6JlUuV6fFVtnqbPopZw==} + engines: {node: '>=6.9'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 dependencies: '@types/html-minifier-terser': 5.1.1 '@types/tapable': 1.0.7 @@ -6992,13 +7912,9 @@ packages: util.promisify: 1.0.0 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>=6.9' - peerDependencies: - webpack: ^4.0.0 || ^5.0.0 - resolution: - integrity: sha512-MouoXEYSjTzCrjIxWwg8gxL5fE2X2WZJLmBYXlaJhQUH5K/b5OrqmV7T4dB7iu0xkmJ6JlUuV6fFVtnqbPopZw== + /htmlparser2/3.10.1: + resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==} dependencies: domelementtype: 1.3.1 domhandler: 2.4.2 @@ -7007,24 +7923,24 @@ packages: inherits: 2.0.4 readable-stream: 3.6.0 dev: true - resolution: - integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + /http-deceiver/1.2.7: + resolution: {integrity: sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=} dev: true - resolution: - integrity: sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= + /http-errors/1.6.3: + resolution: {integrity: sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=} + engines: {node: '>= 0.6'} dependencies: depd: 1.1.2 inherits: 2.0.3 setprototypeof: 1.1.0 statuses: 1.5.0 dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= + /http-errors/1.7.2: + resolution: {integrity: sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==} + engines: {node: '>= 0.6'} dependencies: depd: 1.1.2 inherits: 2.0.3 @@ -7032,11 +7948,10 @@ packages: statuses: 1.5.0 toidentifier: 1.0.0 dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg== + /http-errors/1.7.3: + resolution: {integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==} + engines: {node: '>= 0.6'} dependencies: depd: 1.1.2 inherits: 2.0.4 @@ -7044,379 +7959,385 @@ packages: statuses: 1.5.0 toidentifier: 1.0.0 dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== + /http-parser-js/0.5.3: + resolution: {integrity: sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg==} dev: true - resolution: - integrity: sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg== + /http-proxy-middleware/0.19.1_debug@4.3.1: + resolution: {integrity: sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==} + engines: {node: '>=4.0.0'} dependencies: http-proxy: 1.18.1_debug@4.3.1 is-glob: 4.0.1 lodash: 4.17.21 micromatch: 3.1.10 + transitivePeerDependencies: + - debug dev: true - engines: - node: '>=4.0.0' + + /http-proxy-middleware/2.0.6: + resolution: {integrity: sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==} + engines: {node: '>=12.0.0'} peerDependencies: - debug: '*' - resolution: - integrity: sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q== - /http-proxy/1.18.1_debug@4.3.1: + '@types/express': ^4.17.13 + peerDependenciesMeta: + '@types/express': + optional: true + dependencies: + '@types/http-proxy': 1.17.9 + http-proxy: 1.18.1 + is-glob: 4.0.1 + is-plain-obj: 3.0.0 + micromatch: 4.0.2 + transitivePeerDependencies: + - debug + dev: true + + /http-proxy/1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} dependencies: eventemitter3: 4.0.7 follow-redirects: 1.13.3 requires-port: 1.0.0 + transitivePeerDependencies: + - debug dev: true - engines: - node: '>=8.0.0' - peerDependencies: - debug: '*' - resolution: - integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + + /http-proxy/1.18.1_debug@4.3.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.13.3_debug@4.3.1 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + dev: true + /http-signature/1.2.0: + resolution: {integrity: sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=} + engines: {node: '>=0.8', npm: '>=1.3.7'} dependencies: assert-plus: 1.0.0 jsprim: 1.4.1 sshpk: 1.16.1 dev: true - engines: - node: '>=0.8' - npm: '>=1.3.7' - resolution: - integrity: sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + /https-browserify/1.0.0: + resolution: {integrity: sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=} dev: true - resolution: - integrity: sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= + /human-signals/1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} dev: true - engines: - node: '>=8.12.0' - resolution: - integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== + /human-signals/2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} dev: true - engines: - node: '>=10.17.0' - resolution: - integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + /hyphenate-style-name/1.0.4: + resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==} dev: false - resolution: - integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ== + /i18next/19.9.2: + resolution: {integrity: sha512-0i6cuo6ER6usEOtKajUUDj92zlG+KArFia0857xxiEHAQcUwh/RtOQocui1LPJwunSYT574Pk64aNva1kwtxZg==} dependencies: '@babel/runtime': 7.13.10 dev: false - resolution: - integrity: sha512-0i6cuo6ER6usEOtKajUUDj92zlG+KArFia0857xxiEHAQcUwh/RtOQocui1LPJwunSYT574Pk64aNva1kwtxZg== + /iconv-lite/0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} dependencies: safer-buffer: 2.1.2 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + /icss-utils/4.1.1: + resolution: {integrity: sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==} + engines: {node: '>= 6'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA== + /identity-obj-proxy/3.0.0: + resolution: {integrity: sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=} + engines: {node: '>=4'} dependencies: harmony-reflect: 1.6.1 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ= + /ieee754/1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: true - resolution: - integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + /iferr/0.1.5: + resolution: {integrity: sha1-xg7taebY/bazEEofy8ocGS3FtQE=} dev: true - resolution: - integrity: sha1-xg7taebY/bazEEofy8ocGS3FtQE= + /ignore/4.0.6: + resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==} + engines: {node: '>= 4'} dev: true - engines: - node: '>= 4' - resolution: - integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== + /ignore/5.1.8: - engines: - node: '>= 4' - resolution: - integrity: sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== + resolution: {integrity: sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==} + engines: {node: '>= 4'} + /image-size/0.5.5: - dev: true - engines: - node: '>=0.10.0' + resolution: {integrity: sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=} + engines: {node: '>=0.10.0'} hasBin: true + requiresBuild: true + dev: true optional: true - resolution: - integrity: sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w= - /immer/6.0.9: - dev: false - resolution: - integrity: sha512-SyCYnAuiRf67Lvk0VkwFvwtDoEiCMjeamnHvRfnVDyc7re1/rQrNxuL+jJ7lA3WvdC4uznrvbmm+clJ9+XXatg== + /immer/8.0.1: + resolution: {integrity: sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==} dev: false - resolution: - integrity: sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== + /import-cwd/2.1.0: + resolution: {integrity: sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk=} + engines: {node: '>=4'} dependencies: import-from: 2.1.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk= + /import-fresh/2.0.0: + resolution: {integrity: sha1-2BNVwVYS04bGH53dOSLUMEgipUY=} + engines: {node: '>=4'} dependencies: caller-path: 2.0.0 resolve-from: 3.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-2BNVwVYS04bGH53dOSLUMEgipUY= + /import-fresh/3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + /import-from/2.1.0: + resolution: {integrity: sha1-M1238qev/VOqpHHUuAId7ja387E=} + engines: {node: '>=4'} dependencies: resolve-from: 3.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-M1238qev/VOqpHHUuAId7ja387E= + /import-local/2.0.0: + resolution: {integrity: sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==} + engines: {node: '>=6'} + hasBin: true dependencies: pkg-dir: 3.0.0 resolve-cwd: 2.0.0 dev: true - engines: - node: '>=6' - hasBin: true - resolution: - integrity: sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== + /import-local/3.0.2: + resolution: {integrity: sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==} + engines: {node: '>=8'} + hasBin: true dependencies: pkg-dir: 4.2.0 resolve-cwd: 3.0.0 dev: true - engines: - node: '>=8' - hasBin: true - resolution: - integrity: sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA== + /imurmurhash/0.1.4: + resolution: {integrity: sha1-khi5srkoojixPcT7a21XbyMUU+o=} + engines: {node: '>=0.8.19'} dev: true - engines: - node: '>=0.8.19' - resolution: - integrity: sha1-khi5srkoojixPcT7a21XbyMUU+o= + /indent-string/4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + /indexes-of/1.0.1: + resolution: {integrity: sha1-8w9xbI4r00bHtn0985FVZqfAVgc=} dev: true - resolution: - integrity: sha1-8w9xbI4r00bHtn0985FVZqfAVgc= + /infer-owner/1.0.4: + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} dev: true - resolution: - integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== + /inflight/1.0.6: + resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=} dependencies: once: 1.4.0 wrappy: 1.0.2 - resolution: - integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + /inherits/2.0.1: + resolution: {integrity: sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=} dev: true - resolution: - integrity: sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + /inherits/2.0.3: + resolution: {integrity: sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=} dev: true - resolution: - integrity: sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + /inherits/2.0.4: - resolution: - integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + /ini/1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} dev: false - resolution: - integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + + /inline-style-parser/0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + dev: true + /inline-style-prefixer/6.0.0: + resolution: {integrity: sha512-XTHvRUS4ZJNzC1GixJRmOlWSS45fSt+DJoyQC9ytj0WxQfcgofQtDtyKKYxHUqEsWCs+LIWftPF1ie7+i012Fg==} dependencies: css-in-js-utils: 2.0.1 dev: false - resolution: - integrity: sha512-XTHvRUS4ZJNzC1GixJRmOlWSS45fSt+DJoyQC9ytj0WxQfcgofQtDtyKKYxHUqEsWCs+LIWftPF1ie7+i012Fg== + /internal-ip/4.3.0: + resolution: {integrity: sha512-S1zBo1D6zcsyuC6PMmY5+55YMILQ9av8lotMx447Bq6SAgo/sDK6y6uUKmuYhW7eacnIhFfsPmCNYdDzsnnDCg==} + engines: {node: '>=6'} dependencies: default-gateway: 4.2.0 ipaddr.js: 1.9.1 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-S1zBo1D6zcsyuC6PMmY5+55YMILQ9av8lotMx447Bq6SAgo/sDK6y6uUKmuYhW7eacnIhFfsPmCNYdDzsnnDCg== + /internal-slot/1.0.3: + resolution: {integrity: sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==} + engines: {node: '>= 0.4'} dependencies: get-intrinsic: 1.1.1 has: 1.0.3 side-channel: 1.0.4 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA== + /interpret/2.2.0: + resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} + engines: {node: '>= 0.10'} dev: true - engines: - node: '>= 0.10' - resolution: - integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== + /ip-port-regex/2.0.0: + resolution: {integrity: sha1-5a+/arAXmGQ/eTatVCCsIUJzEH4=} + engines: {node: '>=4'} dependencies: ip-regex: 1.0.3 dev: false - engines: - node: '>=4' - resolution: - integrity: sha1-5a+/arAXmGQ/eTatVCCsIUJzEH4= + /ip-regex/1.0.3: + resolution: {integrity: sha1-3FiQdvZZ9BnCIgOaMzFvHHOH7/0=} + engines: {node: '>=0.10.0'} dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-3FiQdvZZ9BnCIgOaMzFvHHOH7/0= + /ip-regex/2.1.0: + resolution: {integrity: sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= + /ip/1.1.5: + resolution: {integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=} dev: true - resolution: - integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= + /ipaddr.js/1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} dev: true - engines: - node: '>= 0.10' - resolution: - integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + /is-absolute-url/2.1.0: + resolution: {integrity: sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= + /is-absolute-url/3.0.3: + resolution: {integrity: sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q== + /is-accessor-descriptor/0.1.6: + resolution: {integrity: sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=} + engines: {node: '>=0.10.0'} dependencies: kind-of: 3.2.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + /is-accessor-descriptor/1.0.0: + resolution: {integrity: sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==} + engines: {node: '>=0.10.0'} dependencies: kind-of: 6.0.3 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + + /is-alphabetical/1.0.4: + resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} + dev: true + + /is-alphanumerical/1.0.4: + resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==} + dependencies: + is-alphabetical: 1.0.4 + is-decimal: 1.0.4 + dev: true + /is-arguments/1.1.0: + resolution: {integrity: sha512-1Ij4lOMPl/xB5kBDn7I+b2ttPMKa8szhEIrXDuXQD/oe3HJLTLhqhgGspwgyGd6MOywBUqVvYicF72lkgDnIHg==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-1Ij4lOMPl/xB5kBDn7I+b2ttPMKa8szhEIrXDuXQD/oe3HJLTLhqhgGspwgyGd6MOywBUqVvYicF72lkgDnIHg== + /is-arrayish/0.2.1: + resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=} dev: true - resolution: - integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + /is-arrayish/0.3.2: - dev: true - resolution: - integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + /is-bigint/1.0.1: + resolution: {integrity: sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg==} dev: true - resolution: - integrity: sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg== + /is-binary-path/1.0.1: + resolution: {integrity: sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=} + engines: {node: '>=0.10.0'} dependencies: binary-extensions: 1.13.1 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} dependencies: binary-extensions: 2.2.0 dev: true - engines: - node: '>=8' - optional: true - resolution: - integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + /is-boolean-object/1.1.0: + resolution: {integrity: sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA== + /is-buffer/1.1.6: - resolution: - integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + /is-buffer/2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + dev: true + /is-callable/1.2.3: + resolution: {integrity: sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==} + engines: {node: '>= 0.4'} dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== + /is-ci/2.0.0: + resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} + hasBin: true dependencies: ci-info: 2.0.0 dev: true - hasBin: true - resolution: - integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + /is-color-stop/1.1.0: + resolution: {integrity: sha1-z/9HGu5N1cnhWFmPvhKWe1za00U=} dependencies: css-color-names: 0.0.4 hex-color-regex: 1.1.0 @@ -7425,367 +8346,347 @@ packages: rgb-regex: 1.0.1 rgba-regex: 1.0.0 dev: true - resolution: - integrity: sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= + /is-core-module/2.2.0: + resolution: {integrity: sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==} dependencies: has: 1.0.3 dev: true - resolution: - integrity: sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== + /is-data-descriptor/0.1.4: + resolution: {integrity: sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=} + engines: {node: '>=0.10.0'} dependencies: kind-of: 3.2.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + /is-data-descriptor/1.0.0: + resolution: {integrity: sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==} + engines: {node: '>=0.10.0'} dependencies: kind-of: 6.0.3 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + /is-date-object/1.0.2: + resolution: {integrity: sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==} + engines: {node: '>= 0.4'} + dev: true + + /is-decimal/1.0.4: + resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== + /is-descriptor/0.1.6: + resolution: {integrity: sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==} + engines: {node: '>=0.10.0'} dependencies: is-accessor-descriptor: 0.1.6 is-data-descriptor: 0.1.4 kind-of: 5.1.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + /is-descriptor/1.0.2: + resolution: {integrity: sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==} + engines: {node: '>=0.10.0'} dependencies: is-accessor-descriptor: 1.0.0 is-data-descriptor: 1.0.0 kind-of: 6.0.3 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + /is-directory/0.3.1: + resolution: {integrity: sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= + /is-docker/2.1.1: - engines: - node: '>=8' + resolution: {integrity: sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw==} + engines: {node: '>=8'} hasBin: true - resolution: - integrity: sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw== + /is-extendable/0.1.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + resolution: {integrity: sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=} + engines: {node: '>=0.10.0'} + /is-extendable/1.0.1: + resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==} + engines: {node: '>=0.10.0'} dependencies: is-plain-object: 2.0.4 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + /is-extglob/2.1.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=} + engines: {node: '>=0.10.0'} + /is-fullwidth-code-point/2.0.0: + resolution: {integrity: sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + /is-fullwidth-code-point/3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + /is-generator-fn/2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + /is-glob/3.1.0: + resolution: {integrity: sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=} + engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= + /is-glob/4.0.1: + resolution: {integrity: sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==} + engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + + /is-hexadecimal/1.0.4: + resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} + dev: true + /is-module/1.0.0: + resolution: {integrity: sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=} dev: true - resolution: - integrity: sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= + /is-negative-zero/2.0.1: + resolution: {integrity: sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==} + engines: {node: '>= 0.4'} dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== + /is-number-object/1.0.4: + resolution: {integrity: sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==} + engines: {node: '>= 0.4'} dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw== + /is-number/3.0.0: + resolution: {integrity: sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=} + engines: {node: '>=0.10.0'} dependencies: kind-of: 3.2.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + /is-number/7.0.0: - engines: - node: '>=0.12.0' - resolution: - integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + /is-obj/1.0.1: + resolution: {integrity: sha1-PkcprB9f3gJc19g6iW2rn09n2w8=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-PkcprB9f3gJc19g6iW2rn09n2w8= + /is-obj/2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + /is-path-cwd/2.2.0: + resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== + /is-path-in-cwd/2.1.0: + resolution: {integrity: sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==} + engines: {node: '>=6'} dependencies: is-path-inside: 2.1.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ== + /is-path-inside/2.1.0: + resolution: {integrity: sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==} + engines: {node: '>=6'} dependencies: path-is-inside: 1.0.2 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg== + /is-plain-obj/1.1.0: + resolution: {integrity: sha1-caUMhCnfync8kqOQpKA7OfzVHT4=} + engines: {node: '>=0.10.0'} + dev: true + + /is-plain-obj/2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-caUMhCnfync8kqOQpKA7OfzVHT4= + + /is-plain-obj/3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + dev: true + /is-plain-object/2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} dependencies: isobject: 3.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + /is-potential-custom-element-name/1.0.0: + resolution: {integrity: sha1-DFLlS8yjkbssSUsh6GJtczbG45c=} dev: true - resolution: - integrity: sha1-DFLlS8yjkbssSUsh6GJtczbG45c= + /is-regex/1.1.2: + resolution: {integrity: sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 has-symbols: 1.0.2 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg== + /is-regexp/1.0.0: + resolution: {integrity: sha1-/S2INUXEa6xaYz57mgnof6LLUGk=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-/S2INUXEa6xaYz57mgnof6LLUGk= + /is-resolvable/1.1.0: + resolution: {integrity: sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==} dev: true - resolution: - integrity: sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== + /is-root/2.1.0: + resolution: {integrity: sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==} + engines: {node: '>=6'} dev: false - engines: - node: '>=6' - resolution: - integrity: sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== + /is-stream/1.1.0: + resolution: {integrity: sha1-EtSj3U5o4Lec6428hBc66A2RykQ=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + /is-stream/2.0.0: + resolution: {integrity: sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== + /is-string/1.0.5: + resolution: {integrity: sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==} + engines: {node: '>= 0.4'} dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== + /is-svg/3.0.0: + resolution: {integrity: sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==} + engines: {node: '>=4'} dependencies: html-comment-regex: 1.1.2 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ== + /is-symbol/1.0.3: + resolution: {integrity: sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==} + engines: {node: '>= 0.4'} dependencies: has-symbols: 1.0.2 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== + /is-typedarray/1.0.0: + resolution: {integrity: sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=} dev: true - resolution: - integrity: sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + /is-unicode-supported/0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + /is-what/3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} dev: true - resolution: - integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA== + /is-windows/1.0.2: - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + /is-wsl/1.1.0: + resolution: {integrity: sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= + /is-wsl/2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} dependencies: is-docker: 2.1.1 - engines: - node: '>=8' - resolution: - integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + /isarray/0.0.1: - dev: false - resolution: - integrity: sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + resolution: {integrity: sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=} + /isarray/1.0.0: - resolution: - integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=} + /isexe/2.0.0: - resolution: - integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=} + /isobject/2.1.0: + resolution: {integrity: sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=} + engines: {node: '>=0.10.0'} dependencies: isarray: 1.0.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + /isobject/3.0.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + resolution: {integrity: sha1-TkMekrEalzFjaqH5yNHMvP2reN8=} + engines: {node: '>=0.10.0'} + /isstream/0.1.2: + resolution: {integrity: sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=} dev: true - resolution: - integrity: sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + /istanbul-lib-coverage/3.0.0: + resolution: {integrity: sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg== + /istanbul-lib-instrument/4.0.3: + resolution: {integrity: sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==} + engines: {node: '>=8'} dependencies: '@babel/core': 7.12.3 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.0.0 semver: 6.3.0 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== + /istanbul-lib-report/3.0.0: + resolution: {integrity: sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==} + engines: {node: '>=8'} dependencies: istanbul-lib-coverage: 3.0.0 make-dir: 3.1.0 supports-color: 7.2.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw== + /istanbul-lib-source-maps/4.0.0: + resolution: {integrity: sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==} + engines: {node: '>=8'} dependencies: debug: 4.3.1 istanbul-lib-coverage: 3.0.0 source-map: 0.6.1 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg== + /istanbul-reports/3.0.2: + resolution: {integrity: sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==} + engines: {node: '>=8'} dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw== + + /javascript-stringify/2.1.0: + resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} + dev: true + /jest-changed-files/26.6.2: + resolution: {integrity: sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 execa: 4.1.0 throat: 5.0.0 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ== + /jest-circus/26.6.0: + resolution: {integrity: sha512-L2/Y9szN6FJPWFK8kzWXwfp+FOR7xq0cUL4lIsdbIdwz3Vh6P1nrpcqOleSzr28zOtSHQNV9Z7Tl+KkuK7t5Ng==} + engines: {node: '>= 10.14.2'} dependencies: '@babel/traverse': 7.13.13 '@jest/environment': 26.6.2 @@ -7808,12 +8709,18 @@ packages: pretty-format: 26.6.2 stack-utils: 2.0.3 throat: 5.0.0 - dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-L2/Y9szN6FJPWFK8kzWXwfp+FOR7xq0cUL4lIsdbIdwz3Vh6P1nrpcqOleSzr28zOtSHQNV9Z7Tl+KkuK7t5Ng== + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + /jest-cli/26.6.3: + resolution: {integrity: sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg==} + engines: {node: '>= 10.14.2'} + hasBin: true dependencies: '@jest/core': 26.6.3 '@jest/test-result': 26.6.2 @@ -7828,13 +8735,22 @@ packages: jest-validate: 26.6.2 prompts: 2.4.0 yargs: 15.4.1 - dev: true - engines: - node: '>= 10.14.2' - hasBin: true - resolution: - integrity: sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg== + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + /jest-config/26.6.3: + resolution: {integrity: sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg==} + engines: {node: '>= 10.14.2'} + peerDependencies: + ts-node: '>=9.0.0' + peerDependenciesMeta: + ts-node: + optional: true dependencies: '@babel/core': 7.12.3 '@jest/test-sequencer': 26.6.3 @@ -7854,36 +8770,33 @@ packages: jest-validate: 26.6.2 micromatch: 4.0.2 pretty-format: 26.6.2 + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - utf-8-validate dev: true - engines: - node: '>= 10.14.2' - peerDependencies: - ts-node: '>=9.0.0' - peerDependenciesMeta: - ts-node: - optional: true - resolution: - integrity: sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg== + /jest-diff/26.6.2: + resolution: {integrity: sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==} + engines: {node: '>= 10.14.2'} dependencies: chalk: 4.1.0 diff-sequences: 26.6.2 jest-get-type: 26.3.0 pretty-format: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== + /jest-docblock/26.0.0: + resolution: {integrity: sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==} + engines: {node: '>= 10.14.2'} dependencies: detect-newline: 3.1.0 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w== + /jest-each/26.6.2: + resolution: {integrity: sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 chalk: 4.1.0 @@ -7891,11 +8804,10 @@ packages: jest-util: 26.6.2 pretty-format: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A== + /jest-environment-jsdom/26.6.2: + resolution: {integrity: sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/environment': 26.6.2 '@jest/fake-timers': 26.6.2 @@ -7904,12 +8816,15 @@ packages: jest-mock: 26.6.2 jest-util: 26.6.2 jsdom: 16.5.2 + transitivePeerDependencies: + - bufferutil + - canvas + - utf-8-validate dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q== + /jest-environment-node/26.6.2: + resolution: {integrity: sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/environment': 26.6.2 '@jest/fake-timers': 26.6.2 @@ -7918,17 +8833,15 @@ packages: jest-mock: 26.6.2 jest-util: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag== + /jest-get-type/26.3.0: + resolution: {integrity: sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==} + engines: {node: '>= 10.14.2'} dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== + /jest-haste-map/26.6.2: + resolution: {integrity: sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 '@types/graceful-fs': 4.1.5 @@ -7943,14 +8856,13 @@ packages: micromatch: 4.0.2 sane: 4.1.0 walker: 1.0.7 - dev: true - engines: - node: '>= 10.14.2' optionalDependencies: fsevents: 2.3.2 - resolution: - integrity: sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w== + dev: true + /jest-jasmine2/26.6.3: + resolution: {integrity: sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg==} + engines: {node: '>= 10.14.2'} dependencies: '@babel/traverse': 7.13.13 '@jest/environment': 26.6.2 @@ -7970,32 +8882,35 @@ packages: jest-util: 26.6.2 pretty-format: 26.6.2 throat: 5.0.0 - dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg== + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + /jest-leak-detector/26.6.2: + resolution: {integrity: sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg==} + engines: {node: '>= 10.14.2'} dependencies: jest-get-type: 26.3.0 pretty-format: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg== + /jest-matcher-utils/26.6.2: + resolution: {integrity: sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw==} + engines: {node: '>= 10.14.2'} dependencies: chalk: 4.1.0 jest-diff: 26.6.2 jest-get-type: 26.3.0 pretty-format: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw== + /jest-message-util/26.6.2: + resolution: {integrity: sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA==} + engines: {node: '>= 10.14.2'} dependencies: '@babel/code-frame': 7.12.13 '@jest/types': 26.6.2 @@ -8007,62 +8922,56 @@ packages: slash: 3.0.0 stack-utils: 2.0.3 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA== + /jest-mock/26.6.2: + resolution: {integrity: sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 '@types/node': 12.20.7 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew== + /jest-pnp-resolver/1.2.2_jest-resolve@26.6.0: - dependencies: - jest-resolve: 26.6.0 - dev: true - engines: - node: '>=6' + resolution: {integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==} + engines: {node: '>=6'} peerDependencies: jest-resolve: '*' peerDependenciesMeta: jest-resolve: optional: true - resolution: - integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== - /jest-pnp-resolver/1.2.2_jest-resolve@26.6.2: dependencies: - jest-resolve: 26.6.2 + jest-resolve: 26.6.0 dev: true - engines: - node: '>=6' + + /jest-pnp-resolver/1.2.2_jest-resolve@26.6.2: + resolution: {integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==} + engines: {node: '>=6'} peerDependencies: jest-resolve: '*' peerDependenciesMeta: jest-resolve: optional: true - resolution: - integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== + dependencies: + jest-resolve: 26.6.2 + dev: true + /jest-regex-util/26.0.0: + resolution: {integrity: sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==} + engines: {node: '>= 10.14.2'} dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== + /jest-resolve-dependencies/26.6.3: + resolution: {integrity: sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 jest-regex-util: 26.0.0 jest-snapshot: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg== + /jest-resolve/26.6.0: + resolution: {integrity: sha512-tRAz2bwraHufNp+CCmAD8ciyCpXCs1NQxB5EJAmtCFy6BN81loFEGWKzYu26Y62lAJJe4X4jg36Kf+NsQyiStQ==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 chalk: 4.1.0 @@ -8073,11 +8982,10 @@ packages: resolve: 1.18.1 slash: 3.0.0 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-tRAz2bwraHufNp+CCmAD8ciyCpXCs1NQxB5EJAmtCFy6BN81loFEGWKzYu26Y62lAJJe4X4jg36Kf+NsQyiStQ== + /jest-resolve/26.6.2: + resolution: {integrity: sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 chalk: 4.1.0 @@ -8088,11 +8996,10 @@ packages: resolve: 1.18.1 slash: 3.0.0 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ== + /jest-runner/26.6.3: + resolution: {integrity: sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/console': 26.6.2 '@jest/environment': 26.6.2 @@ -8114,12 +9021,18 @@ packages: jest-worker: 26.6.2 source-map-support: 0.5.19 throat: 5.0.0 - dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ== + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + /jest-runtime/26.6.3: + resolution: {integrity: sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw==} + engines: {node: '>= 10.14.2'} + hasBin: true dependencies: '@jest/console': 26.6.2 '@jest/environment': 26.6.2 @@ -8148,22 +9061,25 @@ packages: slash: 3.0.0 strip-bom: 4.0.0 yargs: 15.4.1 - dev: true - engines: - node: '>= 10.14.2' - hasBin: true - resolution: - integrity: sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw== + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + /jest-serializer/26.6.2: + resolution: {integrity: sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==} + engines: {node: '>= 10.14.2'} dependencies: '@types/node': 12.20.7 graceful-fs: 4.2.6 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g== + /jest-snapshot/26.6.2: + resolution: {integrity: sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og==} + engines: {node: '>= 10.14.2'} dependencies: '@babel/types': 7.13.13 '@jest/types': 26.6.2 @@ -8182,11 +9098,20 @@ packages: pretty-format: 26.6.2 semver: 7.3.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og== + + /jest-styled-components/7.0.5_styled-components@5.2.1: + resolution: {integrity: sha512-ZR/r3IKNkgaaVIOThn0Qis4sNQtA352qHjhbxSHeLS3FDIvHSUSJoI2b3kzk+bHHQ1VOeV630usERtnyhyZh4A==} + engines: {node: '>= 12'} + peerDependencies: + styled-components: '>= 5' + dependencies: + css: 3.0.0 + styled-components: 5.2.1_react-dom@17.0.2+react@17.0.2 + dev: true + /jest-util/26.6.2: + resolution: {integrity: sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 '@types/node': 12.20.7 @@ -8195,11 +9120,10 @@ packages: is-ci: 2.0.0 micromatch: 4.0.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q== + /jest-validate/26.6.2: + resolution: {integrity: sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 camelcase: 6.2.0 @@ -8208,11 +9132,12 @@ packages: leven: 3.1.0 pretty-format: 26.6.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ== + /jest-watch-typeahead/0.6.1_jest@26.6.0: + resolution: {integrity: sha512-ITVnHhj3Jd/QkqQcTqZfRgjfyRhDFM/auzgVo2RKvSwi18YMvh0WvXDJFoFED6c7jd/5jxtu4kSOb9PTu2cPVg==} + engines: {node: '>=10'} + peerDependencies: + jest: ^26.0.0 dependencies: ansi-escapes: 4.3.2 chalk: 4.1.0 @@ -8223,13 +9148,10 @@ packages: string-length: 4.0.2 strip-ansi: 6.0.0 dev: true - engines: - node: '>=10' - peerDependencies: - jest: ^26.0.0 - resolution: - integrity: sha512-ITVnHhj3Jd/QkqQcTqZfRgjfyRhDFM/auzgVo2RKvSwi18YMvh0WvXDJFoFED6c7jd/5jxtu4kSOb9PTu2cPVg== + /jest-watcher/26.6.2: + resolution: {integrity: sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ==} + engines: {node: '>= 10.14.2'} dependencies: '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 @@ -8239,64 +9161,71 @@ packages: jest-util: 26.6.2 string-length: 4.0.2 dev: true - engines: - node: '>= 10.14.2' - resolution: - integrity: sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ== + /jest-worker/24.9.0: + resolution: {integrity: sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw==} + engines: {node: '>= 6'} dependencies: merge-stream: 2.0.0 supports-color: 6.1.0 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw== + /jest-worker/26.6.2: + resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} + engines: {node: '>= 10.13.0'} dependencies: '@types/node': 12.20.7 merge-stream: 2.0.0 supports-color: 7.2.0 dev: true - engines: - node: '>= 10.13.0' - resolution: - integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== + /jest/26.6.0: + resolution: {integrity: sha512-jxTmrvuecVISvKFFhOkjsWRZV7sFqdSUAd1ajOKY+/QE/aLBVstsJ/dX8GczLzwiT6ZEwwmZqtCUHLHHQVzcfA==} + engines: {node: '>= 10.14.2'} + hasBin: true dependencies: '@jest/core': 26.6.3 import-local: 3.0.2 jest-cli: 26.6.3 - dev: true - engines: - node: '>= 10.14.2' - hasBin: true - resolution: - integrity: sha512-jxTmrvuecVISvKFFhOkjsWRZV7sFqdSUAd1ajOKY+/QE/aLBVstsJ/dX8GczLzwiT6ZEwwmZqtCUHLHHQVzcfA== + transitivePeerDependencies: + - bufferutil + - canvas + - supports-color + - ts-node + - utf-8-validate + dev: true + /js-cookie/2.2.1: + resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} dev: false - resolution: - integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== + /js-sha3/0.8.0: + resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==} dev: false - resolution: - integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== + /js-tokens/4.0.0: - resolution: - integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + /js-yaml/3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true dependencies: argparse: 1.0.10 esprima: 4.0.1 dev: true - hasBin: true - resolution: - integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + /jsbn/0.1.1: + resolution: {integrity: sha1-peZUwuWi3rXyAdls77yoDA7y9RM=} dev: true - resolution: - integrity: sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + /jsdom/16.5.2: + resolution: {integrity: sha512-JxNtPt9C1ut85boCbJmffaQ06NBnzkQY/MWO3YxPW8IWS38A26z+B1oBvA9LwKrytewdfymnhi4UNH3/RAgZrg==} + engines: {node: '>=10'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true dependencies: abab: 2.0.5 acorn: 8.1.0 @@ -8324,175 +9253,176 @@ packages: whatwg-url: 8.5.0 ws: 7.4.4 xml-name-validator: 3.0.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate dev: true - engines: - node: '>=10' - peerDependencies: - canvas: ^2.5.0 - peerDependenciesMeta: - canvas: - optional: true - resolution: - integrity: sha512-JxNtPt9C1ut85boCbJmffaQ06NBnzkQY/MWO3YxPW8IWS38A26z+B1oBvA9LwKrytewdfymnhi4UNH3/RAgZrg== + /jsesc/0.5.0: - dev: true + resolution: {integrity: sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=} hasBin: true - resolution: - integrity: sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= + dev: true + /jsesc/2.5.2: - engines: - node: '>=4' + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} hasBin: true - resolution: - integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + /json-parse-better-errors/1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} dev: true - resolution: - integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + /json-parse-even-better-errors/2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true - resolution: - integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + /json-schema-traverse/0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true - resolution: - integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + /json-schema-traverse/1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} dev: true - resolution: - integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + /json-schema/0.2.3: + resolution: {integrity: sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=} dev: true - resolution: - integrity: sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + /json-stable-stringify-without-jsonify/1.0.1: + resolution: {integrity: sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=} dev: true - resolution: - integrity: sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= + /json-stringify-safe/5.0.1: + resolution: {integrity: sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=} dev: true - resolution: - integrity: sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= - /json2mq/0.2.0: - dependencies: - string-convert: 0.2.1 - dev: false - resolution: - integrity: sha1-tje9O6nqvhIsg+lyBIOusQ0skEo= + /json3/3.3.3: + resolution: {integrity: sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA==} dev: true - resolution: - integrity: sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== + /json5/1.0.1: + resolution: {integrity: sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==} + hasBin: true dependencies: minimist: 1.2.5 dev: true - hasBin: true - resolution: - integrity: sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + /json5/2.2.0: + resolution: {integrity: sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==} + engines: {node: '>=6'} + hasBin: true dependencies: minimist: 1.2.5 - engines: - node: '>=6' + + /json5/2.2.1: + resolution: {integrity: sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==} + engines: {node: '>=6'} hasBin: true - resolution: - integrity: sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== - /jsonfile/4.0.0: dev: true + + /jsonfile/4.0.0: + resolution: {integrity: sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=} optionalDependencies: - graceful-fs: 4.2.6 - resolution: - integrity: sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + graceful-fs: 4.2.9 + dev: true + /jsonfile/6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} dependencies: universalify: 2.0.0 - dev: true optionalDependencies: - graceful-fs: 4.2.6 - resolution: - integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + graceful-fs: 4.2.9 + dev: true + /jsprim/1.4.1: + resolution: {integrity: sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=} + engines: {'0': node >=0.6.0} dependencies: assert-plus: 1.0.0 extsprintf: 1.3.0 json-schema: 0.2.3 verror: 1.10.0 dev: true - engines: - '0': node >=0.6.0 - resolution: - integrity: sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + /jsx-ast-utils/3.2.0: + resolution: {integrity: sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q==} + engines: {node: '>=4.0'} dependencies: array-includes: 3.1.3 object.assign: 4.1.2 dev: true - engines: - node: '>=4.0' - resolution: - integrity: sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q== + + /katex/0.12.0: + resolution: {integrity: sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg==} + hasBin: true + dependencies: + commander: 2.20.3 + dev: true + + /kebab-case/1.0.1: + resolution: {integrity: sha512-txPHx6nVLhv8PHGXIlAk0nYoh894SpAqGPXNvbg2hh8spvHXIah3+vT87DLoa59nKgC6scD3u3xAuRIgiMqbfQ==} + dev: true + /keyboardjs/2.6.4: + resolution: {integrity: sha512-xDiNwiwH3KUqap++RFJiLAXzbvRB5Yw08xliuceOgLhM1o7g1puKKR9vWy6wp9H/Bi4VP0+SQMpiWXMWWmR6rA==} dev: false - resolution: - integrity: sha512-xDiNwiwH3KUqap++RFJiLAXzbvRB5Yw08xliuceOgLhM1o7g1puKKR9vWy6wp9H/Bi4VP0+SQMpiWXMWWmR6rA== + /killable/1.0.1: + resolution: {integrity: sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg==} dev: true - resolution: - integrity: sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg== + /kind-of/3.2.2: + resolution: {integrity: sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=} + engines: {node: '>=0.10.0'} dependencies: is-buffer: 1.1.6 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + /kind-of/4.0.0: + resolution: {integrity: sha1-IIE989cSkosgc3hpGkUGb65y3Vc=} + engines: {node: '>=0.10.0'} dependencies: is-buffer: 1.1.6 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + /kind-of/5.1.0: - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + resolution: {integrity: sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==} + engines: {node: '>=0.10.0'} + /kind-of/6.0.3: - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + /kleur/3.0.3: - engines: - node: '>=6' - resolution: - integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + /klona/2.0.4: + resolution: {integrity: sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==} + engines: {node: '>= 8'} dev: true - engines: - node: '>= 8' - resolution: - integrity: sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA== + /language-subtag-registry/0.3.21: + resolution: {integrity: sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==} dev: true - resolution: - integrity: sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg== + /language-tags/1.0.5: + resolution: {integrity: sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=} dependencies: language-subtag-registry: 0.3.21 dev: true - resolution: - integrity: sha1-0yHbxNowuovzAk4ED6XBRmH5GTo= + /last-call-webpack-plugin/3.0.0: + resolution: {integrity: sha512-7KI2l2GIZa9p2spzPIVZBYyNKkN+e/SQPpnjlTiPhdbDW3F86tdKKELxKpzJ5sgU19wQWsACULZmpTPYHeWO5w==} dependencies: lodash: 4.17.21 webpack-sources: 1.4.3 dev: true - resolution: - integrity: sha512-7KI2l2GIZa9p2spzPIVZBYyNKkN+e/SQPpnjlTiPhdbDW3F86tdKKELxKpzJ5sgU19wQWsACULZmpTPYHeWO5w== + /less-loader/7.3.0_less@3.13.1+webpack@4.44.2: + resolution: {integrity: sha512-Mi8915g7NMaLlgi77mgTTQvK022xKRQBIVDSyfl3ErTuBhmZBQab0mjeJjNNqGbdR+qrfTleKXqbGI4uEFavxg==} + engines: {node: '>= 10.13.0'} + peerDependencies: + less: ^3.5.0 || ^4.0.0 + webpack: ^4.0.0 || ^5.0.0 dependencies: klona: 2.0.4 less: 3.13.1 @@ -8500,68 +9430,59 @@ packages: schema-utils: 3.0.0 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>= 10.13.0' - peerDependencies: - less: ^3.5.0 || ^4.0.0 - webpack: ^4.0.0 || ^5.0.0 - resolution: - integrity: sha512-Mi8915g7NMaLlgi77mgTTQvK022xKRQBIVDSyfl3ErTuBhmZBQab0mjeJjNNqGbdR+qrfTleKXqbGI4uEFavxg== + /less-vars-to-js/1.3.0: + resolution: {integrity: sha512-xeiLLn/IMCGtdyCkYQnW8UuzoW2oYMCKg9boZRaGI58fLz5r90bNJDlqGzmVt/1Uqk75/DxIVtQSNCMkE5fRZQ==} + engines: {node: '>=8'} dependencies: strip-json-comments: 2.0.1 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-xeiLLn/IMCGtdyCkYQnW8UuzoW2oYMCKg9boZRaGI58fLz5r90bNJDlqGzmVt/1Uqk75/DxIVtQSNCMkE5fRZQ== + /less/3.13.1: + resolution: {integrity: sha512-SwA1aQXGUvp+P5XdZslUOhhLnClSLIjWvJhmd+Vgib5BFIr9lMNlQwmwUNOjXThF/A0x+MCYYPeWEfeWiLRnTw==} + engines: {node: '>=6'} + hasBin: true dependencies: copy-anything: 2.0.3 tslib: 1.14.1 - dev: true - engines: - node: '>=6' - hasBin: true optionalDependencies: errno: 0.1.8 - graceful-fs: 4.2.6 + graceful-fs: 4.2.9 image-size: 0.5.5 make-dir: 2.1.0 mime: 1.6.0 - native-request: 1.0.8 + native-request: 1.1.0 source-map: 0.6.1 - resolution: - integrity: sha512-SwA1aQXGUvp+P5XdZslUOhhLnClSLIjWvJhmd+Vgib5BFIr9lMNlQwmwUNOjXThF/A0x+MCYYPeWEfeWiLRnTw== + dev: true + /leven/3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + /levn/0.3.0: + resolution: {integrity: sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=} + engines: {node: '>= 0.8.0'} dependencies: prelude-ls: 1.1.2 type-check: 0.3.2 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + /levn/0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + /lines-and-columns/1.1.6: + resolution: {integrity: sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=} dev: true - resolution: - integrity: sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= + /lint-staged/10.5.4: + resolution: {integrity: sha512-EechC3DdFic/TdOPgj/RB3FicqE6932LTHCUm0Y2fsD9KGlLB+RwJl2q1IYBIvEsKzDOgn0D4gll+YxG5RsrKg==} + hasBin: true dependencies: chalk: 4.1.0 cli-truncate: 2.1.0 @@ -8578,11 +9499,15 @@ packages: please-upgrade-node: 3.2.0 string-argv: 0.3.1 stringify-object: 3.3.0 + transitivePeerDependencies: + - supports-color dev: true - hasBin: true - resolution: - integrity: sha512-EechC3DdFic/TdOPgj/RB3FicqE6932LTHCUm0Y2fsD9KGlLB+RwJl2q1IYBIvEsKzDOgn0D4gll+YxG5RsrKg== + /listr2/3.4.3_enquirer@2.3.6: + resolution: {integrity: sha512-wZmkzNiuinOfwrGqAwTCcPw6aKQGTAMGXwG5xeU1WpDjJNeBA35jGBeWxR3OF+R6Yl5Y3dRG+3vE8t6PDcSNHA==} + engines: {node: '>=10.0.0'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' dependencies: chalk: 4.1.0 cli-truncate: 2.1.0 @@ -8595,300 +9520,494 @@ packages: through: 2.3.8 wrap-ansi: 7.0.0 dev: true - engines: - node: '>=10.0.0' - peerDependencies: - enquirer: '>= 2.3.0 < 3' - resolution: - integrity: sha512-wZmkzNiuinOfwrGqAwTCcPw6aKQGTAMGXwG5xeU1WpDjJNeBA35jGBeWxR3OF+R6Yl5Y3dRG+3vE8t6PDcSNHA== + /load-json-file/2.0.0: + resolution: {integrity: sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=} + engines: {node: '>=4'} dependencies: graceful-fs: 4.2.6 parse-json: 2.2.0 pify: 2.3.0 strip-bom: 3.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= + /loader-runner/2.4.0: + resolution: {integrity: sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==} + engines: {node: '>=4.3.0 <5.0.0 || >=5.10'} dev: true - engines: - node: '>=4.3.0 <5.0.0 || >=5.10' - resolution: - integrity: sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== + /loader-utils/1.2.3: + resolution: {integrity: sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==} + engines: {node: '>=4.0.0'} dependencies: big.js: 5.2.2 emojis-list: 2.1.0 json5: 1.0.1 dev: true - engines: - node: '>=4.0.0' - resolution: - integrity: sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA== + /loader-utils/1.4.0: + resolution: {integrity: sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==} + engines: {node: '>=4.0.0'} dependencies: big.js: 5.2.2 emojis-list: 3.0.0 json5: 1.0.1 dev: true - engines: - node: '>=4.0.0' - resolution: - integrity: sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + /loader-utils/2.0.0: + resolution: {integrity: sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==} + engines: {node: '>=8.9.0'} dependencies: big.js: 5.2.2 emojis-list: 3.0.0 json5: 2.2.0 - engines: - node: '>=8.9.0' - resolution: - integrity: sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ== + + /loader-utils/2.0.4: + resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} + engines: {node: '>=8.9.0'} + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.1 + dev: true + /locate-path/2.0.0: + resolution: {integrity: sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=} + engines: {node: '>=4'} dependencies: p-locate: 2.0.0 path-exists: 3.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= + /locate-path/3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} dependencies: p-locate: 3.0.0 path-exists: 3.0.0 - engines: - node: '>=6' - resolution: - integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + /locate-path/5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} dependencies: p-locate: 4.1.0 - engines: - node: '>=8' - resolution: - integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + /lodash-es/4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} dev: false - resolution: - integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + /lodash._reinterpolate/3.0.0: + resolution: {integrity: sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=} dev: true - resolution: - integrity: sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= + /lodash.clonedeep/4.5.0: + resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=} dev: true - resolution: - integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= + /lodash.debounce/4.0.8: + resolution: {integrity: sha1-gteb/zCmfEAF/9XiUVMArZyk168=} dev: true - resolution: - integrity: sha1-gteb/zCmfEAF/9XiUVMArZyk168= + /lodash.flatten/4.4.0: + resolution: {integrity: sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=} dev: true - resolution: - integrity: sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + /lodash.memoize/4.1.2: + resolution: {integrity: sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=} dev: true - resolution: - integrity: sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= + /lodash.template/4.5.0: + resolution: {integrity: sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==} dependencies: lodash._reinterpolate: 3.0.0 lodash.templatesettings: 4.2.0 dev: true - resolution: - integrity: sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A== + /lodash.templatesettings/4.2.0: + resolution: {integrity: sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==} dependencies: lodash._reinterpolate: 3.0.0 dev: true - resolution: - integrity: sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ== + + /lodash.throttle/4.1.1: + resolution: {integrity: sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=} + dev: true + /lodash.truncate/4.4.2: + resolution: {integrity: sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=} dev: true - resolution: - integrity: sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= + /lodash.uniq/4.5.0: + resolution: {integrity: sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=} dev: true - resolution: - integrity: sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= + /lodash/4.17.21: - resolution: - integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + /log-symbols/4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} dependencies: chalk: 4.1.0 is-unicode-supported: 0.1.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + /log-update/4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} dependencies: ansi-escapes: 4.3.2 cli-cursor: 3.1.0 slice-ansi: 4.0.0 wrap-ansi: 6.2.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg== + /loglevel/1.7.1: + resolution: {integrity: sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==} + engines: {node: '>= 0.6.0'} + dev: true + + /longest-streak/2.0.4: + resolution: {integrity: sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==} dev: true - engines: - node: '>= 0.6.0' - resolution: - integrity: sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw== + /loose-envify/1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true dependencies: js-tokens: 4.0.0 - hasBin: true - resolution: - integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - /lower-case/1.1.4: - dev: false - resolution: - integrity: sha1-miyr0bno4K6ZOkv31YdcOcQujqw= + /lower-case/2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} dependencies: - tslib: 2.1.0 - dev: true - resolution: - integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + tslib: 2.4.1 + /lru-cache/5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: yallist: 3.1.1 dev: true - resolution: - integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + /lru-cache/6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} dependencies: yallist: 4.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + /lz-string/1.4.4: - dev: true + resolution: {integrity: sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=} hasBin: true - resolution: - integrity: sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= + dev: true + /magic-string/0.25.7: + resolution: {integrity: sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==} dependencies: sourcemap-codec: 1.4.8 dev: true - resolution: - integrity: sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== + /make-dir/2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} dependencies: pify: 4.0.1 semver: 5.7.1 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== + /make-dir/3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} dependencies: semver: 6.3.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + /makeerror/1.0.11: + resolution: {integrity: sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=} dependencies: tmpl: 1.0.4 dev: true - resolution: - integrity: sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= + /map-cache/0.2.2: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + resolution: {integrity: sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=} + engines: {node: '>=0.10.0'} + /map-visit/1.0.0: + resolution: {integrity: sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=} + engines: {node: '>=0.10.0'} dependencies: object-visit: 1.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + + /markdown-table/2.0.0: + resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + dependencies: + repeat-string: 1.6.1 + dev: true + /match-sorter/6.3.0: + resolution: {integrity: sha512-efYOf/wUpNb8FgNY+cOD2EIJI1S5I7YPKsw0LBp7wqPh5pmMS6i/wr3ZWwfwrAw1NvqTA2KUReVRWDX84lUcOQ==} dependencies: '@babel/runtime': 7.13.10 remove-accents: 0.4.2 dev: false - resolution: - integrity: sha512-efYOf/wUpNb8FgNY+cOD2EIJI1S5I7YPKsw0LBp7wqPh5pmMS6i/wr3ZWwfwrAw1NvqTA2KUReVRWDX84lUcOQ== + + /mathjax-full/3.2.0: + resolution: {integrity: sha512-D2EBNvUG+mJyhn+M1C858k0f2Fc4KxXvbEX2WCMXroV10212JwfYqaBJ336ECBSz5X9L5LRoamxb7AJtg3KaJA==} + dependencies: + esm: 3.2.25 + mhchemparser: 4.1.1 + mj-context-menu: 0.6.1 + speech-rule-engine: 3.3.3 + dev: true + /md5.js/1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} dependencies: hash-base: 3.1.0 inherits: 2.0.4 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + + /mdast-util-definitions/4.0.0: + resolution: {integrity: sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==} + dependencies: + unist-util-visit: 2.0.3 + dev: true + + /mdast-util-find-and-replace/1.1.1: + resolution: {integrity: sha512-9cKl33Y21lyckGzpSmEQnIDjEfeeWelN5s1kUW1LwdB0Fkuq2u+4GdqcGEygYxJE8GVqCl0741bYXHgamfWAZA==} + dependencies: + escape-string-regexp: 4.0.0 + unist-util-is: 4.1.0 + unist-util-visit-parents: 3.1.1 + dev: true + + /mdast-util-from-markdown/0.8.5: + resolution: {integrity: sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==} + dependencies: + '@types/mdast': 3.0.10 + mdast-util-to-string: 2.0.0 + micromark: 2.11.4 + parse-entities: 2.0.0 + unist-util-stringify-position: 2.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /mdast-util-frontmatter/0.2.0: + resolution: {integrity: sha512-FHKL4w4S5fdt1KjJCwB0178WJ0evnyyQr5kXTM3wrOVpytD0hrkvd+AOOjU9Td8onOejCkmZ+HQRT3CZ3coHHQ==} + dependencies: + micromark-extension-frontmatter: 0.2.2 + dev: true + + /mdast-util-gfm-autolink-literal/0.1.3: + resolution: {integrity: sha512-GjmLjWrXg1wqMIO9+ZsRik/s7PLwTaeCHVB7vRxUwLntZc8mzmTsLVr6HW1yLokcnhfURsn5zmSVdi3/xWWu1A==} + dependencies: + ccount: 1.1.0 + mdast-util-find-and-replace: 1.1.1 + micromark: 2.11.4 + transitivePeerDependencies: + - supports-color + dev: true + + /mdast-util-gfm-strikethrough/0.2.3: + resolution: {integrity: sha512-5OQLXpt6qdbttcDG/UxYY7Yjj3e8P7X16LzvpX8pIQPYJ/C2Z1qFGMmcw+1PZMUM3Z8wt8NRfYTvCni93mgsgA==} + dependencies: + mdast-util-to-markdown: 0.6.5 + dev: true + + /mdast-util-gfm-table/0.1.6: + resolution: {integrity: sha512-j4yDxQ66AJSBwGkbpFEp9uG/LS1tZV3P33fN1gkyRB2LoRL+RR3f76m0HPHaby6F4Z5xr9Fv1URmATlRRUIpRQ==} + dependencies: + markdown-table: 2.0.0 + mdast-util-to-markdown: 0.6.5 + dev: true + + /mdast-util-gfm-task-list-item/0.1.6: + resolution: {integrity: sha512-/d51FFIfPsSmCIRNp7E6pozM9z1GYPIkSy1urQ8s/o4TC22BZ7DqfHFWiqBD23bc7J3vV1Fc9O4QIHBlfuit8A==} + dependencies: + mdast-util-to-markdown: 0.6.5 + dev: true + + /mdast-util-gfm/0.1.2: + resolution: {integrity: sha512-NNkhDx/qYcuOWB7xHUGWZYVXvjPFFd6afg6/e2g+SV4r9q5XUcCbV4Wfa3DLYIiD+xAEZc6K4MGaE/m0KDcPwQ==} + dependencies: + mdast-util-gfm-autolink-literal: 0.1.3 + mdast-util-gfm-strikethrough: 0.2.3 + mdast-util-gfm-table: 0.1.6 + mdast-util-gfm-task-list-item: 0.1.6 + mdast-util-to-markdown: 0.6.5 + transitivePeerDependencies: + - supports-color + dev: true + + /mdast-util-math/0.1.2: + resolution: {integrity: sha512-fogAitds+wH+QRas78Yr1TwmQGN4cW/G2WRw5ePuNoJbBSPJCxIOCE8MTzHgWHVSpgkRaPQTgfzXRE1CrwWSlg==} + dependencies: + longest-streak: 2.0.4 + mdast-util-to-markdown: 0.6.5 + repeat-string: 1.6.1 + dev: true + + /mdast-util-to-hast/10.2.0: + resolution: {integrity: sha512-JoPBfJ3gBnHZ18icCwHR50orC9kNH81tiR1gs01D8Q5YpV6adHNO9nKNuFBCJQ941/32PT1a63UF/DitmS3amQ==} + dependencies: + '@types/mdast': 3.0.10 + '@types/unist': 2.0.6 + mdast-util-definitions: 4.0.0 + mdurl: 1.0.1 + unist-builder: 2.0.3 + unist-util-generated: 1.1.6 + unist-util-position: 3.1.0 + unist-util-visit: 2.0.3 + dev: true + + /mdast-util-to-markdown/0.6.5: + resolution: {integrity: sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==} + dependencies: + '@types/unist': 2.0.6 + longest-streak: 2.0.4 + mdast-util-to-string: 2.0.0 + parse-entities: 2.0.0 + repeat-string: 1.6.1 + zwitch: 1.0.5 + dev: true + + /mdast-util-to-string/2.0.0: + resolution: {integrity: sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==} + dev: true + /mdn-data/2.0.14: - resolution: - integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + /mdn-data/2.0.4: + resolution: {integrity: sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==} dev: true - resolution: - integrity: sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== + + /mdurl/1.0.1: + resolution: {integrity: sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=} + dev: true + /media-typer/0.3.0: + resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= + /memory-fs/0.4.1: + resolution: {integrity: sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=} dependencies: errno: 0.1.8 readable-stream: 2.3.7 dev: true - resolution: - integrity: sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= + /memory-fs/0.5.0: + resolution: {integrity: sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==} + engines: {node: '>=4.3.0 <5.0.0 || >=5.10'} dependencies: errno: 0.1.8 readable-stream: 2.3.7 dev: true - engines: - node: '>=4.3.0 <5.0.0 || >=5.10' - resolution: - integrity: sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA== + /merge-descriptors/1.0.1: + resolution: {integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=} dev: true - resolution: - integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= + /merge-stream/2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: true - resolution: - integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + /merge2/1.4.1: - engines: - node: '>= 8' - resolution: - integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + /methods/1.1.2: + resolution: {integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=} + engines: {node: '>= 0.6'} + dev: true + + /mhchemparser/4.1.1: + resolution: {integrity: sha512-R75CUN6O6e1t8bgailrF1qPq+HhVeFTM3XQ0uzI+mXTybmphy3b6h4NbLOYhemViQ3lUs+6CKRkC3Ws1TlYREA==} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + /microevent.ts/0.1.1: + resolution: {integrity: sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g==} dev: false - resolution: - integrity: sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== + + /micromark-extension-frontmatter/0.2.2: + resolution: {integrity: sha512-q6nPLFCMTLtfsctAuS0Xh4vaolxSFUWUWR6PZSrXXiRy+SANGllpcqdXFv2z07l0Xz/6Hl40hK0ffNCJPH2n1A==} + dependencies: + fault: 1.0.4 + dev: true + + /micromark-extension-gfm-autolink-literal/0.5.7: + resolution: {integrity: sha512-ePiDGH0/lhcngCe8FtH4ARFoxKTUelMp4L7Gg2pujYD5CSMb9PbblnyL+AAMud/SNMyusbS2XDSiPIRcQoNFAw==} + dependencies: + micromark: 2.11.4 + transitivePeerDependencies: + - supports-color + dev: true + + /micromark-extension-gfm-strikethrough/0.6.5: + resolution: {integrity: sha512-PpOKlgokpQRwUesRwWEp+fHjGGkZEejj83k9gU5iXCbDG+XBA92BqnRKYJdfqfkrRcZRgGuPuXb7DaK/DmxOhw==} + dependencies: + micromark: 2.11.4 + transitivePeerDependencies: + - supports-color + dev: true + + /micromark-extension-gfm-table/0.4.3: + resolution: {integrity: sha512-hVGvESPq0fk6ALWtomcwmgLvH8ZSVpcPjzi0AjPclB9FsVRgMtGZkUcpE0zgjOCFAznKepF4z3hX8z6e3HODdA==} + dependencies: + micromark: 2.11.4 + transitivePeerDependencies: + - supports-color + dev: true + + /micromark-extension-gfm-tagfilter/0.3.0: + resolution: {integrity: sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q==} + dev: true + + /micromark-extension-gfm-task-list-item/0.3.3: + resolution: {integrity: sha512-0zvM5iSLKrc/NQl84pZSjGo66aTGd57C1idmlWmE87lkMcXrTxg1uXa/nXomxJytoje9trP0NDLvw4bZ/Z/XCQ==} + dependencies: + micromark: 2.11.4 + transitivePeerDependencies: + - supports-color + dev: true + + /micromark-extension-gfm/0.3.3: + resolution: {integrity: sha512-oVN4zv5/tAIA+l3GbMi7lWeYpJ14oQyJ3uEim20ktYFAcfX1x3LNlFGGlmrZHt7u9YlKExmyJdDGaTt6cMSR/A==} + dependencies: + micromark: 2.11.4 + micromark-extension-gfm-autolink-literal: 0.5.7 + micromark-extension-gfm-strikethrough: 0.6.5 + micromark-extension-gfm-table: 0.4.3 + micromark-extension-gfm-tagfilter: 0.3.0 + micromark-extension-gfm-task-list-item: 0.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /micromark-extension-math/0.1.2: + resolution: {integrity: sha512-ZJXsT2eVPM8VTmcw0CPSDeyonOn9SziGK3Z+nkf9Vb6xMPeU+4JMEnO6vzDL10562Favw8Vste74f54rxJ/i6Q==} + dependencies: + katex: 0.12.0 + micromark: 2.11.4 + transitivePeerDependencies: + - supports-color + dev: true + + /micromark/2.11.4: + resolution: {integrity: sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==} + dependencies: + debug: 4.3.1 + parse-entities: 2.0.0 + transitivePeerDependencies: + - supports-color + dev: true + /micromatch/3.1.10: + resolution: {integrity: sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==} + engines: {node: '>=0.10.0'} dependencies: arr-diff: 4.0.0 array-unique: 0.3.2 @@ -8903,83 +10022,107 @@ packages: regex-not: 1.0.2 snapdragon: 0.8.2 to-regex: 3.0.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + /micromatch/4.0.2: + resolution: {integrity: sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==} + engines: {node: '>=8'} dependencies: braces: 3.0.2 picomatch: 2.2.2 - engines: - node: '>=8' - resolution: - integrity: sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + + /micromatch/4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + /microseconds/0.2.0: + resolution: {integrity: sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==} dev: false - resolution: - integrity: sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA== + /miller-rabin/4.0.1: + resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} + hasBin: true dependencies: bn.js: 4.12.0 brorand: 1.1.0 dev: true - hasBin: true - resolution: - integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== + /mime-db/1.46.0: + resolution: {integrity: sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ== + /mime-types/2.1.29: + resolution: {integrity: sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==} + engines: {node: '>= 0.6'} dependencies: mime-db: 1.46.0 dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ== - /mime/1.6.0: + + /mime/1.3.6: + resolution: {integrity: sha1-WR2E02U6awtKO5343lqoEI5y5eA=} + hasBin: true dev: true - engines: - node: '>=4' + + /mime/1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} hasBin: true - resolution: - integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - /mime/2.5.2: dev: true - engines: - node: '>=4.0.0' + + /mime/2.5.2: + resolution: {integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==} + engines: {node: '>=4.0.0'} hasBin: true - resolution: - integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== + dev: true + /mimic-fn/2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + + /min-document/2.19.0: + resolution: {integrity: sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=} + dependencies: + dom-walk: 0.1.2 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + /min-indent/1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== - /mini-create-react-context/0.4.1_prop-types@15.7.2+react@17.0.2: + + /mini-create-react-context/0.4.1_prop-types@15.7.2+react@16.14.0: + resolution: {integrity: sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==} + peerDependencies: + prop-types: ^15.0.0 + react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: '@babel/runtime': 7.13.10 prop-types: 15.7.2 - react: 17.0.2 + react: 16.14.0 tiny-warning: 1.0.3 - dev: false + dev: true + + /mini-create-react-context/0.4.1_prop-types@15.7.2+react@17.0.2: + resolution: {integrity: sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==} peerDependencies: prop-types: ^15.0.0 react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - resolution: - integrity: sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ== + dependencies: + '@babel/runtime': 7.13.10 + prop-types: 15.7.2 + react: 17.0.2 + tiny-warning: 1.0.3 + /mini-css-extract-plugin/0.11.3_webpack@4.44.2: + resolution: {integrity: sha512-n9BA8LonkOkW1/zn+IbLPQmovsL0wMb9yx75fMJQZf2X1Zoec9yTZtyMePcyu19wPkmFbzZZA6fLTotpFhQsOA==} + engines: {node: '>= 6.9.0'} + peerDependencies: + webpack: ^4.4.0 || ^5.0.0 dependencies: loader-utils: 1.4.0 normalize-url: 1.9.1 @@ -8987,82 +10130,73 @@ packages: webpack: 4.44.2_webpack-cli@4.6.0 webpack-sources: 1.4.3 dev: true - engines: - node: '>= 6.9.0' - peerDependencies: - webpack: ^4.4.0 || ^5.0.0 - resolution: - integrity: sha512-n9BA8LonkOkW1/zn+IbLPQmovsL0wMb9yx75fMJQZf2X1Zoec9yTZtyMePcyu19wPkmFbzZZA6fLTotpFhQsOA== + /mini-store/3.0.6_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-YzffKHbYsMQGUWQRKdsearR79QsMzzJcDDmZKlJBqt5JNkqpyJHYlK6gP61O36X+sLf76sO9G6mhKBe83gIZIQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' dependencies: hoist-non-react-statics: 3.3.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 shallowequal: 1.1.0 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-YzffKHbYsMQGUWQRKdsearR79QsMzzJcDDmZKlJBqt5JNkqpyJHYlK6gP61O36X+sLf76sO9G6mhKBe83gIZIQ== + /minimalistic-assert/1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} dev: true - resolution: - integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + /minimalistic-crypto-utils/1.0.1: + resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=} dev: true - resolution: - integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= + /minimatch/3.0.4: + resolution: {integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==} dependencies: brace-expansion: 1.1.11 - resolution: - integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + /minimist/1.2.5: - resolution: - integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + resolution: {integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==} + /minipass-collect/1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} dependencies: minipass: 3.1.3 dev: true - engines: - node: '>= 8' - resolution: - integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== + /minipass-flush/1.0.5: + resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + engines: {node: '>= 8'} dependencies: minipass: 3.1.3 dev: true - engines: - node: '>= 8' - resolution: - integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== + /minipass-pipeline/1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} dependencies: minipass: 3.1.3 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== + /minipass/3.1.3: + resolution: {integrity: sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==} + engines: {node: '>=8'} dependencies: yallist: 4.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg== + /minizlib/2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} dependencies: minipass: 3.1.3 yallist: 4.0.0 dev: true - engines: - node: '>= 8' - resolution: - integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + /mississippi/3.0.0: + resolution: {integrity: sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==} + engines: {node: '>=4.0.0'} dependencies: concat-stream: 1.6.2 duplexify: 3.7.1 @@ -9075,37 +10209,52 @@ packages: stream-each: 1.2.3 through2: 2.0.5 dev: true - engines: - node: '>=4.0.0' - resolution: - integrity: sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA== + /mixin-deep/1.3.2: + resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} + engines: {node: '>=0.10.0'} dependencies: for-in: 1.0.2 is-extendable: 1.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + + /mj-context-menu/0.6.1: + resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + dev: true + /mkdirp/0.5.5: + resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==} + hasBin: true dependencies: minimist: 1.2.5 dev: true - hasBin: true - resolution: - integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + /mkdirp/1.0.4: - dev: true - engines: - node: '>=10' + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} hasBin: true - resolution: - integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + dev: true + /moment/2.29.1: - dev: false - resolution: - integrity: sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== - /move-concurrently/1.0.1: + resolution: {integrity: sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==} + dev: true + + /monaco-editor-webpack-plugin/7.0.1_d758ab496f5c143a3f97c41b30684737: + resolution: {integrity: sha512-M8qIqizltrPlIbrb73cZdTWfU9sIsUVFvAZkL3KGjAHmVWEJ0hZKa/uad14JuOckc0GwnCaoGHvMoYtJjVyCzw==} + peerDependencies: + monaco-editor: '>= 0.31.0' + webpack: ^4.5.0 || 5.x + dependencies: + loader-utils: 2.0.4 + monaco-editor: 0.34.1 + webpack: 4.44.2_webpack-cli@4.6.0 + dev: true + + /monaco-editor/0.34.1: + resolution: {integrity: sha512-FKc80TyiMaruhJKKPz5SpJPIjL+dflGvz4CpuThaPMc94AyN7SeC9HQ8hrvaxX7EyHdJcUY5i4D0gNyJj1vSZQ==} + dev: false + + /move-concurrently/1.0.1: + resolution: {integrity: sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=} dependencies: aproba: 1.2.0 copy-concurrently: 1.0.5 @@ -9114,40 +10263,47 @@ packages: rimraf: 2.7.1 run-queue: 1.0.3 dev: true - resolution: - integrity: sha1-viwAX9oy4LKa8fBdfEszIUxwH5I= + + /mpld3/0.5.2: + resolution: {integrity: sha512-9Asjh2evbVnbDn3x7ubVEZJ06v9Gl+DDKixLmaTwBu4Zy5M6vj7A9jv3ZVYoM8pMfEmT+VD5ot/m5DJItx29vg==} + dev: false + /ms/2.0.0: - resolution: - integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=} + /ms/2.1.1: + resolution: {integrity: sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==} dev: true - resolution: - integrity: sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== + /ms/2.1.2: - resolution: - integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + /ms/2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true - resolution: - integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + /multicast-dns-service-types/1.1.0: + resolution: {integrity: sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=} dev: true - resolution: - integrity: sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE= + /multicast-dns/6.2.3: + resolution: {integrity: sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g==} + hasBin: true dependencies: dns-packet: 1.3.1 thunky: 1.1.0 dev: true - hasBin: true - resolution: - integrity: sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g== + /nan/2.14.2: + resolution: {integrity: sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==} dev: true optional: true - resolution: - integrity: sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== + /nano-css/5.3.1_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-ENPIyNzANQRyYVvb62ajDd7PAyIgS2LIUnT9ewih4yrXSZX4hKoUwssy8WjUH++kEOA5wUTMgNnV7ko5n34kUA==} + peerDependencies: + react: '*' + react-dom: '*' dependencies: css-tree: 1.1.2 csstype: 3.0.7 @@ -9160,25 +10316,22 @@ packages: stacktrace-js: 2.0.2 stylis: 4.0.9 dev: false - peerDependencies: - react: '*' - react-dom: '*' - resolution: - integrity: sha512-ENPIyNzANQRyYVvb62ajDd7PAyIgS2LIUnT9ewih4yrXSZX4hKoUwssy8WjUH++kEOA5wUTMgNnV7ko5n34kUA== + /nano-time/1.0.0: + resolution: {integrity: sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=} dependencies: big-integer: 1.6.48 dev: false - resolution: - integrity: sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8= + /nanoid/3.1.22: - dev: true - engines: - node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 + resolution: {integrity: sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - resolution: - integrity: sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ== + dev: true + /nanomatch/1.2.13: + resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} + engines: {node: '>=0.10.0'} dependencies: arr-diff: 4.0.0 array-unique: 0.3.2 @@ -9191,67 +10344,57 @@ packages: regex-not: 1.0.2 snapdragon: 0.8.2 to-regex: 3.0.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== - /native-request/1.0.8: + + /native-request/1.1.0: + resolution: {integrity: sha512-uZ5rQaeRn15XmpgE0xoPL8YWqcX90VtCFglYwAgkvKM5e8fog+vePLAhHxuuv/gRkrQxIeh5U3q9sMNUrENqWw==} + requiresBuild: true dev: true optional: true - resolution: - integrity: sha512-vU2JojJVelUGp6jRcLwToPoWGxSx23z/0iX+I77J3Ht17rf2INGjrhOoQnjVo60nQd8wVsgzKkPfRXBiVdD2ag== + /native-url/0.2.6: + resolution: {integrity: sha512-k4bDC87WtgrdD362gZz6zoiXQrl40kYlBmpfmSjwRO1VU0V5ccwJTlxuE72F6m3V0vc1xOf6n3UCP9QyerRqmA==} dependencies: querystring: 0.2.1 dev: true - resolution: - integrity: sha512-k4bDC87WtgrdD362gZz6zoiXQrl40kYlBmpfmSjwRO1VU0V5ccwJTlxuE72F6m3V0vc1xOf6n3UCP9QyerRqmA== + /natural-compare/1.4.0: + resolution: {integrity: sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=} dev: true - resolution: - integrity: sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + /negotiator/0.6.2: + resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== + /neo-async/2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} dev: true - resolution: - integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + /next-tick/1.0.0: + resolution: {integrity: sha1-yobR/ogoFpsBICCOPchCS524NCw=} dev: true - resolution: - integrity: sha1-yobR/ogoFpsBICCOPchCS524NCw= + /nice-try/1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} dev: true - resolution: - integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - /no-case/2.3.2: - dependencies: - lower-case: 1.1.4 - dev: false - resolution: - integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ== + /no-case/3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 - tslib: 2.1.0 - dev: true - resolution: - integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== + tslib: 2.4.1 + /node-forge/0.10.0: + resolution: {integrity: sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==} + engines: {node: '>= 6.0.0'} dev: true - engines: - node: '>= 6.0.0' - resolution: - integrity: sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== + /node-int64/0.4.0: + resolution: {integrity: sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=} dev: true - resolution: - integrity: sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= + /node-libs-browser/2.2.1: + resolution: {integrity: sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==} dependencies: assert: 1.5.0 browserify-zlib: 0.2.0 @@ -9277,15 +10420,15 @@ packages: util: 0.11.1 vm-browserify: 1.1.2 dev: true - resolution: - integrity: sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== + /node-modules-regexp/1.0.0: + resolution: {integrity: sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= + /node-notifier/8.0.2: + resolution: {integrity: sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==} + requiresBuild: true dependencies: growly: 1.3.0 is-wsl: 2.2.0 @@ -9295,251 +10438,236 @@ packages: which: 2.0.2 dev: true optional: true - resolution: - integrity: sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg== + /node-releases/1.1.71: - resolution: - integrity: sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg== + resolution: {integrity: sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg==} + /normalize-package-data/2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} dependencies: hosted-git-info: 2.8.8 resolve: 1.18.1 semver: 5.7.1 validate-npm-package-license: 3.0.4 dev: true - resolution: - integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + /normalize-path/2.1.1: + resolution: {integrity: sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=} + engines: {node: '>=0.10.0'} dependencies: remove-trailing-separator: 1.1.0 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + /normalize-range/0.1.2: + resolution: {integrity: sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= + /normalize-url/1.9.1: + resolution: {integrity: sha1-LMDWazHqIwNkWENuNiDYWVTGbDw=} + engines: {node: '>=4'} dependencies: object-assign: 4.1.1 prepend-http: 1.0.4 query-string: 4.3.4 sort-keys: 1.1.2 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= + /normalize-url/3.3.0: + resolution: {integrity: sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== + /npm-run-path/2.0.2: + resolution: {integrity: sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=} + engines: {node: '>=4'} dependencies: path-key: 2.0.1 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + /npm-run-path/4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} dependencies: path-key: 3.1.1 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + /nth-check/1.0.2: + resolution: {integrity: sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==} dependencies: boolbase: 1.0.0 dev: true - resolution: - integrity: sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + /num2fraction/1.2.2: + resolution: {integrity: sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=} dev: true - resolution: - integrity: sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4= + + /number-precision/1.6.0: + resolution: {integrity: sha512-05OLPgbgmnixJw+VvEh18yNPUo3iyp4BEWJcrLu4X9W05KmMifN7Mu5exYvQXqxxeNWhvIF+j3Rij+HmddM/hQ==} + dev: false + /nwsapi/2.2.0: + resolution: {integrity: sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==} dev: true - resolution: - integrity: sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== + /oauth-sign/0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} dev: true - resolution: - integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + /object-assign/4.1.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + /object-copy/0.1.0: + resolution: {integrity: sha1-fn2Fi3gb18mRpBupde04EnVOmYw=} + engines: {node: '>=0.10.0'} dependencies: copy-descriptor: 0.1.1 define-property: 0.2.5 kind-of: 3.2.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + + /object-inspect/1.11.0: + resolution: {integrity: sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==} + /object-inspect/1.9.0: + resolution: {integrity: sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==} dev: true - resolution: - integrity: sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== + /object-is/1.1.5: + resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== + /object-keys/1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + /object-visit/1.0.1: + resolution: {integrity: sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=} + engines: {node: '>=0.10.0'} dependencies: isobject: 3.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + /object.assign/4.1.2: + resolution: {integrity: sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 has-symbols: 1.0.2 object-keys: 1.1.1 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== + /object.entries/1.1.3: + resolution: {integrity: sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 es-abstract: 1.18.0 has: 1.0.3 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg== + /object.fromentries/2.0.4: + resolution: {integrity: sha512-EsFBshs5RUUpQEY1D4q/m59kMfz4YJvxuNCJcv/jWwOJr34EaVnG11ZrZa0UHB3wnzV1wx8m58T4hQL8IuNXlQ==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 es-abstract: 1.18.0 has: 1.0.3 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-EsFBshs5RUUpQEY1D4q/m59kMfz4YJvxuNCJcv/jWwOJr34EaVnG11ZrZa0UHB3wnzV1wx8m58T4hQL8IuNXlQ== + /object.getownpropertydescriptors/2.1.2: + resolution: {integrity: sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ==} + engines: {node: '>= 0.8'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 es-abstract: 1.18.0 dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ== + /object.pick/1.3.0: + resolution: {integrity: sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=} + engines: {node: '>=0.10.0'} dependencies: isobject: 3.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + /object.values/1.1.3: + resolution: {integrity: sha512-nkF6PfDB9alkOUxpf1HNm/QlkeW3SReqL5WXeBLpEJJnlPSvRaDQpW3gQTksTN3fgJX4hL42RzKyOin6ff3tyw==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 es-abstract: 1.18.0 has: 1.0.3 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-nkF6PfDB9alkOUxpf1HNm/QlkeW3SReqL5WXeBLpEJJnlPSvRaDQpW3gQTksTN3fgJX4hL42RzKyOin6ff3tyw== + /obuf/1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} dev: true - resolution: - integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== + /on-finished/2.3.0: + resolution: {integrity: sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=} + engines: {node: '>= 0.8'} dependencies: ee-first: 1.1.1 dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= + /on-headers/1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== + /once/1.4.0: + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} dependencies: wrappy: 1.0.2 - resolution: - integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + /onetime/5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} dependencies: mimic-fn: 2.1.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + /open/7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} dependencies: is-docker: 2.1.1 is-wsl: 2.2.0 dev: false - engines: - node: '>=8' - resolution: - integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + /opn/5.5.0: + resolution: {integrity: sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA==} + engines: {node: '>=4'} dependencies: is-wsl: 1.1.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA== + /optimize-css-assets-webpack-plugin/5.0.4_webpack@4.44.2: + resolution: {integrity: sha512-wqd6FdI2a5/FdoiCNNkEvLeA//lHHfG24Ln2Xm2qqdIk4aOlsR18jwpyOihqQ8849W3qu2DX8fOYxpvTMj+93A==} + peerDependencies: + webpack: ^4.0.0 dependencies: cssnano: 4.1.10 last-call-webpack-plugin: 3.0.0 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - peerDependencies: - webpack: ^4.0.0 - resolution: - integrity: sha512-wqd6FdI2a5/FdoiCNNkEvLeA//lHHfG24Ln2Xm2qqdIk4aOlsR18jwpyOihqQ8849W3qu2DX8fOYxpvTMj+93A== + /optionator/0.8.3: + resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} + engines: {node: '>= 0.8.0'} dependencies: deep-is: 0.1.3 fast-levenshtein: 2.0.6 @@ -9548,11 +10676,10 @@ packages: type-check: 0.3.2 word-wrap: 1.2.3 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + /optionator/0.9.1: + resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} + engines: {node: '>= 0.8.0'} dependencies: deep-is: 0.1.3 fast-levenshtein: 2.0.6 @@ -9561,138 +10688,121 @@ packages: type-check: 0.4.0 word-wrap: 1.2.3 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== + /original/1.0.2: + resolution: {integrity: sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==} dependencies: url-parse: 1.5.1 dev: true - resolution: - integrity: sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg== + /os-browserify/0.3.0: + resolution: {integrity: sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=} dev: true - resolution: - integrity: sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= + /p-each-series/2.2.0: + resolution: {integrity: sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA== + /p-finally/1.0.0: + resolution: {integrity: sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + /p-limit/1.3.0: + resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} + engines: {node: '>=4'} dependencies: p-try: 1.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== + /p-limit/2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} dependencies: p-try: 2.2.0 - engines: - node: '>=6' - resolution: - integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + /p-limit/3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} dependencies: yocto-queue: 0.1.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + /p-locate/2.0.0: + resolution: {integrity: sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=} + engines: {node: '>=4'} dependencies: p-limit: 1.3.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= + /p-locate/3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} dependencies: p-limit: 2.3.0 - engines: - node: '>=6' - resolution: - integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + /p-locate/4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} dependencies: p-limit: 2.3.0 - engines: - node: '>=8' - resolution: - integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + /p-map/2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + /p-map/4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} dependencies: aggregate-error: 3.1.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + /p-retry/3.0.1: + resolution: {integrity: sha512-XE6G4+YTTkT2a0UWb2kjZe8xNwf8bIbnqpc/IS/idOBVhyves0mK5OJgeocjx7q5pvX/6m23xuzVPYT1uGM73w==} + engines: {node: '>=6'} dependencies: retry: 0.12.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-XE6G4+YTTkT2a0UWb2kjZe8xNwf8bIbnqpc/IS/idOBVhyves0mK5OJgeocjx7q5pvX/6m23xuzVPYT1uGM73w== + /p-try/1.0.0: + resolution: {integrity: sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= + /p-try/2.2.0: - engines: - node: '>=6' - resolution: - integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + /pako/1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} dev: true - resolution: - integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + /parallel-transform/1.2.0: + resolution: {integrity: sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==} dependencies: cyclist: 1.0.1 inherits: 2.0.4 readable-stream: 2.3.7 dev: true - resolution: - integrity: sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg== + /param-case/3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} dependencies: dot-case: 3.0.4 - tslib: 2.1.0 - dev: true - resolution: - integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A== + tslib: 2.4.1 + /parent-module/1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} dependencies: callsites: 3.1.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + /parse-asn1/5.1.6: + resolution: {integrity: sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==} dependencies: asn1.js: 5.4.1 browserify-aes: 1.2.0 @@ -9700,131 +10810,122 @@ packages: pbkdf2: 3.1.1 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw== + + /parse-entities/2.0.0: + resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} + dependencies: + character-entities: 1.2.4 + character-entities-legacy: 1.1.4 + character-reference-invalid: 1.1.4 + is-alphanumerical: 1.0.4 + is-decimal: 1.0.4 + is-hexadecimal: 1.0.4 + dev: true + /parse-json/2.2.0: + resolution: {integrity: sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=} + engines: {node: '>=0.10.0'} dependencies: error-ex: 1.3.2 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= + /parse-json/4.0.0: + resolution: {integrity: sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=} + engines: {node: '>=4'} dependencies: error-ex: 1.3.2 json-parse-better-errors: 1.0.2 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + /parse-json/5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} dependencies: '@babel/code-frame': 7.12.13 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.1.6 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + /parse5/6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} dev: true - resolution: - integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== + /parseurl/1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - /pascal-case/2.0.1: - dependencies: - camel-case: 3.0.0 - upper-case-first: 1.1.2 - dev: false - resolution: - integrity: sha1-LVeNNFX2YNpl7KGO+VtODekSdh4= + /pascal-case/3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} dependencies: no-case: 3.0.4 - tslib: 2.1.0 - dev: true - resolution: - integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== + tslib: 2.4.1 + /pascalcase/0.1.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + resolution: {integrity: sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=} + engines: {node: '>=0.10.0'} + /path-browserify/0.0.1: + resolution: {integrity: sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==} dev: true - resolution: - integrity: sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ== + /path-dirname/1.0.2: + resolution: {integrity: sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=} dev: true - resolution: - integrity: sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= + /path-exists/3.0.0: - engines: - node: '>=4' - resolution: - integrity: sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + resolution: {integrity: sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=} + engines: {node: '>=4'} + /path-exists/4.0.0: - engines: - node: '>=8' - resolution: - integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + /path-is-absolute/1.0.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} + engines: {node: '>=0.10.0'} + /path-is-inside/1.0.2: + resolution: {integrity: sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=} dev: true - resolution: - integrity: sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= + /path-key/2.0.1: + resolution: {integrity: sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + /path-key/3.1.1: - engines: - node: '>=8' - resolution: - integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + /path-parse/1.0.6: + resolution: {integrity: sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==} dev: true - resolution: - integrity: sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + /path-to-regexp/0.1.7: + resolution: {integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=} dev: true - resolution: - integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= + /path-to-regexp/1.8.0: + resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} dependencies: isarray: 0.0.1 - dev: false - resolution: - integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + /path-type/2.0.0: + resolution: {integrity: sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=} + engines: {node: '>=4'} dependencies: pify: 2.3.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= + /path-type/4.0.0: - engines: - node: '>=8' - resolution: - integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + /pbkdf2/3.1.1: + resolution: {integrity: sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg==} + engines: {node: '>=0.12'} dependencies: create-hash: 1.2.0 create-hmac: 1.1.7 @@ -9832,188 +10933,173 @@ packages: safe-buffer: 5.2.1 sha.js: 2.4.11 dev: true - engines: - node: '>=0.12' - resolution: - integrity: sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg== + /performance-now/2.1.0: - resolution: - integrity: sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + resolution: {integrity: sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=} + /picomatch/2.2.2: - engines: - node: '>=8.6' - resolution: - integrity: sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + resolution: {integrity: sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==} + engines: {node: '>=8.6'} + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + /pify/2.3.0: + resolution: {integrity: sha1-7RQaasBDqEnqWISY59yosVMw6Qw=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + /pify/4.0.1: - engines: - node: '>=6' - resolution: - integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + /pinkie-promise/2.0.1: + resolution: {integrity: sha1-ITXW36ejWMBprJsXh3YogihFD/o=} + engines: {node: '>=0.10.0'} dependencies: pinkie: 2.0.4 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-ITXW36ejWMBprJsXh3YogihFD/o= + /pinkie/2.0.4: + resolution: {integrity: sha1-clVrgM+g1IqXToDnckjoDtT3+HA=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-clVrgM+g1IqXToDnckjoDtT3+HA= + /pirates/4.0.1: + resolution: {integrity: sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==} + engines: {node: '>= 6'} dependencies: node-modules-regexp: 1.0.0 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA== + /pkg-dir/2.0.0: + resolution: {integrity: sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=} + engines: {node: '>=4'} dependencies: find-up: 2.1.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= + /pkg-dir/3.0.0: + resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} + engines: {node: '>=6'} dependencies: find-up: 3.0.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== + /pkg-dir/4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} dependencies: find-up: 4.1.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + /pkg-up/3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} dependencies: find-up: 3.0.0 dev: false - engines: - node: '>=8' - resolution: - integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== + /please-upgrade-node/3.2.0: + resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} dependencies: semver-compare: 1.0.0 dev: true - resolution: - integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg== + /pnp-webpack-plugin/1.6.4_typescript@4.2.3: + resolution: {integrity: sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==} + engines: {node: '>=6'} dependencies: ts-pnp: 1.2.0_typescript@4.2.3 + transitivePeerDependencies: + - typescript dev: true - engines: - node: '>=6' - peerDependencies: - typescript: '*' - resolution: - integrity: sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg== + /portfinder/1.0.28: + resolution: {integrity: sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==} + engines: {node: '>= 0.12.0'} dependencies: async: 2.6.3 debug: 3.2.7 mkdirp: 0.5.5 dev: true - engines: - node: '>= 0.12.0' - resolution: - integrity: sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== + /posix-character-classes/0.1.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + resolution: {integrity: sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=} + engines: {node: '>=0.10.0'} + /postcss-attribute-case-insensitive/4.0.2: + resolution: {integrity: sha512-clkFxk/9pcdb4Vkn0hAHq3YnxBQ2p0CGD1dy24jN+reBck+EWxMbxSUqN4Yj7t0w8csl87K6p0gxBe1utkJsYA==} dependencies: postcss: 7.0.35 postcss-selector-parser: 6.0.4 dev: true - resolution: - integrity: sha512-clkFxk/9pcdb4Vkn0hAHq3YnxBQ2p0CGD1dy24jN+reBck+EWxMbxSUqN4Yj7t0w8csl87K6p0gxBe1utkJsYA== + /postcss-browser-comments/3.0.0_browserslist@4.16.3: + resolution: {integrity: sha512-qfVjLfq7HFd2e0HW4s1dvU8X080OZdG46fFbIBFjW7US7YPDcWfRvdElvwMJr2LI6hMmD+7LnH2HcmXTs+uOig==} + engines: {node: '>=8.0.0'} + peerDependencies: + browserslist: ^4 dependencies: browserslist: 4.16.3 postcss: 7.0.35 dev: true - engines: - node: '>=8.0.0' - peerDependencies: - browserslist: ^4 - resolution: - integrity: sha512-qfVjLfq7HFd2e0HW4s1dvU8X080OZdG46fFbIBFjW7US7YPDcWfRvdElvwMJr2LI6hMmD+7LnH2HcmXTs+uOig== + /postcss-calc/7.0.5: + resolution: {integrity: sha512-1tKHutbGtLtEZF6PT4JSihCHfIVldU72mZ8SdZHIYriIZ9fh9k9aWSppaT8rHsyI3dX+KSR+W+Ix9BMY3AODrg==} dependencies: postcss: 7.0.35 postcss-selector-parser: 6.0.4 postcss-value-parser: 4.1.0 dev: true - resolution: - integrity: sha512-1tKHutbGtLtEZF6PT4JSihCHfIVldU72mZ8SdZHIYriIZ9fh9k9aWSppaT8rHsyI3dX+KSR+W+Ix9BMY3AODrg== + /postcss-color-functional-notation/2.0.1: + resolution: {integrity: sha512-ZBARCypjEDofW4P6IdPVTLhDNXPRn8T2s1zHbZidW6rPaaZvcnCS2soYFIQJrMZSxiePJ2XIYTlcb2ztr/eT2g==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-ZBARCypjEDofW4P6IdPVTLhDNXPRn8T2s1zHbZidW6rPaaZvcnCS2soYFIQJrMZSxiePJ2XIYTlcb2ztr/eT2g== + /postcss-color-gray/5.0.0: + resolution: {integrity: sha512-q6BuRnAGKM/ZRpfDascZlIZPjvwsRye7UDNalqVz3s7GDxMtqPY6+Q871liNxsonUw8oC61OG+PSaysYpl1bnw==} + engines: {node: '>=6.0.0'} dependencies: '@csstools/convert-colors': 1.4.0 postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-q6BuRnAGKM/ZRpfDascZlIZPjvwsRye7UDNalqVz3s7GDxMtqPY6+Q871liNxsonUw8oC61OG+PSaysYpl1bnw== + /postcss-color-hex-alpha/5.0.3: + resolution: {integrity: sha512-PF4GDel8q3kkreVXKLAGNpHKilXsZ6xuu+mOQMHWHLPNyjiUBOr75sp5ZKJfmv1MCus5/DWUGcK9hm6qHEnXYw==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-PF4GDel8q3kkreVXKLAGNpHKilXsZ6xuu+mOQMHWHLPNyjiUBOr75sp5ZKJfmv1MCus5/DWUGcK9hm6qHEnXYw== + /postcss-color-mod-function/3.0.3: + resolution: {integrity: sha512-YP4VG+xufxaVtzV6ZmhEtc+/aTXH3d0JLpnYfxqTvwZPbJhWqp8bSY3nfNzNRFLgB4XSaBA82OE4VjOOKpCdVQ==} + engines: {node: '>=6.0.0'} dependencies: '@csstools/convert-colors': 1.4.0 postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-YP4VG+xufxaVtzV6ZmhEtc+/aTXH3d0JLpnYfxqTvwZPbJhWqp8bSY3nfNzNRFLgB4XSaBA82OE4VjOOKpCdVQ== + /postcss-color-rebeccapurple/4.0.1: + resolution: {integrity: sha512-aAe3OhkS6qJXBbqzvZth2Au4V3KieR5sRQ4ptb2b2O8wgvB3SJBsdG+jsn2BZbbwekDG8nTfcCNKcSfe/lEy8g==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-aAe3OhkS6qJXBbqzvZth2Au4V3KieR5sRQ4ptb2b2O8wgvB3SJBsdG+jsn2BZbbwekDG8nTfcCNKcSfe/lEy8g== + /postcss-colormin/4.0.3: + resolution: {integrity: sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw==} + engines: {node: '>=6.9.0'} dependencies: browserslist: 4.16.3 color: 3.1.3 @@ -10021,214 +11107,199 @@ packages: postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw== + /postcss-convert-values/4.0.1: + resolution: {integrity: sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ== + /postcss-custom-media/7.0.8: + resolution: {integrity: sha512-c9s5iX0Ge15o00HKbuRuTqNndsJUbaXdiNsksnVH8H4gdc+zbLzr/UasOwNG6CTDpLFekVY4672eWdiiWu2GUg==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-c9s5iX0Ge15o00HKbuRuTqNndsJUbaXdiNsksnVH8H4gdc+zbLzr/UasOwNG6CTDpLFekVY4672eWdiiWu2GUg== + /postcss-custom-properties/8.0.11: + resolution: {integrity: sha512-nm+o0eLdYqdnJ5abAJeXp4CEU1c1k+eB2yMCvhgzsds/e0umabFrN6HoTy/8Q4K5ilxERdl/JD1LO5ANoYBeMA==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-nm+o0eLdYqdnJ5abAJeXp4CEU1c1k+eB2yMCvhgzsds/e0umabFrN6HoTy/8Q4K5ilxERdl/JD1LO5ANoYBeMA== + /postcss-custom-selectors/5.1.2: + resolution: {integrity: sha512-DSGDhqinCqXqlS4R7KGxL1OSycd1lydugJ1ky4iRXPHdBRiozyMHrdu0H3o7qNOCiZwySZTUI5MV0T8QhCLu+w==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-selector-parser: 5.0.0 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-DSGDhqinCqXqlS4R7KGxL1OSycd1lydugJ1ky4iRXPHdBRiozyMHrdu0H3o7qNOCiZwySZTUI5MV0T8QhCLu+w== + /postcss-dir-pseudo-class/5.0.0: + resolution: {integrity: sha512-3pm4oq8HYWMZePJY+5ANriPs3P07q+LW6FAdTlkFH2XqDdP4HeeJYMOzn0HYLhRSjBO3fhiqSwwU9xEULSrPgw==} + engines: {node: '>=4.0.0'} dependencies: postcss: 7.0.35 postcss-selector-parser: 5.0.0 dev: true - engines: - node: '>=4.0.0' - resolution: - integrity: sha512-3pm4oq8HYWMZePJY+5ANriPs3P07q+LW6FAdTlkFH2XqDdP4HeeJYMOzn0HYLhRSjBO3fhiqSwwU9xEULSrPgw== + /postcss-discard-comments/4.0.2: + resolution: {integrity: sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg== + /postcss-discard-duplicates/4.0.2: + resolution: {integrity: sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ== + /postcss-discard-empty/4.0.1: + resolution: {integrity: sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w== + /postcss-discard-overridden/4.0.1: + resolution: {integrity: sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg== + /postcss-double-position-gradients/1.0.0: + resolution: {integrity: sha512-G+nV8EnQq25fOI8CH/B6krEohGWnF5+3A6H/+JEpOncu5dCnkS1QQ6+ct3Jkaepw1NGVqqOZH6lqrm244mCftA==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-G+nV8EnQq25fOI8CH/B6krEohGWnF5+3A6H/+JEpOncu5dCnkS1QQ6+ct3Jkaepw1NGVqqOZH6lqrm244mCftA== + /postcss-env-function/2.0.2: + resolution: {integrity: sha512-rwac4BuZlITeUbiBq60h/xbLzXY43qOsIErngWa4l7Mt+RaSkT7QBjXVGTcBHupykkblHMDrBFh30zchYPaOUw==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-rwac4BuZlITeUbiBq60h/xbLzXY43qOsIErngWa4l7Mt+RaSkT7QBjXVGTcBHupykkblHMDrBFh30zchYPaOUw== + /postcss-flexbugs-fixes/4.2.1: + resolution: {integrity: sha512-9SiofaZ9CWpQWxOwRh1b/r85KD5y7GgvsNt1056k6OYLvWUun0czCvogfJgylC22uJTwW1KzY3Gz65NZRlvoiQ==} dependencies: postcss: 7.0.35 dev: true - resolution: - integrity: sha512-9SiofaZ9CWpQWxOwRh1b/r85KD5y7GgvsNt1056k6OYLvWUun0czCvogfJgylC22uJTwW1KzY3Gz65NZRlvoiQ== + /postcss-focus-visible/4.0.0: + resolution: {integrity: sha512-Z5CkWBw0+idJHSV6+Bgf2peDOFf/x4o+vX/pwcNYrWpXFrSfTkQ3JQ1ojrq9yS+upnAlNRHeg8uEwFTgorjI8g==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-Z5CkWBw0+idJHSV6+Bgf2peDOFf/x4o+vX/pwcNYrWpXFrSfTkQ3JQ1ojrq9yS+upnAlNRHeg8uEwFTgorjI8g== + /postcss-focus-within/3.0.0: + resolution: {integrity: sha512-W0APui8jQeBKbCGZudW37EeMCjDeVxKgiYfIIEo8Bdh5SpB9sxds/Iq8SEuzS0Q4YFOlG7EPFulbbxujpkrV2w==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-W0APui8jQeBKbCGZudW37EeMCjDeVxKgiYfIIEo8Bdh5SpB9sxds/Iq8SEuzS0Q4YFOlG7EPFulbbxujpkrV2w== + /postcss-font-variant/4.0.1: + resolution: {integrity: sha512-I3ADQSTNtLTTd8uxZhtSOrTCQ9G4qUVKPjHiDk0bV75QSxXjVWiJVJ2VLdspGUi9fbW9BcjKJoRvxAH1pckqmA==} dependencies: postcss: 7.0.35 dev: true - resolution: - integrity: sha512-I3ADQSTNtLTTd8uxZhtSOrTCQ9G4qUVKPjHiDk0bV75QSxXjVWiJVJ2VLdspGUi9fbW9BcjKJoRvxAH1pckqmA== + /postcss-gap-properties/2.0.0: + resolution: {integrity: sha512-QZSqDaMgXCHuHTEzMsS2KfVDOq7ZFiknSpkrPJY6jmxbugUPTuSzs/vuE5I3zv0WAS+3vhrlqhijiprnuQfzmg==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-QZSqDaMgXCHuHTEzMsS2KfVDOq7ZFiknSpkrPJY6jmxbugUPTuSzs/vuE5I3zv0WAS+3vhrlqhijiprnuQfzmg== + /postcss-image-set-function/3.0.1: + resolution: {integrity: sha512-oPTcFFip5LZy8Y/whto91L9xdRHCWEMs3e1MdJxhgt4jy2WYXfhkng59fH5qLXSCPN8k4n94p1Czrfe5IOkKUw==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-oPTcFFip5LZy8Y/whto91L9xdRHCWEMs3e1MdJxhgt4jy2WYXfhkng59fH5qLXSCPN8k4n94p1Czrfe5IOkKUw== + /postcss-initial/3.0.2: + resolution: {integrity: sha512-ugA2wKonC0xeNHgirR4D3VWHs2JcU08WAi1KFLVcnb7IN89phID6Qtg2RIctWbnvp1TM2BOmDtX8GGLCKdR8YA==} dependencies: lodash.template: 4.5.0 postcss: 7.0.35 dev: true - resolution: - integrity: sha512-ugA2wKonC0xeNHgirR4D3VWHs2JcU08WAi1KFLVcnb7IN89phID6Qtg2RIctWbnvp1TM2BOmDtX8GGLCKdR8YA== + + /postcss-js/2.0.3: + resolution: {integrity: sha512-zS59pAk3deu6dVHyrGqmC3oDXBdNdajk4k1RyxeVXCrcEDBUBHoIhE4QTsmhxgzXxsaqFDAkUZfmMa5f/N/79w==} + dependencies: + camelcase-css: 2.0.1 + postcss: 7.0.35 + dev: true + /postcss-lab-function/2.0.1: + resolution: {integrity: sha512-whLy1IeZKY+3fYdqQFuDBf8Auw+qFuVnChWjmxm/UhHWqNHZx+B99EwxTvGYmUBqe3Fjxs4L1BoZTJmPu6usVg==} + engines: {node: '>=6.0.0'} dependencies: '@csstools/convert-colors': 1.4.0 postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-whLy1IeZKY+3fYdqQFuDBf8Auw+qFuVnChWjmxm/UhHWqNHZx+B99EwxTvGYmUBqe3Fjxs4L1BoZTJmPu6usVg== + /postcss-load-config/2.1.2: + resolution: {integrity: sha512-/rDeGV6vMUo3mwJZmeHfEDvwnTKKqQ0S7OHUi/kJvvtx3aWtyWG2/0ZWnzCt2keEclwN6Tf0DST2v9kITdOKYw==} + engines: {node: '>= 4'} dependencies: cosmiconfig: 5.2.1 import-cwd: 2.1.0 dev: true - engines: - node: '>= 4' - resolution: - integrity: sha512-/rDeGV6vMUo3mwJZmeHfEDvwnTKKqQ0S7OHUi/kJvvtx3aWtyWG2/0ZWnzCt2keEclwN6Tf0DST2v9kITdOKYw== + /postcss-loader/3.0.0: + resolution: {integrity: sha512-cLWoDEY5OwHcAjDnkyRQzAXfs2jrKjXpO/HQFcc5b5u/r7aa471wdmChmwfnv7x2u840iat/wi0lQ5nbRgSkUA==} + engines: {node: '>= 6'} dependencies: loader-utils: 1.4.0 postcss: 7.0.35 postcss-load-config: 2.1.2 schema-utils: 1.0.0 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-cLWoDEY5OwHcAjDnkyRQzAXfs2jrKjXpO/HQFcc5b5u/r7aa471wdmChmwfnv7x2u840iat/wi0lQ5nbRgSkUA== + /postcss-logical/3.0.0: + resolution: {integrity: sha512-1SUKdJc2vuMOmeItqGuNaC+N8MzBWFWEkAnRnLpFYj1tGGa7NqyVBujfRtgNa2gXR+6RkGUiB2O5Vmh7E2RmiA==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-1SUKdJc2vuMOmeItqGuNaC+N8MzBWFWEkAnRnLpFYj1tGGa7NqyVBujfRtgNa2gXR+6RkGUiB2O5Vmh7E2RmiA== + /postcss-media-minmax/4.0.0: + resolution: {integrity: sha512-fo9moya6qyxsjbFAYl97qKO9gyre3qvbMnkOZeZwlsW6XYFsvs2DMGDlchVLfAd8LHPZDxivu/+qW2SMQeTHBw==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-fo9moya6qyxsjbFAYl97qKO9gyre3qvbMnkOZeZwlsW6XYFsvs2DMGDlchVLfAd8LHPZDxivu/+qW2SMQeTHBw== + /postcss-merge-longhand/4.0.11: + resolution: {integrity: sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw==} + engines: {node: '>=6.9.0'} dependencies: css-color-names: 0.0.4 postcss: 7.0.35 postcss-value-parser: 3.3.1 stylehacks: 4.0.3 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw== + /postcss-merge-rules/4.0.3: + resolution: {integrity: sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ==} + engines: {node: '>=6.9.0'} dependencies: browserslist: 4.16.3 caniuse-api: 3.0.0 @@ -10237,31 +11308,28 @@ packages: postcss-selector-parser: 3.1.2 vendors: 1.0.4 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ== + /postcss-minify-font-values/4.0.2: + resolution: {integrity: sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg== + /postcss-minify-gradients/4.0.2: + resolution: {integrity: sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q==} + engines: {node: '>=6.9.0'} dependencies: cssnano-util-get-arguments: 4.0.0 is-color-stop: 1.1.0 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q== + /postcss-minify-params/4.0.2: + resolution: {integrity: sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg==} + engines: {node: '>=6.9.0'} dependencies: alphanum-sort: 1.0.2 browserslist: 4.16.3 @@ -10270,155 +11338,140 @@ packages: postcss-value-parser: 3.3.1 uniqs: 2.0.0 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg== + /postcss-minify-selectors/4.0.2: + resolution: {integrity: sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g==} + engines: {node: '>=6.9.0'} dependencies: alphanum-sort: 1.0.2 has: 1.0.3 postcss: 7.0.35 postcss-selector-parser: 3.1.2 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g== + /postcss-modules-extract-imports/2.0.0: + resolution: {integrity: sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==} + engines: {node: '>= 6'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ== + /postcss-modules-local-by-default/3.0.3: + resolution: {integrity: sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==} + engines: {node: '>= 6'} dependencies: icss-utils: 4.1.1 postcss: 7.0.35 postcss-selector-parser: 6.0.4 postcss-value-parser: 4.1.0 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw== + /postcss-modules-scope/2.2.0: + resolution: {integrity: sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==} + engines: {node: '>= 6'} dependencies: postcss: 7.0.35 postcss-selector-parser: 6.0.4 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ== + /postcss-modules-values/3.0.0: + resolution: {integrity: sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==} dependencies: icss-utils: 4.1.1 postcss: 7.0.35 dev: true - resolution: - integrity: sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg== + /postcss-nesting/7.0.1: + resolution: {integrity: sha512-FrorPb0H3nuVq0Sff7W2rnc3SmIcruVC6YwpcS+k687VxyxO33iE1amna7wHuRVzM8vfiYofXSBHNAZ3QhLvYg==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-FrorPb0H3nuVq0Sff7W2rnc3SmIcruVC6YwpcS+k687VxyxO33iE1amna7wHuRVzM8vfiYofXSBHNAZ3QhLvYg== + /postcss-normalize-charset/4.0.1: + resolution: {integrity: sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g== + /postcss-normalize-display-values/4.0.2: + resolution: {integrity: sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ==} + engines: {node: '>=6.9.0'} dependencies: cssnano-util-get-match: 4.0.0 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ== + /postcss-normalize-positions/4.0.2: + resolution: {integrity: sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA==} + engines: {node: '>=6.9.0'} dependencies: cssnano-util-get-arguments: 4.0.0 has: 1.0.3 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA== + /postcss-normalize-repeat-style/4.0.2: + resolution: {integrity: sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q==} + engines: {node: '>=6.9.0'} dependencies: cssnano-util-get-arguments: 4.0.0 cssnano-util-get-match: 4.0.0 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q== + /postcss-normalize-string/4.0.2: + resolution: {integrity: sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA==} + engines: {node: '>=6.9.0'} dependencies: has: 1.0.3 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA== + /postcss-normalize-timing-functions/4.0.2: + resolution: {integrity: sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A==} + engines: {node: '>=6.9.0'} dependencies: cssnano-util-get-match: 4.0.0 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A== + /postcss-normalize-unicode/4.0.1: + resolution: {integrity: sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg==} + engines: {node: '>=6.9.0'} dependencies: browserslist: 4.16.3 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg== + /postcss-normalize-url/4.0.1: + resolution: {integrity: sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA==} + engines: {node: '>=6.9.0'} dependencies: is-absolute-url: 2.1.0 normalize-url: 3.3.0 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA== + /postcss-normalize-whitespace/4.0.2: + resolution: {integrity: sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA==} + engines: {node: '>=6.9.0'} dependencies: postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA== + /postcss-normalize/8.0.1: + resolution: {integrity: sha512-rt9JMS/m9FHIRroDDBGSMsyW1c0fkvOJPy62ggxSHUldJO7B195TqFMqIf+lY5ezpDcYOV4j86aUp3/XbxzCCQ==} + engines: {node: '>=8.0.0'} dependencies: '@csstools/normalize.css': 10.1.0 browserslist: 4.16.3 @@ -10426,44 +11479,40 @@ packages: postcss-browser-comments: 3.0.0_browserslist@4.16.3 sanitize.css: 10.0.0 dev: true - engines: - node: '>=8.0.0' - resolution: - integrity: sha512-rt9JMS/m9FHIRroDDBGSMsyW1c0fkvOJPy62ggxSHUldJO7B195TqFMqIf+lY5ezpDcYOV4j86aUp3/XbxzCCQ== + /postcss-ordered-values/4.1.2: + resolution: {integrity: sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw==} + engines: {node: '>=6.9.0'} dependencies: cssnano-util-get-arguments: 4.0.0 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw== + /postcss-overflow-shorthand/2.0.0: + resolution: {integrity: sha512-aK0fHc9CBNx8jbzMYhshZcEv8LtYnBIRYQD5i7w/K/wS9c2+0NSR6B3OVMu5y0hBHYLcMGjfU+dmWYNKH0I85g==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-aK0fHc9CBNx8jbzMYhshZcEv8LtYnBIRYQD5i7w/K/wS9c2+0NSR6B3OVMu5y0hBHYLcMGjfU+dmWYNKH0I85g== + /postcss-page-break/2.0.0: + resolution: {integrity: sha512-tkpTSrLpfLfD9HvgOlJuigLuk39wVTbbd8RKcy8/ugV2bNBUW3xU+AIqyxhDrQr1VUj1RmyJrBn1YWrqUm9zAQ==} dependencies: postcss: 7.0.35 dev: true - resolution: - integrity: sha512-tkpTSrLpfLfD9HvgOlJuigLuk39wVTbbd8RKcy8/ugV2bNBUW3xU+AIqyxhDrQr1VUj1RmyJrBn1YWrqUm9zAQ== + /postcss-place/4.0.1: + resolution: {integrity: sha512-Zb6byCSLkgRKLODj/5mQugyuj9bvAAw9LqJJjgwz5cYryGeXfFZfSXoP1UfveccFmeq0b/2xxwcTEVScnqGxBg==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-values-parser: 2.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-Zb6byCSLkgRKLODj/5mQugyuj9bvAAw9LqJJjgwz5cYryGeXfFZfSXoP1UfveccFmeq0b/2xxwcTEVScnqGxBg== + /postcss-preset-env/6.7.0: + resolution: {integrity: sha512-eU4/K5xzSFwUFJ8hTdTQzo2RBLbDVt83QZrAvI07TULOkmyQlnYlpwep+2yIK+K+0KlZO4BvFcleOCCcUtwchg==} + engines: {node: '>=6.0.0'} dependencies: autoprefixer: 9.8.6 browserslist: 4.16.3 @@ -10503,284 +11552,300 @@ packages: postcss-selector-matches: 4.0.0 postcss-selector-not: 4.0.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-eU4/K5xzSFwUFJ8hTdTQzo2RBLbDVt83QZrAvI07TULOkmyQlnYlpwep+2yIK+K+0KlZO4BvFcleOCCcUtwchg== + /postcss-pseudo-class-any-link/6.0.0: + resolution: {integrity: sha512-lgXW9sYJdLqtmw23otOzrtbDXofUdfYzNm4PIpNE322/swES3VU9XlXHeJS46zT2onFO7V1QFdD4Q9LiZj8mew==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 postcss-selector-parser: 5.0.0 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-lgXW9sYJdLqtmw23otOzrtbDXofUdfYzNm4PIpNE322/swES3VU9XlXHeJS46zT2onFO7V1QFdD4Q9LiZj8mew== + /postcss-reduce-initial/4.0.3: + resolution: {integrity: sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA==} + engines: {node: '>=6.9.0'} dependencies: browserslist: 4.16.3 caniuse-api: 3.0.0 has: 1.0.3 postcss: 7.0.35 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA== + /postcss-reduce-transforms/4.0.2: + resolution: {integrity: sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg==} + engines: {node: '>=6.9.0'} dependencies: cssnano-util-get-match: 4.0.0 has: 1.0.3 postcss: 7.0.35 postcss-value-parser: 3.3.1 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg== + /postcss-replace-overflow-wrap/3.0.0: + resolution: {integrity: sha512-2T5hcEHArDT6X9+9dVSPQdo7QHzG4XKclFT8rU5TzJPDN7RIRTbO9c4drUISOVemLj03aezStHCR2AIcr8XLpw==} + dependencies: + postcss: 7.0.35 + dev: true + + /postcss-safe-parser/4.0.2: + resolution: {integrity: sha512-Uw6ekxSWNLCPesSv/cmqf2bY/77z11O7jZGPax3ycZMFU/oi2DMH9i89AdHc1tRwFg/arFoEwX0IS3LCUxJh1g==} + engines: {node: '>=6.0.0'} dependencies: postcss: 7.0.35 dev: true - resolution: - integrity: sha512-2T5hcEHArDT6X9+9dVSPQdo7QHzG4XKclFT8rU5TzJPDN7RIRTbO9c4drUISOVemLj03aezStHCR2AIcr8XLpw== + /postcss-safe-parser/5.0.2: + resolution: {integrity: sha512-jDUfCPJbKOABhwpUKcqCVbbXiloe/QXMcbJ6Iipf3sDIihEzTqRCeMBfRaOHxhBuTYqtASrI1KJWxzztZU4qUQ==} + engines: {node: '>=10.0'} dependencies: postcss: 8.2.8 dev: true - engines: - node: '>=10.0' - resolution: - integrity: sha512-jDUfCPJbKOABhwpUKcqCVbbXiloe/QXMcbJ6Iipf3sDIihEzTqRCeMBfRaOHxhBuTYqtASrI1KJWxzztZU4qUQ== + /postcss-selector-matches/4.0.0: + resolution: {integrity: sha512-LgsHwQR/EsRYSqlwdGzeaPKVT0Ml7LAT6E75T8W8xLJY62CE4S/l03BWIt3jT8Taq22kXP08s2SfTSzaraoPww==} dependencies: balanced-match: 1.0.0 postcss: 7.0.35 dev: true - resolution: - integrity: sha512-LgsHwQR/EsRYSqlwdGzeaPKVT0Ml7LAT6E75T8W8xLJY62CE4S/l03BWIt3jT8Taq22kXP08s2SfTSzaraoPww== + /postcss-selector-not/4.0.1: + resolution: {integrity: sha512-YolvBgInEK5/79C+bdFMyzqTg6pkYqDbzZIST/PDMqa/o3qtXenD05apBG2jLgT0/BQ77d4U2UK12jWpilqMAQ==} dependencies: balanced-match: 1.0.0 postcss: 7.0.35 dev: true - resolution: - integrity: sha512-YolvBgInEK5/79C+bdFMyzqTg6pkYqDbzZIST/PDMqa/o3qtXenD05apBG2jLgT0/BQ77d4U2UK12jWpilqMAQ== + /postcss-selector-parser/3.1.2: + resolution: {integrity: sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==} + engines: {node: '>=8'} dependencies: dot-prop: 5.3.0 indexes-of: 1.0.1 uniq: 1.0.1 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA== + /postcss-selector-parser/5.0.0: + resolution: {integrity: sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ==} + engines: {node: '>=4'} dependencies: cssesc: 2.0.0 indexes-of: 1.0.1 uniq: 1.0.1 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ== + /postcss-selector-parser/6.0.4: + resolution: {integrity: sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==} + engines: {node: '>=4'} dependencies: cssesc: 3.0.0 indexes-of: 1.0.1 uniq: 1.0.1 util-deprecate: 1.0.2 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw== + /postcss-svgo/4.0.2: + resolution: {integrity: sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw==} + engines: {node: '>=6.9.0'} dependencies: is-svg: 3.0.0 postcss: 7.0.35 postcss-value-parser: 3.3.1 svgo: 1.3.2 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw== + /postcss-unique-selectors/4.0.1: + resolution: {integrity: sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg==} + engines: {node: '>=6.9.0'} dependencies: alphanum-sort: 1.0.2 postcss: 7.0.35 uniqs: 2.0.0 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg== + /postcss-value-parser/3.3.1: + resolution: {integrity: sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==} dev: true - resolution: - integrity: sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== + /postcss-value-parser/4.1.0: - resolution: - integrity: sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== + resolution: {integrity: sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==} + /postcss-values-parser/2.0.1: + resolution: {integrity: sha512-2tLuBsA6P4rYTNKCXYG/71C7j1pU6pK503suYOmn4xYrQIzW+opD+7FAFNuGSdZC/3Qfy334QbeMu7MEb8gOxg==} + engines: {node: '>=6.14.4'} dependencies: flatten: 1.0.3 indexes-of: 1.0.1 uniq: 1.0.1 dev: true - engines: - node: '>=6.14.4' - resolution: - integrity: sha512-2tLuBsA6P4rYTNKCXYG/71C7j1pU6pK503suYOmn4xYrQIzW+opD+7FAFNuGSdZC/3Qfy334QbeMu7MEb8gOxg== + /postcss/7.0.21: + resolution: {integrity: sha512-uIFtJElxJo29QC753JzhidoAhvp/e/Exezkdhfmt8AymWT6/5B7W1WmponYWkHk2eg6sONyTch0A3nkMPun3SQ==} + engines: {node: '>=6.0.0'} + dependencies: + chalk: 2.4.2 + source-map: 0.6.1 + supports-color: 6.1.0 + dev: true + + /postcss/7.0.32: + resolution: {integrity: sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==} + engines: {node: '>=6.0.0'} dependencies: chalk: 2.4.2 source-map: 0.6.1 supports-color: 6.1.0 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-uIFtJElxJo29QC753JzhidoAhvp/e/Exezkdhfmt8AymWT6/5B7W1WmponYWkHk2eg6sONyTch0A3nkMPun3SQ== + /postcss/7.0.35: + resolution: {integrity: sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==} + engines: {node: '>=6.0.0'} dependencies: chalk: 2.4.2 source-map: 0.6.1 supports-color: 6.1.0 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== + /postcss/8.2.8: + resolution: {integrity: sha512-1F0Xb2T21xET7oQV9eKuctbM9S7BC0fetoHCc4H13z0PT6haiRLP4T0ZY4XWh7iLP0usgqykT6p9B2RtOf4FPw==} + engines: {node: ^10 || ^12 || >=14} dependencies: colorette: 1.2.2 nanoid: 3.1.22 source-map: 0.6.1 dev: true - engines: - node: ^10 || ^12 || >=14 - resolution: - integrity: sha512-1F0Xb2T21xET7oQV9eKuctbM9S7BC0fetoHCc4H13z0PT6haiRLP4T0ZY4XWh7iLP0usgqykT6p9B2RtOf4FPw== + /prelude-ls/1.1.2: + resolution: {integrity: sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=} + engines: {node: '>= 0.8.0'} dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + /prelude-ls/1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + /prepend-http/1.0.4: + resolution: {integrity: sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= + /prettier-linter-helpers/1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} dependencies: fast-diff: 1.2.0 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== + /prettier/2.2.1: - dev: true - engines: - node: '>=10.13.0' + resolution: {integrity: sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==} + engines: {node: '>=10.13.0'} hasBin: true - resolution: - integrity: sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== + dev: true + /pretty-bytes/5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== + /pretty-error/2.1.2: + resolution: {integrity: sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==} dependencies: lodash: 4.17.21 renderkid: 2.0.5 dev: true - resolution: - integrity: sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw== + /pretty-format/26.6.2: + resolution: {integrity: sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==} + engines: {node: '>= 10'} dependencies: '@jest/types': 26.6.2 ansi-regex: 5.0.0 ansi-styles: 4.3.0 react-is: 17.0.2 dev: true - engines: - node: '>= 10' - resolution: - integrity: sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== + + /prism-react-renderer/1.2.1_react@17.0.2: + resolution: {integrity: sha512-w23ch4f75V1Tnz8DajsYKvY5lF7H1+WvzvLUcF0paFxkTHSp42RS0H5CttdN2Q8RR3DRGZ9v5xD/h3n8C8kGmg==} + peerDependencies: + react: '>=0.14.9' + dependencies: + react: 17.0.2 + dev: true + + /prismjs/1.25.0: + resolution: {integrity: sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==} + dev: true + /process-nextick-args/2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} dev: true - resolution: - integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + /process/0.11.10: + resolution: {integrity: sha1-czIwDoQBYb2j5podHZGn1LwW8YI=} + engines: {node: '>= 0.6.0'} dev: true - engines: - node: '>= 0.6.0' - resolution: - integrity: sha1-czIwDoQBYb2j5podHZGn1LwW8YI= + /progress/2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} dev: true - engines: - node: '>=0.4.0' - resolution: - integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + /promise-inflight/1.0.1: + resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=} dev: true - resolution: - integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM= + /promise/8.1.0: + resolution: {integrity: sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q==} dependencies: asap: 2.0.6 dev: false - resolution: - integrity: sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q== + /prompts/2.4.0: + resolution: {integrity: sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ==} + engines: {node: '>= 6'} dependencies: kleur: 3.0.3 sisteransi: 1.0.5 - engines: - node: '>= 6' - resolution: - integrity: sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ== + /prop-types/15.7.2: + resolution: {integrity: sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + /prop-types/15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 - resolution: - integrity: sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== + dev: false + + /property-information/5.6.0: + resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} + dependencies: + xtend: 4.0.2 + dev: true + /proxy-addr/2.0.6: + resolution: {integrity: sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==} + engines: {node: '>= 0.10'} dependencies: forwarded: 0.1.2 ipaddr.js: 1.9.1 dev: true - engines: - node: '>= 0.10' - resolution: - integrity: sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw== + /prr/1.0.1: + resolution: {integrity: sha1-0/wRS6BplaRexok/SEzrHXj19HY=} dev: true - resolution: - integrity: sha1-0/wRS6BplaRexok/SEzrHXj19HY= + /psl/1.8.0: + resolution: {integrity: sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==} dev: true - resolution: - integrity: sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== + /public-encrypt/4.0.3: + resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} dependencies: bn.js: 4.12.0 browserify-rsa: 4.1.0 @@ -10789,293 +11854,186 @@ packages: randombytes: 2.1.0 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== + /pubsub-js/1.9.3: + resolution: {integrity: sha512-FhYYlPNOywTh7zN38u5AlG67emA47w6JZd7YgdQU1w8gQbZhhIGxVM0AQosdaINHb2ALb+fhfnVyBJAt4D4IzA==} dev: false - resolution: - integrity: sha512-FhYYlPNOywTh7zN38u5AlG67emA47w6JZd7YgdQU1w8gQbZhhIGxVM0AQosdaINHb2ALb+fhfnVyBJAt4D4IzA== + /pump/2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} dependencies: end-of-stream: 1.4.4 once: 1.4.0 dev: true - resolution: - integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== + /pump/3.0.0: + resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: end-of-stream: 1.4.4 once: 1.4.0 dev: true - resolution: - integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + /pumpify/1.5.1: + resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} dependencies: duplexify: 3.7.1 inherits: 2.0.4 pump: 2.0.1 dev: true - resolution: - integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== + /punycode/1.3.2: + resolution: {integrity: sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=} dev: true - resolution: - integrity: sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= + /punycode/1.4.1: + resolution: {integrity: sha1-wNWmOycYgArY4esPpSachN1BhF4=} dev: true - resolution: - integrity: sha1-wNWmOycYgArY4esPpSachN1BhF4= + /punycode/2.1.1: + resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + /q/1.5.1: + resolution: {integrity: sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=} + engines: {node: '>=0.6.0', teleport: '>=0.2.0'} dev: true - engines: - node: '>=0.6.0' - teleport: '>=0.2.0' - resolution: - integrity: sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= + + /qs/6.10.1: + resolution: {integrity: sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.4 + dev: false + /qs/6.5.2: + resolution: {integrity: sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==} + engines: {node: '>=0.6'} dev: true - engines: - node: '>=0.6' - resolution: - integrity: sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + /qs/6.7.0: + resolution: {integrity: sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==} + engines: {node: '>=0.6'} dev: true - engines: - node: '>=0.6' - resolution: - integrity: sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== + /query-string/4.3.4: + resolution: {integrity: sha1-u7aTucqRXCMlFbIosaArYJBD2+s=} + engines: {node: '>=0.10.0'} dependencies: object-assign: 4.1.1 strict-uri-encode: 1.1.0 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-u7aTucqRXCMlFbIosaArYJBD2+s= + + /query-string/6.14.1: + resolution: {integrity: sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==} + engines: {node: '>=6'} + dependencies: + decode-uri-component: 0.2.0 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + dev: true + /querystring-es3/0.2.1: + resolution: {integrity: sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=} + engines: {node: '>=0.4.x'} dev: true - engines: - node: '>=0.4.x' - resolution: - integrity: sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= + /querystring/0.2.0: + resolution: {integrity: sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=} + engines: {node: '>=0.4.x'} + deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. dev: true - engines: - node: '>=0.4.x' - resolution: - integrity: sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + /querystring/0.2.1: + resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==} + engines: {node: '>=0.4.x'} dev: true - engines: - node: '>=0.4.x' - resolution: - integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg== + /querystringify/2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} dev: true - resolution: - integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + /queue-microtask/1.2.3: - resolution: - integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /raf/3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} dependencies: performance-now: 2.1.0 dev: false - resolution: - integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + /randombytes/2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} dependencies: safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + /randomfill/1.0.4: + resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==} dependencies: randombytes: 2.1.0 safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== + /range-parser/1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + /raw-body/2.4.0: + resolution: {integrity: sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==} + engines: {node: '>= 0.8'} dependencies: bytes: 3.1.0 http-errors: 1.7.2 iconv-lite: 0.4.24 unpipe: 1.0.0 dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q== - /rc-align/4.0.9_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - dom-align: 1.12.0 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - resize-observer-polyfill: 1.5.1 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-myAM2R4qoB6LqBul0leaqY8gFaiECDJ3MtQDmzDo9xM9NRT/04TvWOYd2YHU9zvGzqk9QXF6S9/MifzSKDZeMw== - /rc-cascader/1.4.2_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - array-tree-filter: 2.1.0 - rc-trigger: 5.2.3_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - warning: 4.0.3 - dev: false + + /raw-loader/4.0.2_webpack@4.44.2: + resolution: {integrity: sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==} + engines: {node: '>= 10.13.0'} peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-JVuLGrSi+3G8DZyPvlKlGVWJjhoi9NTz6REHIgRspa5WnznRkKGm2ejb0jJtz0m2IL8Q9BG4ZA2sXuqAu71ltQ== - /rc-checkbox/2.3.2_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-afVi1FYiGv1U0JlpNH/UaEXdh6WUJjcWokj/nUN2TgG80bfG+MDdbfHKlLcNNba94mbjy2/SXJ1HDgrOkXGAjg== - /rc-collapse/3.1.0_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-motion: 2.4.1_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - shallowequal: 1.1.0 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-EwpNPJcLe7b+5JfyaxM9ZNnkCgqArt3QQO0Cr5p5plwz/C9h8liAmjYY5I4+hl9lAjBqb7ZwLu94+z+rt5g1WQ== - /rc-dialog/8.5.2_react-dom@17.0.2+react@17.0.2: + webpack: ^4.0.0 || ^5.0.0 dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-motion: 2.4.1_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false + loader-utils: 2.0.0 + schema-utils: 3.1.0 + webpack: 4.44.2_webpack-cli@4.6.0 + dev: true + + /rc-align/4.0.9_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-myAM2R4qoB6LqBul0leaqY8gFaiECDJ3MtQDmzDo9xM9NRT/04TvWOYd2YHU9zvGzqk9QXF6S9/MifzSKDZeMw==} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-3n4taFcjqhTE9uNuzjB+nPDeqgRBTEGBfe46mb1e7r88DgDo0lL4NnxY/PZ6PJKd2tsCt+RrgF/+YeTvJ/Thsw== - /rc-drawer/4.3.1_react-dom@17.0.2+react@17.0.2: dependencies: '@babel/runtime': 7.13.10 classnames: 2.2.6 + dom-align: 1.12.0 rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-GMfFy4maqxS9faYXEhQ+0cA1xtkddEQzraf6SAdzWbn444DrrLogwYPk1NXSpdXjLCLxgxOj9MYtyYG42JsfXg== + resize-observer-polyfill: 1.5.1 + /rc-dropdown/3.2.0_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-trigger: 5.2.3_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false + resolution: {integrity: sha512-j1HSw+/QqlhxyTEF6BArVZnTmezw2LnSmRk6I9W7BCqNCKaRwleRmMMs1PHbuaG8dKHVqP6e21RQ7vPBLVnnNw==} peerDependencies: react: '*' react-dom: '*' - resolution: - integrity: sha512-j1HSw+/QqlhxyTEF6BArVZnTmezw2LnSmRk6I9W7BCqNCKaRwleRmMMs1PHbuaG8dKHVqP6e21RQ7vPBLVnnNw== - /rc-field-form/1.20.0_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - async-validator: 3.5.1 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - engines: - node: '>=8.x' - peerDependencies: - react: '>= 16.9.0' - react-dom: '>= 16.9.0' - resolution: - integrity: sha512-jkzsIfXR7ywEYdeAtktt1aLff88wxIPDLpq7KShHNl4wlsWrCE+TzkXBfjvVzYOVZt5GGrD8YDqNO/q6eaR/eA== - /rc-image/5.2.4_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-dialog: 8.5.2_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-kWOjhZC1OoGKfvWqtDoO9r8WUNswBwnjcstI6rf7HMudz0usmbGvewcWqsOhyaBRJL9+I4eeG+xiAoxV1xi75Q== - /rc-input-number/7.0.3_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-y0nVqVANWyxQbm/vdhz1p5E1V5Y6Yd2+3MGKntSzCxrYgw0F7/COXkbRdcTECnXwiDv8ZrbYQ1pTP3u43PqE4Q== - /rc-mentions/1.5.3_react-dom@17.0.2+react@17.0.2: dependencies: '@babel/runtime': 7.13.10 classnames: 2.2.6 - rc-menu: 8.10.6_react-dom@17.0.2+react@17.0.2 - rc-textarea: 0.3.4_react-dom@17.0.2+react@17.0.2 rc-trigger: 5.2.3_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - dev: false + dev: true + + /rc-menu/8.10.6_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-RVkd8XChwSmVOdNULbqLNnABthRZWnhqct1Q74onEXTClsXvsLADMhlIJtw/umglVSECM+14TJdIli9rl2Bzlw==} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-NG/KB8YiKBCJPHHvr/QapAb4f9YzLJn7kDHtmI1K6t7ZMM5YgrjIxNNhoRKKP9zJvb9PdPts69Hbg4ZMvLVIFQ== - /rc-menu/8.10.6_react-dom@17.0.2+react@17.0.2: dependencies: '@babel/runtime': 7.13.10 classnames: 2.2.6 @@ -11087,369 +12045,153 @@ packages: react-dom: 17.0.2_react@17.0.2 resize-observer-polyfill: 1.5.1 shallowequal: 1.1.0 - dev: false + + /rc-motion/2.4.1_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-TWLvymfMu8SngPx5MDH8dQ0D2RYbluNTfam4hY/dNNx9RQ3WtGuZ/GXHi2ymLMzH+UNd6EEFYkOuR5JTTtm8Xg==} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-RVkd8XChwSmVOdNULbqLNnABthRZWnhqct1Q74onEXTClsXvsLADMhlIJtw/umglVSECM+14TJdIli9rl2Bzlw== - /rc-motion/2.4.1_react-dom@17.0.2+react@17.0.2: dependencies: '@babel/runtime': 7.13.10 classnames: 2.2.6 rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - dev: false + + /rc-resize-observer/1.0.0_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-RgKGukg1mlzyGdvzF7o/LGFC8AeoMH9aGzXTUdp6m+OApvmRdUuOscq/Y2O45cJA+rXt1ApWlpFoOIioXL3AGg==} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-TWLvymfMu8SngPx5MDH8dQ0D2RYbluNTfam4hY/dNNx9RQ3WtGuZ/GXHi2ymLMzH+UNd6EEFYkOuR5JTTtm8Xg== - /rc-notification/4.5.5_react-dom@17.0.2+react@17.0.2: dependencies: '@babel/runtime': 7.13.10 classnames: 2.2.6 - rc-motion: 2.4.1_react-dom@17.0.2+react@17.0.2 rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - dev: false - engines: - node: '>=8.x' + resize-observer-polyfill: 1.5.1 + dev: true + + /rc-tabs/11.7.3_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-5nd2NVss9TprPRV9r8N05SjQyAE7zDrLejxFLcbJ+BdLxSwnGnk3ws/Iq0smqKZUnPQC0XEvnpF3+zlllUUT2w==} + engines: {node: '>=8.x'} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-YIfhTSw+h5GsSdgMnuMx24wqiPlg3FeamuOlkh9RkyHx+SeZVAKzQ0juy2NGvPEF2hDWi5xTqxUqLdo0L2AmGg== - /rc-overflow/1.0.2_react-dom@17.0.2+react@17.0.2: dependencies: '@babel/runtime': 7.13.10 classnames: 2.2.6 + rc-dropdown: 3.2.0_react-dom@17.0.2+react@17.0.2 + rc-menu: 8.10.6_react-dom@17.0.2+react@17.0.2 rc-resize-observer: 1.0.0_react-dom@17.0.2+react@17.0.2 rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - dev: false + dev: true + + /rc-trigger/5.2.3_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-6Fokao07HUbqKIDkDRFEM0AGZvsvK0Fbp8A/KFgl1ngaqfO1nY037cISCG1Jm5fxImVsXp9awdkP7Vu5cxjjog==} + engines: {node: '>=8.x'} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-GXj4DAyNxm4f57LvXLwhJaZoJHzSge2l2lQq64MZP7NJAfLpQqOLD+v9JMV9ONTvDPZe8kdzR+UMmkAn7qlzFA== - /rc-pagination/3.1.6_react-dom@17.0.2+react@17.0.2: dependencies: '@babel/runtime': 7.13.10 classnames: 2.2.6 + rc-align: 4.0.9_react-dom@17.0.2+react@17.0.2 + rc-motion: 2.4.1_react-dom@17.0.2+react@17.0.2 + rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - dev: false + + /rc-upload/4.3.1_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-W8Iyv0LRyEnFEzpv90ET/i1XG2jlPzPxKkkOVtDfgh9c3f4lZV770vgpUfiyQza+iLtQLVco3qIvgue8aDiOsQ==} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-Pb2zJEt8uxXzYCWx/2qwsYZ3vSS9Eqdw0cJBli6C58/iYhmvutSBqrBJh51Z5UzYc5ZcW5CMeP5LbbKE1J3rpw== - /rc-picker/2.5.10_2235c505ed33ea6efd93d3050f896208: dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - date-fns: 2.19.0 - dayjs: 1.10.4 - moment: 2.29.1 - rc-trigger: 5.2.3_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 + '@babel/runtime': 7.14.8 + classnames: 2.3.1 + rc-util: 5.13.2_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - shallowequal: 1.1.0 dev: false - engines: - node: '>=8.x' + + /rc-util/5.13.2_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-eYc71XXGlp96RMzg01Mhq/T3BL6OOVTDSS0urFEuvpi+e7slhJRhaHGCKy2hqJm18m9ff7VoRoptplKu60dYog==} peerDependencies: - dayjs: ^1.8.30 react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-d2or2jql9SSY8CaRPybpbKkXBq3bZ6g88UKyWQZBLTCrc92Xm87RfRC/P3UEQo/CLmia3jVF7IXVi1HmNe2DZA== - /rc-progress/3.1.3_react-dom@17.0.2+react@17.0.2: dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 + '@babel/runtime': 7.14.8 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 + react-is: 16.13.1 + shallowequal: 1.1.0 dev: false + + /rc-util/5.9.8_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-typLSHYGf5irvGLYQshs0Ra3aze086h0FhzsAkyirMunYZ7b3Te8gKa5PVaanoHaZa9sS6qx98BxgysoRP+6Tw==} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - resolution: - integrity: sha512-Jl4fzbBExHYMoC6HBPzel0a9VmhcSXx24LVt/mdhDM90MuzoMCJjXZAlhA0V0CJi+SKjMhfBoIQ6Lla1nD4QNw== - /rc-rate/2.9.1_react-dom@17.0.2+react@17.0.2: dependencies: '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - dev: false - engines: - node: '>=8.x' + react-is: 16.13.1 + shallowequal: 1.1.0 + + /re-resizable/6.9.0_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-3cUDG81ylyqI0Pdgle/RHwwRYq0ORZzsUaySOCO8IbEtNyaRtrIHYm/jMQ5pjcNiKCxR3vsSymIQZHwJq4gg2Q==} peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-MmIU7FT8W4LYRRHJD1sgG366qKtSaKb67D0/vVvJYR0lrCuRrCiVQ5qhfT5ghVO4wuVIORGpZs7ZKaYu+KMUzA== - /rc-resize-observer/1.0.0_react-dom@17.0.2+react@17.0.2: + react: ^16.13.1 || ^17.0.0 + react-dom: ^16.13.1 || ^17.0.0 dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 + fast-memoize: 2.5.2 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 - resize-observer-polyfill: 1.5.1 dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-RgKGukg1mlzyGdvzF7o/LGFC8AeoMH9aGzXTUdp6m+OApvmRdUuOscq/Y2O45cJA+rXt1ApWlpFoOIioXL3AGg== - /rc-select/12.1.7_react-dom@17.0.2+react@17.0.2: + + /react-app-polyfill/2.0.0: + resolution: {integrity: sha512-0sF4ny9v/B7s6aoehwze9vJNWcmCemAUYBVasscVr92+UYiEqDXOxfKjXN685mDaMRNF3WdhHQs76oTODMocFA==} + engines: {node: '>=10'} dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-motion: 2.4.1_react-dom@17.0.2+react@17.0.2 - rc-overflow: 1.0.2_react-dom@17.0.2+react@17.0.2 - rc-trigger: 5.2.3_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - rc-virtual-list: 3.2.6_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 + core-js: 3.9.1 + object-assign: 4.1.1 + promise: 8.1.0 + raf: 3.4.1 + regenerator-runtime: 0.13.7 + whatwg-fetch: 3.6.2 dev: false - engines: - node: '>=8.x' + + /react-attr-converter/0.3.1: + resolution: {integrity: sha512-dSxo2Mn6Zx4HajeCeQNLefwEO4kNtV/0E682R1+ZTyFRPqxDa5zYb5qM/ocqw9Bxr/kFQO0IUiqdV7wdHw+Cdg==} + dev: true + + /react-chartjs-2/3.0.4_chart.js@3.5.0+react@17.0.2: + resolution: {integrity: sha512-pcbFNpkPMTkGXXJ7k7hnukbRD0ZV01qB6JQY1ontITc2IYvhGlK6BBDy28VeydYs1Dl/c5ZpRgRVEtT5GUnxcQ==} peerDependencies: - react: '*' - react-dom: '*' - resolution: - integrity: sha512-sLZlfp+U7Typ+jPM5gTi8I4/oJalRw8kyhxZZ9Q4mEfO2p+otd1Chmzhh+wPraBY3IwE0RZM2/x1Leg/kQKk/w== - /rc-slider/9.7.2_react-dom@17.0.2+react@17.0.2: + chart.js: ^3.1.0 + react: ^16.8.0 || ^17.0.0 dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-tooltip: 5.1.0_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 + chart.js: 3.5.0 + lodash: 4.17.21 react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - shallowequal: 1.1.0 dev: false - engines: - node: '>=8.x' + + /react-clientside-effect/1.2.6_react@17.0.2: + resolution: {integrity: sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==} peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-mVaLRpDo6otasBs6yVnG02ykI3K6hIrLTNfT5eyaqduFv95UODI9PDS6fWuVVehVpdS4ENgOSwsTjrPVun+k9g== - /rc-steps/4.1.3_react-dom@17.0.2+react@17.0.2: + react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 + '@babel/runtime': 7.20.13 react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 dev: false - engines: - node: '>=8.x' - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-GXrMfWQOhN3sVze3JnzNboHpQdNHcdFubOETUHyDpa/U3HEKBZC3xJ8XK4paBgF4OJ3bdUVLC+uBPc6dCxvDYA== - /rc-switch/3.2.2_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-+gUJClsZZzvAHGy1vZfnwySxj+MjLlGRyXKXScrtCTcmiYNPzxDFOxdQ/3pK1Kt/0POvwJ/6ALOR8gwdXGhs+A== - /rc-table/7.13.3_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-resize-observer: 1.0.0_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - shallowequal: 1.1.0 - dev: false - engines: - node: '>=8.x' - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-oP4fknjvKCZAaiDnvj+yzBaWcg+JYjkASbeWonU1BbrLcomkpKvMUgPODNEzg0QdXA9OGW0PO86h4goDSW06Kg== - /rc-tabs/11.7.3_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-dropdown: 3.2.0_react-dom@17.0.2+react@17.0.2 - rc-menu: 8.10.6_react-dom@17.0.2+react@17.0.2 - rc-resize-observer: 1.0.0_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - engines: - node: '>=8.x' - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-5nd2NVss9TprPRV9r8N05SjQyAE7zDrLejxFLcbJ+BdLxSwnGnk3ws/Iq0smqKZUnPQC0XEvnpF3+zlllUUT2w== - /rc-textarea/0.3.4_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-resize-observer: 1.0.0_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-ILUYx831ZukQPv3m7R4RGRtVVWmL1LV4ME03L22mvT56US0DGCJJaRTHs4vmpcSjFHItph5OTmhodY4BOwy81A== - /rc-tooltip/5.1.0_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - rc-trigger: 5.2.3_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-pFqD1JZwNIpbdcefB7k5xREoHAWM/k3yQwYF0iminbmDXERgq4rvBfUwIvlCqqZSM7HDr9hYeYr6ZsVNaKtvCQ== - /rc-tree-select/4.3.1_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-select: 12.1.7_react-dom@17.0.2+react@17.0.2 - rc-tree: 4.1.5_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '*' - react-dom: '*' - resolution: - integrity: sha512-OeV8u5kBEJ8MbatP04Rh8T3boOHGjdGBTEm1a0bubBbB2GNNhlMOr4ZxezkHYtXf02JdBS/WyydmI/RMjXgtJA== - /rc-tree/4.1.5_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-motion: 2.4.1_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - rc-virtual-list: 3.2.6_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - engines: - node: '>=8.x' - peerDependencies: - react: '*' - react-dom: '*' - resolution: - integrity: sha512-q2vjcmnBDylGZ9/ZW4F9oZMKMJdbFWC7um+DAQhZG1nqyg1iwoowbBggUDUaUOEryJP+08bpliEAYnzJXbI5xQ== - /rc-trigger/5.2.3_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-align: 4.0.9_react-dom@17.0.2+react@17.0.2 - rc-motion: 2.4.1_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - engines: - node: '>=8.x' - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-6Fokao07HUbqKIDkDRFEM0AGZvsvK0Fbp8A/KFgl1ngaqfO1nY037cISCG1Jm5fxImVsXp9awdkP7Vu5cxjjog== - /rc-upload/4.2.0_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - classnames: 2.2.6 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-BXtvBs1PnwLjaUzBBU5z4yb9NMSaxc6mUIoPmS9LUAzaTz12L3TLrwu+8dnopYUiyLmYFS3LEO7aUfEWBqJfSA== - /rc-util/5.9.8_react-dom@17.0.2+react@17.0.2: - dependencies: - '@babel/runtime': 7.13.10 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - react-is: 16.13.1 - shallowequal: 1.1.0 - dev: false - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - resolution: - integrity: sha512-typLSHYGf5irvGLYQshs0Ra3aze086h0FhzsAkyirMunYZ7b3Te8gKa5PVaanoHaZa9sS6qx98BxgysoRP+6Tw== - /rc-virtual-list/3.2.6_react-dom@17.0.2+react@17.0.2: - dependencies: - classnames: 2.2.6 - rc-resize-observer: 1.0.0_react-dom@17.0.2+react@17.0.2 - rc-util: 5.9.8_react-dom@17.0.2+react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false - engines: - node: '>=8.x' - peerDependencies: - react: '*' - react-dom: '*' - resolution: - integrity: sha512-8FiQLDzm3c/tMX0d62SQtKDhLH7zFlSI6pWBAPt+TUntEqd3Lz9zFAmpvTu8gkvUom/HCsDSZs4wfV4wDPWC0Q== - /react-app-polyfill/2.0.0: - dependencies: - core-js: 3.9.1 - object-assign: 4.1.1 - promise: 8.1.0 - raf: 3.4.1 - regenerator-runtime: 0.13.7 - whatwg-fetch: 3.6.2 - dev: false - engines: - node: '>=10' - resolution: - integrity: sha512-0sF4ny9v/B7s6aoehwze9vJNWcmCemAUYBVasscVr92+UYiEqDXOxfKjXN685mDaMRNF3WdhHQs76oTODMocFA== - /react-chartjs-2/3.0.3_chart.js@3.2.1+react@17.0.2: - dependencies: - chart.js: 3.2.1 - lodash: 4.17.21 - react: 17.0.2 - dev: false - peerDependencies: - chart.js: ^3.1.0 - react: ^16.8.0 || ^17.0.0 - resolution: - integrity: sha512-jOFZKwZ8sMLkddewZ/tToxuu4pYimAvvY5I6uK+hCpSFT16Pvo2bdHhUoZ0X87zu9I+dx2I+JCqaLN6XhmrbDg== + /react-dev-utils/11.0.4: + resolution: {integrity: sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A==} + engines: {node: '>=10'} dependencies: '@babel/code-frame': 7.10.4 address: 1.1.2 @@ -11476,47 +12218,64 @@ packages: strip-ansi: 6.0.0 text-table: 0.2.0 dev: false - engines: - node: '>=10' - resolution: - integrity: sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A== - /react-dom/17.0.2_react@17.0.2: + + /react-docgen-typescript-dumi-tmp/1.22.1-0_typescript@4.2.3: + resolution: {integrity: sha512-wjuAm1yj+ZZucovow2VF0MXkH2SGZ+squZxfNdnam3oyUbHy/xZaU1ZabCn7rY+13ZFx0/NLda+ZuBgF3g8vBA==} + peerDependencies: + typescript: '>= 3.x' + dependencies: + typescript: 4.2.3 + dev: true + + /react-dom/16.14.0_react@16.14.0: + resolution: {integrity: sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==} + peerDependencies: + react: ^16.14.0 dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 - react: 17.0.2 - scheduler: 0.20.2 - dev: false + prop-types: 15.7.2 + react: 16.14.0 + scheduler: 0.19.1 + dev: true + + /react-dom/17.0.2_react@17.0.2: + resolution: {integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==} peerDependencies: react: 17.0.2 - resolution: - integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== - /react-drag-listview/0.1.8: dependencies: - babel-runtime: 6.26.0 - prop-types: 15.7.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + react: 17.0.2 + scheduler: 0.20.2 dev: false - resolution: - integrity: sha512-ZJnjFEz89RPZ1DzI8f6LngmtsmJbLry/pMz2tEqABxHA+d8cUFRmVPS1DxZdoz/htc+uri9fCdv4dqIiPz0xIA== + /react-draggable/4.4.3: + resolution: {integrity: sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==} dependencies: classnames: 2.2.6 prop-types: 15.7.2 dev: false - resolution: - integrity: sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w== - /react-error-overlay/6.0.9: - dev: false - resolution: - integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== - /react-eva/1.1.14: + + /react-error-boundary/3.1.3_react@17.0.2: + resolution: {integrity: sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' dependencies: - rxjs: 6.6.7 - rxjs-compat: 6.6.7 + '@babel/runtime': 7.14.8 + react: 17.0.2 + dev: true + + /react-error-overlay/6.0.9: + resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==} dev: false - resolution: - integrity: sha512-/5EeqL425sgDX0yhtNIoJh0kdjt1i/FFwh0OcnEZri83FIGNzaAXmNm7lbnSd2wvedgJ1/qnYPcOz5HuF2UvBA== + /react-flow-renderer/9.4.0_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-jv3w2RnoK5CC4tSKD48x9HWoaZZFjQbGAtu5lIeYtmgBKRaOFrA2t+zxgdenSALAXd2k9J+L5hXlY18FvtBmmA==} + peerDependencies: + react: "16 ||\_17" + react-dom: "16 ||\_17" dependencies: '@babel/runtime': 7.13.10 '@types/d3': 6.3.0 @@ -11530,32 +12289,59 @@ packages: react-draggable: 4.4.3 react-redux: 7.2.3_8436876974e3dcafae98d64b636de192 redux: 4.0.5 + transitivePeerDependencies: + - react-native dev: false + + /react-focus-lock/2.9.3_5170878e5e8a60dfb58a26e1cbcc99ef: + resolution: {integrity: sha512-cGNkz9p5Fpqio6hBHlkKxzRYrBYtcPosFOL6Q3N/LSbHjwP/PTBqHpvbgaOYoE7rWfzw8qXPKTB3Tk/VPgw4NQ==} peerDependencies: - react: "16 ||\_17" - react-dom: "16 ||\_17" - resolution: - integrity: sha512-jv3w2RnoK5CC4tSKD48x9HWoaZZFjQbGAtu5lIeYtmgBKRaOFrA2t+zxgdenSALAXd2k9J+L5hXlY18FvtBmmA== + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.20.13 + '@types/react': 16.14.5 + focus-lock: 0.11.5 + prop-types: 15.8.1 + react: 17.0.2 + react-clientside-effect: 1.2.6_react@17.0.2 + use-callback-ref: 1.3.0_5170878e5e8a60dfb58a26e1cbcc99ef + use-sidecar: 1.1.2_5170878e5e8a60dfb58a26e1cbcc99ef + dev: false + /react-i18next/11.8.12_i18next@19.9.2+react@17.0.2: + resolution: {integrity: sha512-M2PSVP9MzT/7yofXfCOF5gAVotinrM4BXWiguk8uFSznJsfFzTjrp3K9CBWcXitpoCBVZGZJ2AnbaWGSNkJqfw==} + peerDependencies: + i18next: '>= 19.0.0' + react: '>= 16.8.0' dependencies: '@babel/runtime': 7.13.10 html-parse-stringify2: 2.0.1 i18next: 19.9.2 react: 17.0.2 dev: false - peerDependencies: - i18next: '>= 19.0.0' - react: '>= 16.8.0' - resolution: - integrity: sha512-M2PSVP9MzT/7yofXfCOF5gAVotinrM4BXWiguk8uFSznJsfFzTjrp3K9CBWcXitpoCBVZGZJ2AnbaWGSNkJqfw== + /react-is/16.13.1: - resolution: - integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + /react-is/17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} dev: true - resolution: - integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + /react-query/3.13.0_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-CzBvgjMh8jNJMSPhXCE92DBIFbE31j8PA2k7ipR1F8DlcNAEsZwLsUzh1cTtzpDaS2+r6sntgmM6qKnCD6E5zQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true dependencies: '@babel/runtime': 7.13.10 broadcast-channel: 3.5.3 @@ -11563,18 +12349,19 @@ packages: react: 17.0.2 react-dom: 17.0.2_react@17.0.2 dev: false + + /react-redux/7.2.3_8436876974e3dcafae98d64b636de192: + resolution: {integrity: sha512-ZhAmQ1lrK+Pyi0ZXNMUZuYxYAZd59wFuVDGUt536kSGdD0ya9Q7BfsE95E3TsFLE3kOSFp5m6G5qbatE+Ic1+w==} peerDependencies: - react: ^16.8.0 || ^17.0.0 + react: ^16.8.3 || ^17 react-dom: '*' react-native: '*' + redux: ^2.0.0 || ^3.0.0 || ^4.0.0-0 peerDependenciesMeta: react-dom: optional: true react-native: optional: true - resolution: - integrity: sha512-CzBvgjMh8jNJMSPhXCE92DBIFbE31j8PA2k7ipR1F8DlcNAEsZwLsUzh1cTtzpDaS2+r6sntgmM6qKnCD6E5zQ== - /react-redux/7.2.3_8436876974e3dcafae98d64b636de192: dependencies: '@babel/runtime': 7.13.10 '@types/react-redux': 7.1.16 @@ -11586,25 +12373,58 @@ packages: react-is: 16.13.1 redux: 4.0.5 dev: false - peerDependencies: - react: ^16.8.3 || ^17 - react-dom: '*' - react-native: '*' - redux: ^2.0.0 || ^3.0.0 || ^4.0.0-0 - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - resolution: - integrity: sha512-ZhAmQ1lrK+Pyi0ZXNMUZuYxYAZd59wFuVDGUt536kSGdD0ya9Q7BfsE95E3TsFLE3kOSFp5m6G5qbatE+Ic1+w== + + /react-refresh/0.10.0: + resolution: {integrity: sha512-PgidR3wST3dDYKr6b4pJoqQFpPGNKDSCDx4cZoshjXipw3LzO7mG1My2pwEzz2JVkF+inx3xRpDeQLFQGH/hsQ==} + engines: {node: '>=0.10.0'} + dev: true + /react-refresh/0.8.3: + resolution: {integrity: sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==} + engines: {node: '>=0.10.0'} dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== + + /react-router-config/5.1.1_react-router@5.2.0+react@16.14.0: + resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==} + peerDependencies: + react: '>=15' + react-router: '>=5' + dependencies: + '@babel/runtime': 7.14.8 + react: 16.14.0 + react-router: 5.2.0_react@17.0.2 + dev: true + + /react-router-config/5.1.1_react-router@5.2.0+react@17.0.2: + resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==} + peerDependencies: + react: '>=15' + react-router: '>=5' + dependencies: + '@babel/runtime': 7.14.8 + react: 17.0.2 + react-router: 5.2.0_react@17.0.2 + dev: true + + /react-router-dom/5.2.0_react@16.14.0: + resolution: {integrity: sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==} + peerDependencies: + react: '>=15' + dependencies: + '@babel/runtime': 7.13.10 + history: 4.10.1 + loose-envify: 1.4.0 + prop-types: 15.7.2 + react: 16.14.0 + react-router: 5.2.0_react@16.14.0 + tiny-invariant: 1.1.0 + tiny-warning: 1.0.3 + dev: true + /react-router-dom/5.2.0_react@17.0.2: + resolution: {integrity: sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==} + peerDependencies: + react: '>=15' dependencies: '@babel/runtime': 7.13.10 history: 4.10.1 @@ -11614,12 +12434,29 @@ packages: react-router: 5.2.0_react@17.0.2 tiny-invariant: 1.1.0 tiny-warning: 1.0.3 - dev: false + + /react-router/5.2.0_react@16.14.0: + resolution: {integrity: sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==} peerDependencies: react: '>=15' - resolution: - integrity: sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== + dependencies: + '@babel/runtime': 7.13.10 + history: 4.10.1 + hoist-non-react-statics: 3.3.2 + loose-envify: 1.4.0 + mini-create-react-context: 0.4.1_prop-types@15.7.2+react@16.14.0 + path-to-regexp: 1.8.0 + prop-types: 15.7.2 + react: 16.14.0 + react-is: 16.13.1 + tiny-invariant: 1.1.0 + tiny-warning: 1.0.3 + dev: true + /react-router/5.2.0_react@17.0.2: + resolution: {integrity: sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==} + peerDependencies: + react: '>=15' dependencies: '@babel/runtime': 7.13.10 history: 4.10.1 @@ -11632,29 +12469,36 @@ packages: react-is: 16.13.1 tiny-invariant: 1.1.0 tiny-warning: 1.0.3 - dev: false + + /react-transition-group/4.4.5_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: - react: '>=15' - resolution: - integrity: sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== - /react-stikky/0.1.30: + react: '>=16.6.0' + react-dom: '>=16.6.0' dependencies: - classnames: 2.2.6 - window-scroll: 1.0.0 + '@babel/runtime': 7.20.13 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 dev: false - resolution: - integrity: sha512-qzt3MF/t3VoXLPQl4AY6+0jIdIIA9LdZgxwjniYWLWgPxPHGzbkxHOSlYAs+JrEm//9mxFCwS2i2kEUKrS3ewA== + /react-universal-interface/0.6.2_react@17.0.2+tslib@2.1.0: + resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==} + peerDependencies: + react: '*' + tslib: '*' dependencies: react: 17.0.2 tslib: 2.1.0 dev: false - peerDependencies: - react: '*' - tslib: '*' - resolution: - integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw== + /react-use/15.3.8_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-GeGcrmGuUvZrY5wER3Lnph9DSYhZt5nEjped4eKDq8BRGr2CnLf9bDQWG9RFc7oCPphnscUUdOovzq0E5F2c6Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 + react-dom: ^16.8.0 || ^17.0.0 dependencies: '@types/js-cookie': 2.2.6 '@xobotyi/scrollbar-width': 1.9.5 @@ -11673,61 +12517,62 @@ packages: ts-easing: 0.2.0 tslib: 2.1.0 dev: false - peerDependencies: - react: ^16.8.0 || ^17.0.0 - react-dom: ^16.8.0 || ^17.0.0 - resolution: - integrity: sha512-GeGcrmGuUvZrY5wER3Lnph9DSYhZt5nEjped4eKDq8BRGr2CnLf9bDQWG9RFc7oCPphnscUUdOovzq0E5F2c6Q== + + /react/16.14.0: + resolution: {integrity: sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + prop-types: 15.7.2 + dev: true + /react/17.0.2: + resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==} + engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + /read-pkg-up/2.0.0: + resolution: {integrity: sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=} + engines: {node: '>=4'} dependencies: find-up: 2.1.0 read-pkg: 2.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= + /read-pkg-up/7.0.1: + resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} + engines: {node: '>=8'} dependencies: find-up: 4.1.0 read-pkg: 5.2.0 type-fest: 0.8.1 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + /read-pkg/2.0.0: + resolution: {integrity: sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=} + engines: {node: '>=4'} dependencies: load-json-file: 2.0.0 normalize-package-data: 2.5.0 path-type: 2.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= + /read-pkg/5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} dependencies: '@types/normalize-package-data': 2.4.0 normalize-package-data: 2.5.0 parse-json: 5.2.0 type-fest: 0.6.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + /readable-stream/2.3.7: + resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==} dependencies: core-util-is: 1.0.2 inherits: 2.0.4 @@ -11737,50 +12582,49 @@ packages: string_decoder: 1.1.1 util-deprecate: 1.0.2 dev: true - resolution: - integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + /readable-stream/3.6.0: + resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==} + engines: {node: '>= 6'} dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + /readdirp/2.2.1: + resolution: {integrity: sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==} + engines: {node: '>=0.10'} dependencies: graceful-fs: 4.2.6 micromatch: 3.1.10 readable-stream: 2.3.7 dev: true - engines: - node: '>=0.10' - resolution: - integrity: sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== + /readdirp/3.5.0: + resolution: {integrity: sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==} + engines: {node: '>=8.10.0'} dependencies: picomatch: 2.2.2 dev: true - engines: - node: '>=8.10.0' + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true optional: true - resolution: - integrity: sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== + /rechoir/0.7.0: + resolution: {integrity: sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==} + engines: {node: '>= 0.10'} dependencies: resolve: 1.18.1 dev: true - engines: - node: '>= 0.10' - resolution: - integrity: sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q== - /recoil/0.1.3_react-dom@17.0.2+react@17.0.2: - dependencies: - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - dev: false + + /recoil/0.3.1_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-KNA3DRqgxX4rRC8E7fc6uIw7BACmMPuraIYy+ejhE8tsw7w32CetMm8w7AMZa34wzanKKkev3vl3H7Z4s0QSiA==} peerDependencies: react: '>=16.13.1' react-dom: '*' @@ -11790,84 +12634,96 @@ packages: optional: true react-native: optional: true - resolution: - integrity: sha512-/Rm7wN7jqCjhtFK1TgtK0V115SUXNu6d4QYvwxWNLydib0QChSmpB6U8CaHoRPS0MFWtAIsD/IFjpbfk/OYm7Q== + dependencies: + hamt_plus: 1.0.2 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + dev: false + /recursive-readdir/2.2.2: + resolution: {integrity: sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg==} + engines: {node: '>=0.10.0'} dependencies: minimatch: 3.0.4 dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg== + /redent/3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + /redux/4.0.5: + resolution: {integrity: sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==} dependencies: loose-envify: 1.4.0 symbol-observable: 1.2.0 dev: false - resolution: - integrity: sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== + /regenerate-unicode-properties/8.2.0: + resolution: {integrity: sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==} + engines: {node: '>=4'} dependencies: regenerate: 1.4.2 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA== + /regenerate/1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} dev: true - resolution: - integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== + /regenerator-runtime/0.11.1: - resolution: - integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== + resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==} + dev: true + + /regenerator-runtime/0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + dev: false + + /regenerator-runtime/0.13.5: + resolution: {integrity: sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==} + dev: true + /regenerator-runtime/0.13.7: - resolution: - integrity: sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== + resolution: {integrity: sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==} + + /regenerator-runtime/0.13.9: + resolution: {integrity: sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==} + /regenerator-transform/0.14.5: + resolution: {integrity: sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==} dependencies: '@babel/runtime': 7.13.10 dev: true - resolution: - integrity: sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw== + /regex-not/1.0.2: + resolution: {integrity: sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==} + engines: {node: '>=0.10.0'} dependencies: extend-shallow: 3.0.2 safe-regex: 1.1.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + /regex-parser/2.2.11: + resolution: {integrity: sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==} dev: true - resolution: - integrity: sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q== + /regexp.prototype.flags/1.3.1: + resolution: {integrity: sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 dev: true - engines: - node: '>= 0.4' - resolution: - integrity: sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA== + /regexpp/3.1.0: + resolution: {integrity: sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== + /regexpu-core/4.7.1: + resolution: {integrity: sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==} + engines: {node: '>=4'} dependencies: regenerate: 1.4.2 regenerate-unicode-properties: 8.2.0 @@ -11876,36 +12732,115 @@ packages: unicode-match-property-ecmascript: 1.0.4 unicode-match-property-value-ecmascript: 1.2.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== + /regjsgen/0.5.2: + resolution: {integrity: sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==} dev: true - resolution: - integrity: sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A== + /regjsparser/0.6.9: + resolution: {integrity: sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ==} + hasBin: true dependencies: jsesc: 0.5.0 dev: true - hasBin: true - resolution: - integrity: sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ== + + /rehype-autolink-headings/4.0.0: + resolution: {integrity: sha512-2lglJ+4S3A4RCz+zlKVWj1wHvwO4bjunAoEOgMfjphT59EVXwdMiJzrL/A2fuAX/33k/LhkGW6BEK1Cl1I5WQw==} + dependencies: + extend: 3.0.2 + hast-util-has-property: 1.0.4 + hast-util-is-element: 1.1.0 + unist-util-visit: 2.0.3 + dev: true + + /rehype-mathjax/3.1.0: + resolution: {integrity: sha512-Pmz92Y56lBFmDjFc9nIdrKu1xzKSBYevcwKiKiG7b5JJg74q1E62nRSbPEm37vXaXn7Bn25iRsWcP39bJKkMxg==} + dependencies: + '@types/mathjax': 0.0.36 + hast-util-from-dom: 3.0.0 + hast-util-to-text: 2.0.1 + jsdom: 16.5.2 + mathjax-full: 3.2.0 + unist-util-visit: 2.0.3 + transitivePeerDependencies: + - bufferutil + - canvas + - utf-8-validate + dev: true + + /rehype-remove-comments/4.0.2: + resolution: {integrity: sha512-E2FNohTuIs7QzUnEQs3SdYdCScsTgUN7yPeDNWi+gsvx+pbLzIAyp27TWz3Gm64jpdLi7/6HxyRHxdd1NVQ37A==} + dependencies: + hast-util-is-conditional-comment: 1.0.4 + unist-util-filter: 2.0.3 + dev: true + + /rehype-stringify/8.0.0: + resolution: {integrity: sha512-VkIs18G0pj2xklyllrPSvdShAV36Ff3yE5PUO9u36f6+2qJFnn22Z5gKwBOwgXviux4UC7K+/j13AnZfPICi/g==} + dependencies: + hast-util-to-html: 7.1.3 + dev: true + /relateurl/0.2.7: + resolution: {integrity: sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=} + engines: {node: '>= 0.10'} dev: true - engines: - node: '>= 0.10' - resolution: - integrity: sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= + + /remark-frontmatter/3.0.0: + resolution: {integrity: sha512-mSuDd3svCHs+2PyO29h7iijIZx4plX0fheacJcAoYAASfgzgVIcXGYSq9GFyYocFLftQs8IOmmkgtOovs6d4oA==} + dependencies: + mdast-util-frontmatter: 0.2.0 + micromark-extension-frontmatter: 0.2.2 + dev: true + + /remark-gfm/1.0.0: + resolution: {integrity: sha512-KfexHJCiqvrdBZVbQ6RopMZGwaXz6wFJEfByIuEwGf0arvITHjiKKZ1dpXujjH9KZdm1//XJQwgfnJ3lmXaDPA==} + dependencies: + mdast-util-gfm: 0.1.2 + micromark-extension-gfm: 0.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /remark-math/4.0.0: + resolution: {integrity: sha512-lH7SoQenXtQrvL0bm+mjZbvOk//YWNuyR+MxV18Qyv8rgFmMEGNuB0TSCQDkoDaiJ40FCnG8lxErc/zhcedYbw==} + dependencies: + mdast-util-math: 0.1.2 + micromark-extension-math: 0.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /remark-parse/9.0.0: + resolution: {integrity: sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==} + dependencies: + mdast-util-from-markdown: 0.8.5 + transitivePeerDependencies: + - supports-color + dev: true + + /remark-rehype/8.1.0: + resolution: {integrity: sha512-EbCu9kHgAxKmW1yEYjx3QafMyGY3q8noUbNUI5xyKbaFP89wbhDrKxyIQNukNYthzjNHZu6J7hwFg7hRm1svYA==} + dependencies: + mdast-util-to-hast: 10.2.0 + dev: true + + /remark-stringify/9.0.1: + resolution: {integrity: sha512-mWmNg3ZtESvZS8fv5PTvaPckdL4iNlCHTt8/e/8oN08nArHRHjNZMKzA/YW3+p7/lYqIw4nx1XsjCBo/AxNChg==} + dependencies: + mdast-util-to-markdown: 0.6.5 + dev: true + /remove-accents/0.4.2: + resolution: {integrity: sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=} dev: false - resolution: - integrity: sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U= + /remove-trailing-separator/1.1.0: + resolution: {integrity: sha1-wkvOKig62tW8P1jg1IJJuSN52O8=} dev: true - resolution: - integrity: sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + /renderkid/2.0.5: + resolution: {integrity: sha512-ccqoLg+HLOHq1vdfYNm4TBeaCDIi1FLt3wGojTDSvdewUv65oTmI3cnT2E4hRjl1gzKZIPK+KZrXzlUYKnR+vQ==} dependencies: css-select: 2.1.0 dom-converter: 0.2.0 @@ -11913,44 +12848,42 @@ packages: lodash: 4.17.21 strip-ansi: 3.0.1 dev: true - resolution: - integrity: sha512-ccqoLg+HLOHq1vdfYNm4TBeaCDIi1FLt3wGojTDSvdewUv65oTmI3cnT2E4hRjl1gzKZIPK+KZrXzlUYKnR+vQ== + /repeat-element/1.1.3: - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== + resolution: {integrity: sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==} + engines: {node: '>=0.10.0'} + /repeat-string/1.6.1: - engines: - node: '>=0.10' - resolution: - integrity: sha1-jcrkcOHIirwtYA//Sndihtp15jc= + resolution: {integrity: sha1-jcrkcOHIirwtYA//Sndihtp15jc=} + engines: {node: '>=0.10'} + /request-promise-core/1.1.4_request@2.88.2: + resolution: {integrity: sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==} + engines: {node: '>=0.10.0'} + peerDependencies: + request: ^2.34 dependencies: lodash: 4.17.21 request: 2.88.2 dev: true - engines: - node: '>=0.10.0' + + /request-promise-native/1.0.9_request@2.88.2: + resolution: {integrity: sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==} + engines: {node: '>=0.12.0'} + deprecated: request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142 peerDependencies: request: ^2.34 - resolution: - integrity: sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw== - /request-promise-native/1.0.9_request@2.88.2: dependencies: request: 2.88.2 request-promise-core: 1.1.4_request@2.88.2 stealthy-require: 1.1.1 tough-cookie: 2.5.0 - deprecated: request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142 dev: true - engines: - node: '>=0.12.0' - peerDependencies: - request: ^2.34 - resolution: - integrity: sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g== + /request/2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 dependencies: aws-sign2: 0.7.0 aws4: 1.11.0 @@ -11972,75 +12905,64 @@ packages: tough-cookie: 2.5.0 tunnel-agent: 0.6.0 uuid: 3.4.0 - deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + /require-directory/2.1.1: + resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + /require-from-string/2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + /require-main-filename/2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} dev: true - resolution: - integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + /requires-port/1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} dev: true - resolution: - integrity: sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + /resize-observer-polyfill/1.5.1: - dev: false - resolution: - integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + /resolve-cwd/2.0.0: + resolution: {integrity: sha1-AKn3OHVW4nA46uIyyqNypqWbZlo=} + engines: {node: '>=4'} dependencies: resolve-from: 3.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= + /resolve-cwd/3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} dependencies: resolve-from: 5.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + /resolve-from/3.0.0: + resolution: {integrity: sha1-six699nWiBvItuZTM17rywoYh0g=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-six699nWiBvItuZTM17rywoYh0g= + /resolve-from/4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + /resolve-from/5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + /resolve-pathname/3.0.0: - dev: false - resolution: - integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} + /resolve-url-loader/3.1.2: + resolution: {integrity: sha512-QEb4A76c8Mi7I3xNKXlRKQSlLBwjUV/ULFMP+G7n3/7tJZ8MG5wsZ3ucxP1Jz8Vevn6fnJsxDx9cIls+utGzPQ==} + engines: {node: '>=6.0.0'} dependencies: adjust-sourcemap-loader: 3.0.0 camelcase: 5.3.1 @@ -12053,107 +12975,102 @@ packages: rework-visit: 1.0.0 source-map: 0.6.1 dev: true - engines: - node: '>=6.0.0' - resolution: - integrity: sha512-QEb4A76c8Mi7I3xNKXlRKQSlLBwjUV/ULFMP+G7n3/7tJZ8MG5wsZ3ucxP1Jz8Vevn6fnJsxDx9cIls+utGzPQ== + /resolve-url/0.2.1: + resolution: {integrity: sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=} deprecated: https://github.com/lydell/resolve-url#deprecated - resolution: - integrity: sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + /resolve/1.18.1: + resolution: {integrity: sha512-lDfCPaMKfOJXjy0dPayzPdF1phampNWr3qFCjAu+rw/qbQmr5jWH5xN2hwh9QKfw9E5v4hwV7A+jrCmL8yjjqA==} dependencies: is-core-module: 2.2.0 path-parse: 1.0.6 dev: true - resolution: - integrity: sha512-lDfCPaMKfOJXjy0dPayzPdF1phampNWr3qFCjAu+rw/qbQmr5jWH5xN2hwh9QKfw9E5v4hwV7A+jrCmL8yjjqA== + /resolve/2.0.0-next.3: + resolution: {integrity: sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==} dependencies: is-core-module: 2.2.0 path-parse: 1.0.6 dev: true - resolution: - integrity: sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q== + /restore-cursor/3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} dependencies: onetime: 5.1.2 signal-exit: 3.0.3 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + /ret/0.1.15: - engines: - node: '>=0.12' - resolution: - integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + /retry/0.12.0: + resolution: {integrity: sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=} + engines: {node: '>= 4'} dev: true - engines: - node: '>= 4' - resolution: - integrity: sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + /reusify/1.0.4: - engines: - iojs: '>=1.0.0' - node: '>=0.10.0' - resolution: - integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + /rework-visit/1.0.0: + resolution: {integrity: sha1-mUWygD8hni96ygCtuLyfZA+ELJo=} dev: true - resolution: - integrity: sha1-mUWygD8hni96ygCtuLyfZA+ELJo= + /rework/1.0.1: + resolution: {integrity: sha1-MIBqhBNCtUUQqkEQhQzUhTQUSqc=} dependencies: convert-source-map: 0.3.5 css: 2.2.4 dev: true - resolution: - integrity: sha1-MIBqhBNCtUUQqkEQhQzUhTQUSqc= + /rgb-regex/1.0.1: + resolution: {integrity: sha1-wODWiC3w4jviVKR16O3UGRX+rrE=} dev: true - resolution: - integrity: sha1-wODWiC3w4jviVKR16O3UGRX+rrE= + /rgba-regex/1.0.0: + resolution: {integrity: sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=} dev: true - resolution: - integrity: sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= + /rimraf/2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true dependencies: glob: 7.1.6 dev: true - hasBin: true - resolution: - integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + /rimraf/3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true dependencies: glob: 7.1.6 - hasBin: true - resolution: - integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + /ripemd160/2.0.2: + resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==} dependencies: hash-base: 3.1.0 inherits: 2.0.4 dev: true - resolution: - integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + /rollup-plugin-babel/4.4.0_@babel+core@7.12.3+rollup@1.32.1: + resolution: {integrity: sha512-Lek/TYp1+7g7I+uMfJnnSJ7YWoD58ajo6Oarhlex7lvUce+RCKRuGRSgztDO3/MF/PuGKmUL5iTHKf208UNszw==} + deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-babel. + peerDependencies: + '@babel/core': 7 || ^7.0.0-rc.2 + rollup: '>=0.60.0 <3' dependencies: '@babel/core': 7.12.3 '@babel/helper-module-imports': 7.13.12 rollup: 1.32.1 rollup-pluginutils: 2.8.2 - deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-babel. dev: true - peerDependencies: - '@babel/core': 7 || ^7.0.0-rc.2 - rollup: '>=0.60.0 <3' - resolution: - integrity: sha512-Lek/TYp1+7g7I+uMfJnnSJ7YWoD58ajo6Oarhlex7lvUce+RCKRuGRSgztDO3/MF/PuGKmUL5iTHKf208UNszw== + /rollup-plugin-terser/5.3.1_rollup@1.32.1: + resolution: {integrity: sha512-1pkwkervMJQGFYvM9nscrUoncPwiKR/K+bHdjv6PFgRo3cgPHoRT83y2Aa3GvINj4539S15t/tpFPb775TDs6w==} + peerDependencies: + rollup: '>=0.66.0 <3' dependencies: '@babel/code-frame': 7.12.13 jest-worker: 24.9.0 @@ -12162,77 +13079,72 @@ packages: serialize-javascript: 4.0.0 terser: 4.8.0 dev: true - peerDependencies: - rollup: '>=0.66.0 <3' - resolution: - integrity: sha512-1pkwkervMJQGFYvM9nscrUoncPwiKR/K+bHdjv6PFgRo3cgPHoRT83y2Aa3GvINj4539S15t/tpFPb775TDs6w== + /rollup-pluginutils/2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} dependencies: estree-walker: 0.6.1 dev: true - resolution: - integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== + /rollup/1.32.1: + resolution: {integrity: sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==} + hasBin: true dependencies: '@types/estree': 0.0.47 '@types/node': 12.20.7 acorn: 7.4.1 dev: true - hasBin: true - resolution: - integrity: sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A== + /rsvp/4.8.5: + resolution: {integrity: sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==} + engines: {node: 6.* || >= 7.*} dev: true - engines: - node: 6.* || >= 7.* - resolution: - integrity: sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== + /rtl-css-js/1.14.0: + resolution: {integrity: sha512-Dl5xDTeN3e7scU1cWX8c9b6/Nqz3u/HgR4gePc1kWXYiQWVQbKCEyK6+Hxve9LbcJ5EieHy1J9nJCN3grTtGwg==} dependencies: '@babel/runtime': 7.13.10 dev: false - resolution: - integrity: sha512-Dl5xDTeN3e7scU1cWX8c9b6/Nqz3u/HgR4gePc1kWXYiQWVQbKCEyK6+Hxve9LbcJ5EieHy1J9nJCN3grTtGwg== + /run-parallel/1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 - resolution: - integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + /run-queue/1.0.3: + resolution: {integrity: sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=} dependencies: aproba: 1.2.0 dev: true - resolution: - integrity: sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec= - /rxjs-compat/6.6.7: - dev: false - resolution: - integrity: sha512-szN4fK+TqBPOFBcBcsR0g2cmTTUF/vaFEOZNuSdfU8/pGFnNmmn2u8SystYXG1QMrjOPBc6XTKHMVfENDf6hHw== + /rxjs/6.6.7: + resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} + engines: {npm: '>=2.0.0'} dependencies: tslib: 1.14.1 - engines: - npm: '>=2.0.0' - resolution: - integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== + dev: true + /safe-buffer/5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} dev: true - resolution: - integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + /safe-buffer/5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: true - resolution: - integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + /safe-regex/1.1.0: + resolution: {integrity: sha1-QKNmnzsHfR6UPURinhV91IAjvy4=} dependencies: ret: 0.1.15 - resolution: - integrity: sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + /safer-buffer/2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: true - resolution: - integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + /sane/4.1.0: + resolution: {integrity: sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==} + engines: {node: 6.* || 8.* || >= 10.*} + hasBin: true dependencies: '@cnakazawa/watch': 1.0.4 anymatch: 2.0.0 @@ -12244,26 +13156,14 @@ packages: minimist: 1.2.5 walker: 1.0.7 dev: true - engines: - node: 6.* || 8.* || >= 10.* - hasBin: true - resolution: - integrity: sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA== + /sanitize.css/10.0.0: + resolution: {integrity: sha512-vTxrZz4dX5W86M6oVWVdOVe72ZiPs41Oi7Z6Km4W5Turyz28mrXSJhhEBZoRtzJWIv3833WKVwLSDWWkEfupMg==} dev: true - resolution: - integrity: sha512-vTxrZz4dX5W86M6oVWVdOVe72ZiPs41Oi7Z6Km4W5Turyz28mrXSJhhEBZoRtzJWIv3833WKVwLSDWWkEfupMg== + /sass-loader/8.0.2_webpack@4.44.2: - dependencies: - clone-deep: 4.0.1 - loader-utils: 1.4.0 - neo-async: 2.6.2 - schema-utils: 2.7.1 - semver: 6.3.0 - webpack: 4.44.2_webpack-cli@4.6.0 - dev: true - engines: - node: '>= 8.9.0' + resolution: {integrity: sha512-7o4dbSK8/Ol2KflEmSco4jTjQoV988bM82P9CZdmo9hR3RLnvNc0ufMNdMrB0caq38JQ/FgF4/7RcbcfKzxoFQ==} + engines: {node: '>= 8.9.0'} peerDependencies: fibers: '>= 3.1.0' node-sass: ^4.0.0 @@ -12276,112 +13176,124 @@ packages: optional: true sass: optional: true - resolution: - integrity: sha512-7o4dbSK8/Ol2KflEmSco4jTjQoV988bM82P9CZdmo9hR3RLnvNc0ufMNdMrB0caq38JQ/FgF4/7RcbcfKzxoFQ== + dependencies: + clone-deep: 4.0.1 + loader-utils: 1.4.0 + neo-async: 2.6.2 + schema-utils: 2.7.1 + semver: 6.3.0 + webpack: 4.44.2_webpack-cli@4.6.0 + dev: true + /sax/1.2.4: + resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} dev: true - resolution: - integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + /saxes/5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} dependencies: xmlchars: 2.2.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== + /scheduler/0.19.1: + resolution: {integrity: sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==} dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 - dev: false - resolution: - integrity: sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== + dev: true + /scheduler/0.20.2: + resolution: {integrity: sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==} dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 dev: false - resolution: - integrity: sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + /schema-utils/1.0.0: + resolution: {integrity: sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==} + engines: {node: '>= 4'} dependencies: ajv: 6.12.6 ajv-errors: 1.0.1_ajv@6.12.6 ajv-keywords: 3.5.2_ajv@6.12.6 dev: true - engines: - node: '>= 4' - resolution: - integrity: sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g== + /schema-utils/2.7.1: + resolution: {integrity: sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==} + engines: {node: '>= 8.9.0'} dependencies: '@types/json-schema': 7.0.7 ajv: 6.12.6 ajv-keywords: 3.5.2_ajv@6.12.6 dev: true - engines: - node: '>= 8.9.0' - resolution: - integrity: sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== + /schema-utils/3.0.0: + resolution: {integrity: sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==} + engines: {node: '>= 10.13.0'} dependencies: '@types/json-schema': 7.0.7 ajv: 6.12.6 ajv-keywords: 3.5.2_ajv@6.12.6 dev: true - engines: - node: '>= 10.13.0' - resolution: - integrity: sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== + + /schema-utils/3.1.0: + resolution: {integrity: sha512-tTEaeYkyIhEZ9uWgAjDerWov3T9MgX8dhhy2r0IGeeX4W8ngtGl1++dUve/RUqzuaASSh7shwCDJjEzthxki8w==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.8 + ajv: 6.12.6 + ajv-keywords: 3.5.2_ajv@6.12.6 + dev: true + /screenfull/5.1.0: + resolution: {integrity: sha512-dYaNuOdzr+kc6J6CFcBrzkLCfyGcMg+gWkJ8us93IQ7y1cevhQAugFsaCdMHb6lw8KV3xPzSxzH7zM1dQap9mA==} + engines: {node: '>=0.10.0'} dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-dYaNuOdzr+kc6J6CFcBrzkLCfyGcMg+gWkJ8us93IQ7y1cevhQAugFsaCdMHb6lw8KV3xPzSxzH7zM1dQap9mA== - /scroll-into-view-if-needed/2.2.28: + + /scroll-into-view-if-needed/2.2.20: + resolution: {integrity: sha512-P9kYMrhi9f6dvWwTGpO5I3HgjSU/8Mts7xL3lkoH5xlewK7O9Obdc5WmMCzppln7bCVGNmf3qfoZXrpCeyNJXw==} dependencies: - compute-scroll-into-view: 1.0.17 + compute-scroll-into-view: 1.0.11 dev: false - resolution: - integrity: sha512-8LuxJSuFVc92+0AdNv4QOxRL4Abeo1DgLnGNkn1XlaujPH/3cCFz3QI60r2VNu4obJJROzgnIUw5TKQkZvZI1w== + /select-hose/2.0.0: + resolution: {integrity: sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=} dev: true - resolution: - integrity: sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= + /selfsigned/1.10.8: + resolution: {integrity: sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==} dependencies: node-forge: 0.10.0 dev: true - resolution: - integrity: sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w== + /semver-compare/1.0.0: + resolution: {integrity: sha1-De4hahyUGrN+nvsXiPavxf9VN/w=} dev: true - resolution: - integrity: sha1-De4hahyUGrN+nvsXiPavxf9VN/w= + /semver/5.7.1: + resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} hasBin: true - resolution: - integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + /semver/6.3.0: - dev: true + resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} hasBin: true - resolution: - integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - /semver/7.0.0: dev: true + + /semver/7.0.0: + resolution: {integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==} hasBin: true - resolution: - integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== - /semver/7.3.2: dev: true - engines: - node: '>=10' + + /semver/7.3.2: + resolution: {integrity: sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==} + engines: {node: '>=10'} hasBin: true - resolution: - integrity: sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== + dev: true + /send/0.17.1: + resolution: {integrity: sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==} + engines: {node: '>= 0.8.0'} dependencies: debug: 2.6.9 depd: 1.1.2 @@ -12397,23 +13309,22 @@ packages: range-parser: 1.2.1 statuses: 1.5.0 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg== + /serialize-javascript/4.0.0: + resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} dependencies: randombytes: 2.1.0 dev: true - resolution: - integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== + /serialize-javascript/5.0.1: + resolution: {integrity: sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==} dependencies: randombytes: 2.1.0 dev: true - resolution: - integrity: sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA== + /serve-index/1.9.1: + resolution: {integrity: sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=} + engines: {node: '>= 0.8.0'} dependencies: accepts: 1.3.7 batch: 0.6.1 @@ -12423,171 +13334,170 @@ packages: mime-types: 2.1.29 parseurl: 1.3.3 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha1-03aNabHn2C5c4FD/9bRTvqEqkjk= + /serve-static/1.14.1: + resolution: {integrity: sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==} + engines: {node: '>= 0.8.0'} dependencies: encodeurl: 1.0.2 escape-html: 1.0.3 parseurl: 1.3.3 send: 0.17.1 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== + /set-blocking/2.0.0: + resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=} dev: true - resolution: - integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + /set-harmonic-interval/1.0.1: + resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} + engines: {node: '>=6.9'} dev: false - engines: - node: '>=6.9' - resolution: - integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g== + /set-value/2.0.1: + resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==} + engines: {node: '>=0.10.0'} dependencies: extend-shallow: 2.0.1 is-extendable: 0.1.1 is-plain-object: 2.0.4 split-string: 3.1.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + /setimmediate/1.0.5: + resolution: {integrity: sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=} dev: true - resolution: - integrity: sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + /setprototypeof/1.1.0: + resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} dev: true - resolution: - integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== + /setprototypeof/1.1.1: + resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==} dev: true - resolution: - integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== + /sha.js/2.4.11: + resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} + hasBin: true dependencies: inherits: 2.0.4 safe-buffer: 5.2.1 dev: true - hasBin: true - resolution: - integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + /shallow-clone/3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} dependencies: kind-of: 6.0.3 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== + /shallowequal/1.1.0: - dev: false - resolution: - integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + /shebang-command/1.2.0: + resolution: {integrity: sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=} + engines: {node: '>=0.10.0'} dependencies: shebang-regex: 1.0.0 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + /shebang-command/2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} dependencies: shebang-regex: 3.0.0 - engines: - node: '>=8' - resolution: - integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + /shebang-regex/1.0.0: + resolution: {integrity: sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + /shebang-regex/3.0.0: - engines: - node: '>=8' - resolution: - integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + /shell-quote/1.7.2: + resolution: {integrity: sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==} dev: false - resolution: - integrity: sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== + /shellwords/0.1.1: + resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} dev: true optional: true - resolution: - integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== + /side-channel/1.0.4: + resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: call-bind: 1.0.2 get-intrinsic: 1.1.1 - object-inspect: 1.9.0 - dev: true - resolution: - integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + object-inspect: 1.11.0 + /signal-exit/3.0.3: + resolution: {integrity: sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==} dev: true - resolution: - integrity: sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + /simple-swizzle/0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} dependencies: is-arrayish: 0.3.2 - dev: true - resolution: - integrity: sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + /sisteransi/1.0.5: - resolution: - integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + /sitemap/6.4.0: + resolution: {integrity: sha512-DoPKNc2/apQZTUnfiOONWctwq7s6dZVspxAZe2VPMNtoqNq7HgXRvlRnbIpKjf+8+piQdWncwcy+YhhTGY5USQ==} + engines: {node: '>=10.3.0', npm: '>=5.6.0'} + hasBin: true + dependencies: + '@types/node': 14.17.20 + '@types/sax': 1.2.3 + arg: 5.0.1 + sax: 1.2.4 + dev: true + /slash/3.0.0: - engines: - node: '>=8' - resolution: - integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + /slash2/2.0.0: + resolution: {integrity: sha512-7ElvBydJPi3MHU/KEOblFSbO/skl4Z69jKkFCpYIYVOMSIZsKi4gYU43HGeZPmjxCXrHekoDAAewphPQNnsqtA==} + engines: {node: '>=6'} + dev: true + /slice-ansi/3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} dependencies: ansi-styles: 4.3.0 astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== + /slice-ansi/4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} dependencies: ansi-styles: 4.3.0 astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + /snapdragon-node/2.1.1: + resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==} + engines: {node: '>=0.10.0'} dependencies: define-property: 1.0.0 isobject: 3.0.1 snapdragon-util: 3.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + /snapdragon-util/3.0.1: + resolution: {integrity: sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==} + engines: {node: '>=0.10.0'} dependencies: kind-of: 3.2.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + /snapdragon/0.8.2: + resolution: {integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==} + engines: {node: '>=0.10.0'} dependencies: base: 0.11.2 debug: 2.6.9 @@ -12597,11 +13507,9 @@ packages: source-map: 0.5.7 source-map-resolve: 0.5.3 use: 3.1.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + /sockjs-client/1.4.0: + resolution: {integrity: sha512-5zaLyO8/nri5cua0VtOrFXBPK1jbL4+1cebT/mmKA1E1ZXOvJrII75bPu0l0k843G/+iAbhEqzyKr0w/eCCj7g==} dependencies: debug: 3.2.7 eventsource: 1.1.0 @@ -12610,102 +13518,101 @@ packages: json3: 3.3.3 url-parse: 1.5.1 dev: true - resolution: - integrity: sha512-5zaLyO8/nri5cua0VtOrFXBPK1jbL4+1cebT/mmKA1E1ZXOvJrII75bPu0l0k843G/+iAbhEqzyKr0w/eCCj7g== + /sockjs/0.3.20: + resolution: {integrity: sha512-SpmVOVpdq0DJc0qArhF3E5xsxvaiqGNb73XfgBpK1y3UD5gs8DSo8aCTsuT5pX8rssdc2NDIzANwP9eCAiSdTA==} dependencies: faye-websocket: 0.10.0 uuid: 3.4.0 websocket-driver: 0.6.5 dev: true - resolution: - integrity: sha512-SpmVOVpdq0DJc0qArhF3E5xsxvaiqGNb73XfgBpK1y3UD5gs8DSo8aCTsuT5pX8rssdc2NDIzANwP9eCAiSdTA== + /sort-keys/1.1.2: + resolution: {integrity: sha1-RBttTTRnmPG05J6JIK37oOVD+a0=} + engines: {node: '>=0.10.0'} dependencies: is-plain-obj: 1.1.0 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-RBttTTRnmPG05J6JIK37oOVD+a0= + /source-list-map/2.0.1: + resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} dev: true - resolution: - integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== + /source-map-resolve/0.5.3: + resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==} dependencies: atob: 2.1.2 decode-uri-component: 0.2.0 resolve-url: 0.2.1 source-map-url: 0.4.1 urix: 0.1.0 - resolution: - integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== + /source-map-resolve/0.6.0: + resolution: {integrity: sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==} dependencies: atob: 2.1.2 decode-uri-component: 0.2.0 dev: true - resolution: - integrity: sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== + /source-map-support/0.5.19: + resolution: {integrity: sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==} dependencies: buffer-from: 1.1.1 source-map: 0.6.1 dev: true - resolution: - integrity: sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== + /source-map-url/0.4.1: - resolution: - integrity: sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== + resolution: {integrity: sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==} + /source-map/0.5.6: + resolution: {integrity: sha1-dc449SvwczxafwwRjYEzSiu19BI=} + engines: {node: '>=0.10.0'} dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-dc449SvwczxafwwRjYEzSiu19BI= + /source-map/0.5.7: - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + resolution: {integrity: sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=} + engines: {node: '>=0.10.0'} + /source-map/0.6.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + /source-map/0.7.3: + resolution: {integrity: sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==} + engines: {node: '>= 8'} dev: true - engines: - node: '>= 8' - resolution: - integrity: sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== + /sourcemap-codec/1.4.8: - resolution: - integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + + /space-separated-tokens/1.1.5: + resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} + dev: true + /spdx-correct/3.1.1: + resolution: {integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==} dependencies: spdx-expression-parse: 3.0.1 spdx-license-ids: 3.0.7 dev: true - resolution: - integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== + /spdx-exceptions/2.3.0: + resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==} dev: true - resolution: - integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== + /spdx-expression-parse/3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} dependencies: spdx-exceptions: 2.3.0 spdx-license-ids: 3.0.7 dev: true - resolution: - integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + /spdx-license-ids/3.0.7: + resolution: {integrity: sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==} dev: true - resolution: - integrity: sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== + /spdy-transport/3.0.0_supports-color@6.1.0: + resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} dependencies: debug: 4.3.1_supports-color@6.1.0 detect-node: 2.0.5 @@ -12713,37 +13620,51 @@ packages: obuf: 1.1.2 readable-stream: 3.6.0 wbuf: 1.7.3 + transitivePeerDependencies: + - supports-color dev: true - peerDependencies: - supports-color: '*' - resolution: - integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw== + /spdy/4.0.2_supports-color@6.1.0: + resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==} + engines: {node: '>=6.0.0'} dependencies: debug: 4.3.1_supports-color@6.1.0 handle-thing: 2.0.1 http-deceiver: 1.2.7 select-hose: 2.0.0 spdy-transport: 3.0.0_supports-color@6.1.0 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=6.0.0' - peerDependencies: - supports-color: '*' - resolution: - integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA== + + /speech-rule-engine/3.3.3: + resolution: {integrity: sha512-0exWw+0XauLjat+f/aFeo5T8SiDsO1JtwpY3qgJE4cWt+yL/Stl0WP4VNDWdh7lzGkubUD9lWP4J1ASnORXfyQ==} + hasBin: true + dependencies: + commander: 7.2.0 + wicked-good-xpath: 1.3.0 + xmldom-sre: 0.1.31 + dev: true + + /split-on-first/1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + dev: true + /split-string/3.1.0: + resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} + engines: {node: '>=0.10.0'} dependencies: extend-shallow: 3.0.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + /sprintf-js/1.0.3: + resolution: {integrity: sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=} dev: true - resolution: - integrity: sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + /sshpk/1.16.1: + resolution: {integrity: sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==} + engines: {node: '>=0.10.0'} + hasBin: true dependencies: asn1: 0.2.4 assert-plus: 1.0.0 @@ -12755,104 +13676,96 @@ packages: safer-buffer: 2.1.2 tweetnacl: 0.14.5 dev: true - engines: - node: '>=0.10.0' - hasBin: true - resolution: - integrity: sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + /ssri/6.0.1: + resolution: {integrity: sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==} dependencies: figgy-pudding: 3.5.2 dev: true - resolution: - integrity: sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== + /ssri/8.0.1: + resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} + engines: {node: '>= 8'} dependencies: minipass: 3.1.3 dev: true - engines: - node: '>= 8' - resolution: - integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ== + /stable/0.1.8: + resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} dev: true - resolution: - integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== + /stack-generator/2.0.5: + resolution: {integrity: sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==} dependencies: stackframe: 1.2.0 dev: false - resolution: - integrity: sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q== + /stack-utils/2.0.3: + resolution: {integrity: sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw==} + engines: {node: '>=10'} dependencies: escape-string-regexp: 2.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw== + /stackframe/1.2.0: - resolution: - integrity: sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA== + resolution: {integrity: sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA==} + /stacktrace-gps/3.0.4: + resolution: {integrity: sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==} dependencies: source-map: 0.5.6 stackframe: 1.2.0 dev: false - resolution: - integrity: sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg== + /stacktrace-js/2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} dependencies: error-stack-parser: 2.0.6 stack-generator: 2.0.5 stacktrace-gps: 3.0.4 dev: false - resolution: - integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg== + /state-local/1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} dev: false - resolution: - integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w== + /static-extend/0.1.2: + resolution: {integrity: sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=} + engines: {node: '>=0.10.0'} dependencies: define-property: 0.2.5 object-copy: 0.1.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + /statuses/1.5.0: + resolution: {integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=} + engines: {node: '>= 0.6'} dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + /stealthy-require/1.1.1: + resolution: {integrity: sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + /store2/2.12.0: + resolution: {integrity: sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw==} dev: false - resolution: - integrity: sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw== + /stream-browserify/2.0.2: + resolution: {integrity: sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==} dependencies: inherits: 2.0.4 readable-stream: 2.3.7 dev: true - resolution: - integrity: sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg== + /stream-each/1.2.3: + resolution: {integrity: sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==} dependencies: end-of-stream: 1.4.4 stream-shift: 1.0.1 dev: true - resolution: - integrity: sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw== + /stream-http/2.8.3: + resolution: {integrity: sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==} dependencies: builtin-status-codes: 3.0.0 inherits: 2.0.4 @@ -12860,62 +13773,58 @@ packages: to-arraybuffer: 1.0.1 xtend: 4.0.2 dev: true - resolution: - integrity: sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw== + /stream-shift/1.0.1: + resolution: {integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==} dev: true - resolution: - integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== + /strict-uri-encode/1.1.0: + resolution: {integrity: sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=} + engines: {node: '>=0.10.0'} + dev: true + + /strict-uri-encode/2.0.0: + resolution: {integrity: sha1-ucczDHBChi9rFC3CdLvMWGbONUY=} + engines: {node: '>=4'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= + /string-argv/0.3.1: + resolution: {integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==} + engines: {node: '>=0.6.19'} dev: true - engines: - node: '>=0.6.19' - resolution: - integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== - /string-convert/0.2.1: - dev: false - resolution: - integrity: sha1-aYLMMEn7tM2F+LJFaLnZvznu/5c= + /string-length/4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} dependencies: char-regex: 1.0.2 strip-ansi: 6.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + /string-natural-compare/3.0.1: + resolution: {integrity: sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==} dev: true - resolution: - integrity: sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== + /string-width/3.1.0: + resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==} + engines: {node: '>=6'} dependencies: emoji-regex: 7.0.3 is-fullwidth-code-point: 2.0.0 strip-ansi: 5.2.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + /string-width/4.2.2: + resolution: {integrity: sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==} + engines: {node: '>=8'} dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== + /string.prototype.matchall/4.0.4: + resolution: {integrity: sha512-pknFIWVachNcyqRfaQSeu/FUfpvJTe4uskUSZ9Wc1RijsPuzbZ8TyYT8WCNnntCjUEqQ3vUHMAfVj2+wLAisPQ==} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 @@ -12925,133 +13834,139 @@ packages: regexp.prototype.flags: 1.3.1 side-channel: 1.0.4 dev: true - resolution: - integrity: sha512-pknFIWVachNcyqRfaQSeu/FUfpvJTe4uskUSZ9Wc1RijsPuzbZ8TyYT8WCNnntCjUEqQ3vUHMAfVj2+wLAisPQ== + /string.prototype.trimend/1.0.4: + resolution: {integrity: sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 dev: true - resolution: - integrity: sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== + /string.prototype.trimstart/1.0.4: + resolution: {integrity: sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==} dependencies: call-bind: 1.0.2 define-properties: 1.1.3 dev: true - resolution: - integrity: sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== + /string_decoder/1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} dependencies: safe-buffer: 5.1.2 dev: true - resolution: - integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + /string_decoder/1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + + /stringify-entities/3.1.0: + resolution: {integrity: sha512-3FP+jGMmMV/ffZs86MoghGqAoqXAdxLrJP4GUdrDN1aIScYih5tuIO3eF4To5AJZ79KDZ8Fpdy7QJnK8SsL1Vg==} + dependencies: + character-entities-html4: 1.1.4 + character-entities-legacy: 1.1.4 + xtend: 4.0.2 + dev: true + /stringify-object/3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} dependencies: get-own-enumerable-property-symbols: 3.0.2 is-obj: 1.0.1 is-regexp: 1.0.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== + /strip-ansi/3.0.1: + resolution: {integrity: sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=} + engines: {node: '>=0.10.0'} dependencies: ansi-regex: 2.1.1 dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + /strip-ansi/5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} dependencies: ansi-regex: 4.1.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + /strip-ansi/6.0.0: + resolution: {integrity: sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==} + engines: {node: '>=8'} dependencies: ansi-regex: 5.0.0 - engines: - node: '>=8' - resolution: - integrity: sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + /strip-bom/3.0.0: + resolution: {integrity: sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + /strip-bom/4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + /strip-comments/1.0.2: + resolution: {integrity: sha512-kL97alc47hoyIQSV165tTt9rG5dn4w1dNnBhOQ3bOU1Nc1hel09jnXANaHJ7vzHLd4Ju8kseDGzlev96pghLFw==} + engines: {node: '>=4'} dependencies: babel-extract-comments: 1.0.0 babel-plugin-transform-object-rest-spread: 6.26.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-kL97alc47hoyIQSV165tTt9rG5dn4w1dNnBhOQ3bOU1Nc1hel09jnXANaHJ7vzHLd4Ju8kseDGzlev96pghLFw== + /strip-eof/1.0.0: + resolution: {integrity: sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + /strip-final-newline/2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + /strip-indent/3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} dependencies: min-indent: 1.0.1 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + /strip-json-comments/2.0.1: + resolution: {integrity: sha1-PFMZQukIwml8DsNEhYwobHygpgo=} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-PFMZQukIwml8DsNEhYwobHygpgo= + /strip-json-comments/3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + /style-loader/1.3.0_webpack@4.44.2: + resolution: {integrity: sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==} + engines: {node: '>= 8.9.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 dependencies: loader-utils: 2.0.0 schema-utils: 2.7.1 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>= 8.9.0' - peerDependencies: - webpack: ^4.0.0 || ^5.0.0 - resolution: - integrity: sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q== + + /style-to-object/0.3.0: + resolution: {integrity: sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==} + dependencies: + inline-style-parser: 0.1.1 + dev: true + /styled-components/5.2.1_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-sBdgLWrCFTKtmZm/9x7jkIabjFNVzCUeKfoQsM6R3saImkUnjx0QYdLwJHBjY9ifEcmjDamJDVfknWm1yxZPxQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8.0' + react-dom: '>= 16.8.0' + react-is: '>= 16.8.0' dependencies: '@babel/helper-module-imports': 7.13.12 '@babel/traverse': 7.13.13_supports-color@5.5.0 @@ -13066,65 +13981,56 @@ packages: shallowequal: 1.1.0 supports-color: 5.5.0 dev: false - engines: - node: '>=10' - peerDependencies: - react: '>= 16.8.0' - react-dom: '>= 16.8.0' - react-is: '>= 16.8.0' - resolution: - integrity: sha512-sBdgLWrCFTKtmZm/9x7jkIabjFNVzCUeKfoQsM6R3saImkUnjx0QYdLwJHBjY9ifEcmjDamJDVfknWm1yxZPxQ== + /stylehacks/4.0.3: + resolution: {integrity: sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g==} + engines: {node: '>=6.9.0'} dependencies: browserslist: 4.16.3 postcss: 7.0.35 postcss-selector-parser: 3.1.2 dev: true - engines: - node: '>=6.9.0' - resolution: - integrity: sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g== + /stylis/4.0.9: + resolution: {integrity: sha512-ci7pEFNVW3YJiWEzqPOMsAjY6kgraZ3ZgBfQ5HYbNtLJEsQ0G46ejWZpfSSCp/FaSiCSGGhzL9O2lN+2cB6ong==} dev: false - resolution: - integrity: sha512-ci7pEFNVW3YJiWEzqPOMsAjY6kgraZ3ZgBfQ5HYbNtLJEsQ0G46ejWZpfSSCp/FaSiCSGGhzL9O2lN+2cB6ong== + /supports-color/5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} dependencies: has-flag: 3.0.0 - engines: - node: '>=4' - resolution: - integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + /supports-color/6.1.0: + resolution: {integrity: sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==} + engines: {node: '>=6'} dependencies: has-flag: 3.0.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + /supports-color/7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} dependencies: has-flag: 4.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + /supports-hyperlinks/2.1.0: + resolution: {integrity: sha512-zoE5/e+dnEijk6ASB6/qrK+oYdm2do1hjoLWrqUC/8WEIW1gbxFcKuBof7sW8ArN6e+AYvsE8HBGiVRWL/F5CA==} + engines: {node: '>=8'} dependencies: has-flag: 4.0.0 supports-color: 7.2.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-zoE5/e+dnEijk6ASB6/qrK+oYdm2do1hjoLWrqUC/8WEIW1gbxFcKuBof7sW8ArN6e+AYvsE8HBGiVRWL/F5CA== + /svg-parser/2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} dev: true - resolution: - integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== + /svgo/1.3.2: + resolution: {integrity: sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==} + engines: {node: '>=4.0.0'} + hasBin: true dependencies: chalk: 2.4.2 coa: 2.0.2 @@ -13140,22 +14046,19 @@ packages: unquote: 1.1.1 util.promisify: 1.0.1 dev: true - engines: - node: '>=4.0.0' - hasBin: true - resolution: - integrity: sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw== + /symbol-observable/1.2.0: + resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==} + engines: {node: '>=0.10.0'} dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + /symbol-tree/3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true - resolution: - integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + /table/6.0.8: + resolution: {integrity: sha512-OBAdezyozae8IvjHGXBDHByVkLCcsmffXUSj8LXkNb0SluRd4ug3GFCjk6JynZONIPhOkyr0Nnvbq1rlIspXyQ==} + engines: {node: '>=10.0.0'} dependencies: ajv: 8.0.1 is-boolean-object: 1.1.0 @@ -13167,22 +14070,19 @@ packages: slice-ansi: 4.0.0 string-width: 4.2.2 dev: true - engines: - node: '>=10.0.0' - resolution: - integrity: sha512-OBAdezyozae8IvjHGXBDHByVkLCcsmffXUSj8LXkNb0SluRd4ug3GFCjk6JynZONIPhOkyr0Nnvbq1rlIspXyQ== + /tapable/1.1.3: - engines: - node: '>=6' - resolution: - integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== + resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==} + engines: {node: '>=6'} + /tapable/2.2.0: + resolution: {integrity: sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw== + /tar/6.1.0: + resolution: {integrity: sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==} + engines: {node: '>= 10'} dependencies: chownr: 2.0.0 fs-minipass: 2.1.0 @@ -13191,36 +14091,34 @@ packages: mkdirp: 1.0.4 yallist: 4.0.0 dev: true - engines: - node: '>= 10' - resolution: - integrity: sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA== + /temp-dir/1.0.0: + resolution: {integrity: sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0= + /tempy/0.3.0: + resolution: {integrity: sha512-WrH/pui8YCwmeiAoxV+lpRH9HpRtgBhSR2ViBPgpGb/wnYDzp21R4MN45fsCGvLROvY67o3byhJRYRONJyImVQ==} + engines: {node: '>=8'} dependencies: temp-dir: 1.0.0 type-fest: 0.3.1 unique-string: 1.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-WrH/pui8YCwmeiAoxV+lpRH9HpRtgBhSR2ViBPgpGb/wnYDzp21R4MN45fsCGvLROvY67o3byhJRYRONJyImVQ== + /terminal-link/2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} dependencies: ansi-escapes: 4.3.2 supports-hyperlinks: 2.1.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ== + /terser-webpack-plugin/1.4.5_webpack@4.44.2: + resolution: {integrity: sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==} + engines: {node: '>= 6.9.0'} + peerDependencies: + webpack: ^4.0.0 dependencies: cacache: 12.0.4 find-cache-dir: 2.1.0 @@ -13233,13 +14131,12 @@ packages: webpack-sources: 1.4.3 worker-farm: 1.7.0 dev: true - engines: - node: '>= 6.9.0' - peerDependencies: - webpack: ^4.0.0 - resolution: - integrity: sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw== + /terser-webpack-plugin/4.2.3_webpack@4.44.2: + resolution: {integrity: sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 dependencies: cacache: 15.0.6 find-cache-dir: 3.3.1 @@ -13252,467 +14149,527 @@ packages: webpack: 4.44.2_webpack-cli@4.6.0 webpack-sources: 1.4.3 dev: true - engines: - node: '>= 10.13.0' - peerDependencies: - webpack: ^4.0.0 || ^5.0.0 - resolution: - integrity: sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ== + /terser/4.8.0: + resolution: {integrity: sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==} + engines: {node: '>=6.0.0'} + hasBin: true dependencies: commander: 2.20.3 source-map: 0.6.1 source-map-support: 0.5.19 dev: true - engines: - node: '>=6.0.0' + + /terser/5.6.0: + resolution: {integrity: sha512-vyqLMoqadC1uR0vywqOZzriDYzgEkNJFK4q9GeyOBHIbiECHiWLKcWfbQWAUaPfxkjDhapSlZB9f7fkMrvkVjA==} + engines: {node: '>=10'} hasBin: true - resolution: - integrity: sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw== - /terser/5.6.1: dependencies: commander: 2.20.3 source-map: 0.7.3 source-map-support: 0.5.19 dev: true - engines: - node: '>=10' + + /terser/5.6.1: + resolution: {integrity: sha512-yv9YLFQQ+3ZqgWCUk+pvNJwgUTdlIxUk1WTN+RnaFJe2L7ipG2csPT0ra2XRm7Cs8cxN7QXmK1rFzEwYEQkzXw==} + engines: {node: '>=10'} hasBin: true - resolution: - integrity: sha512-yv9YLFQQ+3ZqgWCUk+pvNJwgUTdlIxUk1WTN+RnaFJe2L7ipG2csPT0ra2XRm7Cs8cxN7QXmK1rFzEwYEQkzXw== + dependencies: + commander: 2.20.3 + source-map: 0.7.3 + source-map-support: 0.5.19 + dev: true + /test-exclude/6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.1.6 minimatch: 3.0.4 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + /text-table/0.2.0: - resolution: - integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= + resolution: {integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=} + /throat/5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} dev: true - resolution: - integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== + /throttle-debounce/2.3.0: + resolution: {integrity: sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==} + engines: {node: '>=8'} dev: false - engines: - node: '>=8' - resolution: - integrity: sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ== + /through/2.3.8: + resolution: {integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=} dev: true - resolution: - integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + /through2/2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} dependencies: readable-stream: 2.3.7 xtend: 4.0.2 dev: true - resolution: - integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + /thunky/1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} dev: true - resolution: - integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== + /timers-browserify/2.0.12: + resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} + engines: {node: '>=0.6.0'} dependencies: setimmediate: 1.0.5 dev: true - engines: - node: '>=0.6.0' - resolution: - integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ== + /timsort/0.3.0: + resolution: {integrity: sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=} dev: true - resolution: - integrity: sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= + /tiny-invariant/1.1.0: - dev: false - resolution: - integrity: sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== + resolution: {integrity: sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==} + /tiny-warning/1.0.3: - dev: false - resolution: - integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + /tmpl/1.0.4: + resolution: {integrity: sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=} dev: true - resolution: - integrity: sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + /to-arraybuffer/1.0.1: + resolution: {integrity: sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=} dev: true - resolution: - integrity: sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= + /to-fast-properties/2.0.0: - engines: - node: '>=4' - resolution: - integrity: sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + /to-object-path/0.3.0: + resolution: {integrity: sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=} + engines: {node: '>=0.10.0'} dependencies: kind-of: 3.2.2 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + /to-regex-range/2.1.1: + resolution: {integrity: sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=} + engines: {node: '>=0.10.0'} dependencies: is-number: 3.0.0 repeat-string: 1.6.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 - engines: - node: '>=8.0' - resolution: - integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + /to-regex/3.0.2: + resolution: {integrity: sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==} + engines: {node: '>=0.10.0'} dependencies: define-property: 2.0.2 extend-shallow: 3.0.2 regex-not: 1.0.2 safe-regex: 1.1.0 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + /toggle-selection/1.0.6: + resolution: {integrity: sha1-bkWxJj8gF/oKzH2J14sVuL932jI=} dev: false - resolution: - integrity: sha1-bkWxJj8gF/oKzH2J14sVuL932jI= + /toidentifier/1.0.0: + resolution: {integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==} + engines: {node: '>=0.6'} dev: true - engines: - node: '>=0.6' - resolution: - integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== + /tough-cookie/2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} dependencies: psl: 1.8.0 punycode: 2.1.1 dev: true - engines: - node: '>=0.8' - resolution: - integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + /tough-cookie/4.0.0: + resolution: {integrity: sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==} + engines: {node: '>=6'} dependencies: psl: 1.8.0 punycode: 2.1.1 universalify: 0.1.2 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + /tr46/2.0.2: + resolution: {integrity: sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==} + engines: {node: '>=8'} dependencies: punycode: 2.1.1 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg== + + /trough/1.0.5: + resolution: {integrity: sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==} + dev: true + /tryer/1.0.1: + resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==} dev: true - resolution: - integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== + /ts-easing/0.2.0: + resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==} dev: false - resolution: - integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ== + /ts-pnp/1.2.0_typescript@4.2.3: - dependencies: - typescript: 4.2.3 - dev: true - engines: - node: '>=6' + resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==} + engines: {node: '>=6'} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true - resolution: - integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw== + dependencies: + typescript: 4.2.3 + dev: true + /tsconfig-paths-webpack-plugin/3.5.1: + resolution: {integrity: sha512-n5CMlUUj+N5pjBhBACLq4jdr9cPTitySCjIosoQm0zwK99gmrcTGAfY9CwxRFT9+9OleNWXPRUcxsKP4AYExxQ==} dependencies: chalk: 4.1.0 enhanced-resolve: 5.7.0 tsconfig-paths: 3.9.0 dev: true - resolution: - integrity: sha512-n5CMlUUj+N5pjBhBACLq4jdr9cPTitySCjIosoQm0zwK99gmrcTGAfY9CwxRFT9+9OleNWXPRUcxsKP4AYExxQ== + /tsconfig-paths/3.9.0: + resolution: {integrity: sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==} dependencies: '@types/json5': 0.0.29 json5: 1.0.1 minimist: 1.2.5 strip-bom: 3.0.0 dev: true - resolution: - integrity: sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw== + /tslib/1.14.1: - resolution: - integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true + /tslib/2.1.0: - resolution: - integrity: sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== + resolution: {integrity: sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==} + dev: false + + /tslib/2.4.1: + resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + + /tslib/2.5.0: + resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} + dev: false + /tsutils/3.21.0_typescript@4.2.3: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 typescript: 4.2.3 dev: true - engines: - node: '>= 6' - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - resolution: - integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== + /tty-browserify/0.0.0: + resolution: {integrity: sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=} dev: true - resolution: - integrity: sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= + /tunnel-agent/0.6.0: + resolution: {integrity: sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=} dependencies: safe-buffer: 5.2.1 dev: true - resolution: - integrity: sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + /tweetnacl/0.14.5: + resolution: {integrity: sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=} dev: true - resolution: - integrity: sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + /type-check/0.3.2: + resolution: {integrity: sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=} + engines: {node: '>= 0.8.0'} dependencies: prelude-ls: 1.1.2 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + /type-check/0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} dependencies: prelude-ls: 1.2.1 dev: true - engines: - node: '>= 0.8.0' - resolution: - integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + /type-detect/4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + /type-fest/0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + /type-fest/0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + /type-fest/0.3.1: + resolution: {integrity: sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==} + engines: {node: '>=6'} dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ== + /type-fest/0.6.0: + resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + /type-fest/0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + /type-is/1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} dependencies: media-typer: 0.3.0 mime-types: 2.1.29 dev: true - engines: - node: '>= 0.6' - resolution: - integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + /type/1.2.0: + resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==} dev: true - resolution: - integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== + /type/2.5.0: + resolution: {integrity: sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw==} dev: true - resolution: - integrity: sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw== + /typedarray-to-buffer/3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} dependencies: is-typedarray: 1.0.0 dev: true - resolution: - integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + /typedarray/0.0.6: + resolution: {integrity: sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=} dev: true - resolution: - integrity: sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + /typescript/4.2.3: + resolution: {integrity: sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==} + engines: {node: '>=4.2.0'} + hasBin: true dev: true - engines: - node: '>=4.2.0' + + /umi/3.5.20_react-router@5.2.0: + resolution: {integrity: sha512-rliZTS2LoudsIelaSipZrPUEjPOi2HDlj1VCNXt63YFxeqSXQkijKmM1+hSVEDDRwPLq4L+RZhuVnCasZA9Nng==} hasBin: true - resolution: - integrity: sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== + dependencies: + '@umijs/bundler-webpack': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/core': 3.5.20 + '@umijs/deps': 3.5.20 + '@umijs/preset-built-in': 3.5.20_react-dom@16.14.0+react@16.14.0 + '@umijs/runtime': 3.5.20_react@16.14.0 + '@umijs/types': 3.5.20_39566ec7cc5fe716a59f91f7330320ef + '@umijs/utils': 3.5.20 + react: 16.14.0 + react-dom: 16.14.0_react@16.14.0 + v8-compile-cache: 2.3.0 + transitivePeerDependencies: + - react-router + dev: true + /unbox-primitive/1.0.1: + resolution: {integrity: sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==} dependencies: function-bind: 1.1.1 has-bigints: 1.0.1 has-symbols: 1.0.2 which-boxed-primitive: 1.0.2 dev: true - resolution: - integrity: sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== + /unicode-canonical-property-names-ecmascript/1.0.4: + resolution: {integrity: sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ== + /unicode-match-property-ecmascript/1.0.4: + resolution: {integrity: sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==} + engines: {node: '>=4'} dependencies: unicode-canonical-property-names-ecmascript: 1.0.4 unicode-property-aliases-ecmascript: 1.1.0 dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg== + /unicode-match-property-value-ecmascript/1.2.0: + resolution: {integrity: sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ== + /unicode-property-aliases-ecmascript/1.1.0: + resolution: {integrity: sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg== + + /unified/8.4.2: + resolution: {integrity: sha512-JCrmN13jI4+h9UAyKEoGcDZV+i1E7BLFuG7OsaDvTXI5P0qhHX+vZO/kOhz9jn8HGENDKbwSeB0nVOg4gVStGA==} + dependencies: + bail: 1.0.5 + extend: 3.0.2 + is-plain-obj: 2.1.0 + trough: 1.0.5 + vfile: 4.2.1 + dev: true + /union-value/1.0.1: + resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==} + engines: {node: '>=0.10.0'} dependencies: arr-union: 3.1.0 get-value: 2.0.6 is-extendable: 0.1.1 set-value: 2.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + /uniq/1.0.1: + resolution: {integrity: sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=} + dev: true + + /uniqs/2.0.0: + resolution: {integrity: sha1-/+3ks2slKQaW5uFl1KWe25mOawI=} + dev: true + + /unique-filename/1.1.1: + resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} + dependencies: + unique-slug: 2.0.2 + dev: true + + /unique-slug/2.0.2: + resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==} + dependencies: + imurmurhash: 0.1.4 + dev: true + + /unique-string/1.0.0: + resolution: {integrity: sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=} + engines: {node: '>=4'} + dependencies: + crypto-random-string: 1.0.0 + dev: true + + /unist-builder/2.0.3: + resolution: {integrity: sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==} + dev: true + + /unist-util-filter/2.0.3: + resolution: {integrity: sha512-8k6Jl/KLFqIRTHydJlHh6+uFgqYHq66pV75pZgr1JwfyFSjbWb12yfb0yitW/0TbHXjr9U4G9BQpOvMANB+ExA==} + dependencies: + unist-util-is: 4.1.0 + dev: true + + /unist-util-find-after/3.0.0: + resolution: {integrity: sha512-ojlBqfsBftYXExNu3+hHLfJQ/X1jYY/9vdm4yZWjIbf0VuWF6CRufci1ZyoD/wV2TYMKxXUoNuoqwy+CkgzAiQ==} + dependencies: + unist-util-is: 4.1.0 dev: true - resolution: - integrity: sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= - /uniqs/2.0.0: + + /unist-util-generated/1.1.6: + resolution: {integrity: sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==} dev: true - resolution: - integrity: sha1-/+3ks2slKQaW5uFl1KWe25mOawI= - /unique-filename/1.1.1: + + /unist-util-is/4.1.0: + resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==} + dev: true + + /unist-util-position/3.1.0: + resolution: {integrity: sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==} + dev: true + + /unist-util-stringify-position/2.0.3: + resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} dependencies: - unique-slug: 2.0.2 + '@types/unist': 2.0.6 dev: true - resolution: - integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== - /unique-slug/2.0.2: + + /unist-util-visit-parents/3.1.1: + resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==} dependencies: - imurmurhash: 0.1.4 + '@types/unist': 2.0.6 + unist-util-is: 4.1.0 dev: true - resolution: - integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== - /unique-string/1.0.0: + + /unist-util-visit/2.0.3: + resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==} dependencies: - crypto-random-string: 1.0.0 + '@types/unist': 2.0.6 + unist-util-is: 4.1.0 + unist-util-visit-parents: 3.1.1 dev: true - engines: - node: '>=4' - resolution: - integrity: sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo= + /universalify/0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} dev: true - engines: - node: '>= 4.0.0' - resolution: - integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + /universalify/2.0.0: + resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} + engines: {node: '>= 10.0.0'} dev: true - engines: - node: '>= 10.0.0' - resolution: - integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + /unload/2.2.0: + resolution: {integrity: sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==} dependencies: '@babel/runtime': 7.13.10 detect-node: 2.0.5 dev: false - resolution: - integrity: sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA== + /unpipe/1.0.0: + resolution: {integrity: sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=} + engines: {node: '>= 0.8'} dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= + /unquote/1.1.1: + resolution: {integrity: sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=} dev: true - resolution: - integrity: sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= + /unset-value/1.0.0: + resolution: {integrity: sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=} + engines: {node: '>=0.10.0'} dependencies: has-value: 0.3.1 isobject: 3.0.1 - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + /upath/1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} dev: true - engines: - node: '>=4' - resolution: - integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== - /upper-case-first/1.1.2: + + /upper-case/2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} dependencies: - upper-case: 1.1.3 + tslib: 2.4.1 dev: false - resolution: - integrity: sha1-XXm+3P8UQZUY/S7bCgUHybaFkRU= - /upper-case/1.1.3: - dev: false - resolution: - integrity: sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg= + /uri-js/4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: punycode: 2.1.1 dev: true - resolution: - integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + /urix/0.1.0: + resolution: {integrity: sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=} deprecated: Please see https://github.com/lydell/urix#deprecated - resolution: - integrity: sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + /url-loader/4.1.1_file-loader@6.1.1+webpack@4.44.2: + resolution: {integrity: sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + file-loader: '*' + webpack: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + file-loader: + optional: true dependencies: file-loader: 6.1.1_webpack@4.44.2 loader-utils: 2.0.0 @@ -13720,211 +14677,285 @@ packages: schema-utils: 3.0.0 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>= 10.13.0' - peerDependencies: - file-loader: '*' - webpack: ^4.0.0 || ^5.0.0 - peerDependenciesMeta: - file-loader: - optional: true - resolution: - integrity: sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA== + /url-parse/1.5.1: + resolution: {integrity: sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==} dependencies: querystringify: 2.2.0 requires-port: 1.0.0 dev: true - resolution: - integrity: sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q== + /url/0.11.0: + resolution: {integrity: sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=} dependencies: punycode: 1.3.2 querystring: 0.2.0 dev: true - resolution: - integrity: sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= + + /use-callback-ref/1.3.0_5170878e5e8a60dfb58a26e1cbcc99ef: + resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 16.14.5 + react: 17.0.2 + tslib: 2.5.0 + dev: false + + /use-sidecar/1.1.2_5170878e5e8a60dfb58a26e1cbcc99ef: + resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 16.14.5 + detect-node-es: 1.1.0 + react: 17.0.2 + tslib: 2.5.0 + dev: false + + /use-subscription/1.5.1_react@16.14.0: + resolution: {integrity: sha512-Xv2a1P/yReAjAbhylMfFplFKj9GssgTwN7RlcTxBujFQcloStWNDQdc4g4NRWH9xS4i/FDk04vQBptAXoF3VcA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 + dependencies: + object-assign: 4.1.1 + react: 16.14.0 + dev: true + + /use-subscription/1.5.1_react@17.0.2: + resolution: {integrity: sha512-Xv2a1P/yReAjAbhylMfFplFKj9GssgTwN7RlcTxBujFQcloStWNDQdc4g4NRWH9xS4i/FDk04vQBptAXoF3VcA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 + dependencies: + object-assign: 4.1.1 + react: 17.0.2 + dev: true + /use/3.1.1: - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} + engines: {node: '>=0.10.0'} + /util-deprecate/1.0.2: + resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=} dev: true - resolution: - integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + /util.promisify/1.0.0: + resolution: {integrity: sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==} dependencies: define-properties: 1.1.3 object.getownpropertydescriptors: 2.1.2 dev: true - resolution: - integrity: sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA== + /util.promisify/1.0.1: + resolution: {integrity: sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==} dependencies: define-properties: 1.1.3 es-abstract: 1.18.0 has-symbols: 1.0.2 object.getownpropertydescriptors: 2.1.2 dev: true - resolution: - integrity: sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA== + /util/0.10.3: + resolution: {integrity: sha1-evsa/lCAUkZInj23/g7TeTNqwPk=} dependencies: inherits: 2.0.1 dev: true - resolution: - integrity: sha1-evsa/lCAUkZInj23/g7TeTNqwPk= + /util/0.11.1: + resolution: {integrity: sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==} dependencies: inherits: 2.0.3 dev: true - resolution: - integrity: sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ== + /utila/0.4.0: + resolution: {integrity: sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=} dev: true - resolution: - integrity: sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= + /utility-types/3.10.0: + resolution: {integrity: sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==} + engines: {node: '>= 4'} dev: false - engines: - node: '>= 4' - resolution: - integrity: sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== + /utils-merge/1.0.1: + resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} + engines: {node: '>= 0.4.0'} dev: true - engines: - node: '>= 0.4.0' - resolution: - integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + /uuid/3.4.0: - dev: true + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} hasBin: true - resolution: - integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== - /uuid/8.3.2: dev: true + + /uuid/8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + dev: true optional: true - resolution: - integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + /v8-compile-cache/2.3.0: + resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==} dev: true - resolution: - integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== + /v8-to-istanbul/7.1.0: + resolution: {integrity: sha512-uXUVqNUCLa0AH1vuVxzi+MI4RfxEOKt9pBgKwHbgH7st8Kv2P1m+jvWNnektzBh5QShF3ODgKmUFCf38LnVz1g==} + engines: {node: '>=10.10.0'} dependencies: '@types/istanbul-lib-coverage': 2.0.3 convert-source-map: 1.7.0 source-map: 0.7.3 dev: true - engines: - node: '>=10.10.0' - resolution: - integrity: sha512-uXUVqNUCLa0AH1vuVxzi+MI4RfxEOKt9pBgKwHbgH7st8Kv2P1m+jvWNnektzBh5QShF3ODgKmUFCf38LnVz1g== + /validate-npm-package-license/3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: spdx-correct: 3.1.1 spdx-expression-parse: 3.0.1 dev: true - resolution: - integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + /value-equal/1.0.1: - dev: false - resolution: - integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} + /vary/1.1.2: + resolution: {integrity: sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=} + engines: {node: '>= 0.8'} dev: true - engines: - node: '>= 0.8' - resolution: - integrity: sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + /vendors/1.0.4: + resolution: {integrity: sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w==} dev: true - resolution: - integrity: sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w== + /verror/1.10.0: + resolution: {integrity: sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=} + engines: {'0': node >=0.6.0} dependencies: assert-plus: 1.0.0 core-util-is: 1.0.2 extsprintf: 1.3.0 dev: true - engines: - '0': node >=0.6.0 - resolution: - integrity: sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + + /vfile-location/3.2.0: + resolution: {integrity: sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==} + dev: true + + /vfile-message/2.0.4: + resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==} + dependencies: + '@types/unist': 2.0.6 + unist-util-stringify-position: 2.0.3 + dev: true + + /vfile/4.2.1: + resolution: {integrity: sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==} + dependencies: + '@types/unist': 2.0.6 + is-buffer: 2.0.5 + unist-util-stringify-position: 2.0.3 + vfile-message: 2.0.4 + dev: true + /vm-browserify/1.1.2: + resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} dev: true - resolution: - integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== + /void-elements/2.0.1: + resolution: {integrity: sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=} + engines: {node: '>=0.10.0'} dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= + /w3c-hr-time/1.0.2: + resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} dependencies: browser-process-hrtime: 1.0.0 dev: true - resolution: - integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ== + /w3c-xmlserializer/2.0.0: + resolution: {integrity: sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==} + engines: {node: '>=10'} dependencies: xml-name-validator: 3.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== + /walker/1.0.7: + resolution: {integrity: sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=} dependencies: makeerror: 1.0.11 dev: true - resolution: - integrity: sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= - /warning/4.0.3: - dependencies: - loose-envify: 1.4.0 - dev: false - resolution: - integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + /watchpack-chokidar2/2.0.1: + resolution: {integrity: sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==} + requiresBuild: true dependencies: chokidar: 2.1.8 dev: true optional: true - resolution: - integrity: sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww== + /watchpack/1.7.5: + resolution: {integrity: sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==} dependencies: graceful-fs: 4.2.6 neo-async: 2.6.2 - dev: true optionalDependencies: - chokidar: 3.5.1 + chokidar: 3.5.3 watchpack-chokidar2: 2.0.1 - resolution: - integrity: sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ== + dev: true + /wbuf/1.7.3: + resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} dependencies: minimalistic-assert: 1.0.1 dev: true - resolution: - integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA== + + /web-namespaces/1.1.4: + resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==} + dev: true + /webidl-conversions/5.0.0: + resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} + engines: {node: '>=8'} dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== + /webidl-conversions/6.1.0: + resolution: {integrity: sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==} + engines: {node: '>=10.4'} + dev: true + + /webpack-chain/6.5.1: + resolution: {integrity: sha512-7doO/SRtLu8q5WM0s7vPKPWX580qhi0/yBHkOxNkv50f6qB76Zy9o2wRTrrPULqYTvQlVHuvbA8v+G5ayuUDsA==} + engines: {node: '>=8'} + dependencies: + deepmerge: 1.5.2 + javascript-stringify: 2.1.0 dev: true - engines: - node: '>=10.4' - resolution: - integrity: sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== + /webpack-cli/4.6.0_e3222a4926c3b7d4c1aa5becb19e445f: + resolution: {integrity: sha512-9YV+qTcGMjQFiY7Nb1kmnupvb1x40lfpj8pwdO/bom+sQiP4OBMKjHq29YQrlDWDPZO9r/qWaRRywKaRDKqBTA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + '@webpack-cli/generators': '*' + '@webpack-cli/migrate': '*' + webpack: 4.x.x || 5.x.x + webpack-bundle-analyzer: '*' + webpack-dev-server: '*' + peerDependenciesMeta: + '@webpack-cli/generators': + optional: true + '@webpack-cli/migrate': + optional: true + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true dependencies: '@discoveryjs/json-ext': 0.5.2 '@webpack-cli/configtest': 1.0.2_webpack-cli@4.6.0+webpack@4.44.2 @@ -13943,27 +14974,12 @@ packages: webpack-dev-server: 3.11.0_webpack-cli@4.6.0+webpack@4.44.2 webpack-merge: 5.7.3 dev: true - engines: - node: '>=10.13.0' - hasBin: true - peerDependencies: - '@webpack-cli/generators': '*' - '@webpack-cli/migrate': '*' - webpack: 4.x.x || 5.x.x - webpack-bundle-analyzer: '*' - webpack-dev-server: '*' - peerDependenciesMeta: - '@webpack-cli/generators': - optional: true - '@webpack-cli/migrate': - optional: true - webpack-bundle-analyzer: - optional: true - webpack-dev-server: - optional: true - resolution: - integrity: sha512-9YV+qTcGMjQFiY7Nb1kmnupvb1x40lfpj8pwdO/bom+sQiP4OBMKjHq29YQrlDWDPZO9r/qWaRRywKaRDKqBTA== + /webpack-dev-middleware/3.7.3_webpack@4.44.2: + resolution: {integrity: sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==} + engines: {node: '>= 6'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 dependencies: memory-fs: 0.4.1 mime: 2.5.2 @@ -13972,13 +14988,17 @@ packages: webpack: 4.44.2_webpack-cli@4.6.0 webpack-log: 2.0.0 dev: true - engines: - node: '>= 6' + + /webpack-dev-server/3.11.0_webpack-cli@4.6.0+webpack@4.44.2: + resolution: {integrity: sha512-PUxZ+oSTxogFQgkTtFndEtJIPNmml7ExwufBZ9L2/Xyyd5PnOL5UreWe5ZT7IU25DSdykL9p1MLQzmLh2ljSeg==} + engines: {node: '>= 6.11.5'} + hasBin: true peerDependencies: webpack: ^4.0.0 || ^5.0.0 - resolution: - integrity: sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ== - /webpack-dev-server/3.11.0_webpack-cli@4.6.0+webpack@4.44.2: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true dependencies: ansi-html: 0.0.7 bonjour: 3.5.0 @@ -14016,27 +15036,20 @@ packages: ws: 6.2.1 yargs: 13.3.2 dev: true - engines: - node: '>= 6.11.5' - hasBin: true - peerDependencies: - webpack: ^4.0.0 || ^5.0.0 - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - resolution: - integrity: sha512-PUxZ+oSTxogFQgkTtFndEtJIPNmml7ExwufBZ9L2/Xyyd5PnOL5UreWe5ZT7IU25DSdykL9p1MLQzmLh2ljSeg== + /webpack-log/2.0.0: + resolution: {integrity: sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg==} + engines: {node: '>= 6'} dependencies: ansi-colors: 3.2.4 uuid: 3.4.0 dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg== + /webpack-manifest-plugin/2.2.0_webpack@4.44.2: + resolution: {integrity: sha512-9S6YyKKKh/Oz/eryM1RyLVDVmy3NSPV0JXMRhZ18fJsq+AwGxUY34X54VNwkzYcEmEkDwNxuEOboCZEebJXBAQ==} + engines: {node: '>=6.11.5'} + peerDependencies: + webpack: 2 || 3 || 4 dependencies: fs-extra: 7.0.1 lodash: 4.17.21 @@ -14044,29 +15057,34 @@ packages: tapable: 1.1.3 webpack: 4.44.2_webpack-cli@4.6.0 dev: true - engines: - node: '>=6.11.5' - peerDependencies: - webpack: 2 || 3 || 4 - resolution: - integrity: sha512-9S6YyKKKh/Oz/eryM1RyLVDVmy3NSPV0JXMRhZ18fJsq+AwGxUY34X54VNwkzYcEmEkDwNxuEOboCZEebJXBAQ== + /webpack-merge/5.7.3: + resolution: {integrity: sha512-6/JUQv0ELQ1igjGDzHkXbVDRxkfA57Zw7PfiupdLFJYrgFqY5ZP8xxbpp2lU3EPwYx89ht5Z/aDkD40hFCm5AA==} + engines: {node: '>=10.0.0'} dependencies: clone-deep: 4.0.1 wildcard: 2.0.0 dev: true - engines: - node: '>=10.0.0' - resolution: - integrity: sha512-6/JUQv0ELQ1igjGDzHkXbVDRxkfA57Zw7PfiupdLFJYrgFqY5ZP8xxbpp2lU3EPwYx89ht5Z/aDkD40hFCm5AA== + /webpack-sources/1.4.3: + resolution: {integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==} dependencies: source-list-map: 2.0.1 source-map: 0.6.1 dev: true - resolution: - integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== + /webpack/4.44.2_webpack-cli@4.6.0: + resolution: {integrity: sha512-6KJVGlCxYdISyurpQ0IPTklv+DULv05rs2hseIXer6D7KrUicRDLFb4IUM1S6LUAKypPM/nSiVSuv8jHu1m3/Q==} + engines: {node: '>=6.11.5'} + hasBin: true + peerDependencies: + webpack-cli: '*' + webpack-command: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + webpack-command: + optional: true dependencies: '@webassemblyjs/ast': 1.9.0 '@webassemblyjs/helper-module-context': 1.9.0 @@ -14093,68 +15111,53 @@ packages: webpack-cli: 4.6.0_e3222a4926c3b7d4c1aa5becb19e445f webpack-sources: 1.4.3 dev: true - engines: - node: '>=6.11.5' - hasBin: true - peerDependencies: - webpack-cli: '*' - webpack-command: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - webpack-command: - optional: true - resolution: - integrity: sha512-6KJVGlCxYdISyurpQ0IPTklv+DULv05rs2hseIXer6D7KrUicRDLFb4IUM1S6LUAKypPM/nSiVSuv8jHu1m3/Q== + /websocket-driver/0.6.5: + resolution: {integrity: sha1-XLJVbOuF9Dc8bYI4qmkchFThOjY=} + engines: {node: '>=0.6.0'} dependencies: websocket-extensions: 0.1.4 dev: true - engines: - node: '>=0.6.0' - resolution: - integrity: sha1-XLJVbOuF9Dc8bYI4qmkchFThOjY= + /websocket-driver/0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} dependencies: http-parser-js: 0.5.3 safe-buffer: 5.2.1 websocket-extensions: 0.1.4 dev: true - engines: - node: '>=0.8.0' - resolution: - integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + /websocket-extensions/0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} dev: true - engines: - node: '>=0.8.0' - resolution: - integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + /whatwg-encoding/1.0.5: + resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==} dependencies: iconv-lite: 0.4.24 dev: true - resolution: - integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== + /whatwg-fetch/3.6.2: + resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} dev: false - resolution: - integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA== + /whatwg-mimetype/2.3.0: + resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==} dev: true - resolution: - integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== + /whatwg-url/8.5.0: + resolution: {integrity: sha512-fy+R77xWv0AiqfLl4nuGUlQ3/6b5uNfQ4WAbGQVMYshCTCCPK9psC1nWh3XHuxGVCtlcDDQPQW1csmmIQo+fwg==} + engines: {node: '>=10'} dependencies: lodash: 4.17.21 tr46: 2.0.2 webidl-conversions: 6.1.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-fy+R77xWv0AiqfLl4nuGUlQ3/6b5uNfQ4WAbGQVMYshCTCCPK9psC1nWh3XHuxGVCtlcDDQPQW1csmmIQo+fwg== + /which-boxed-primitive/1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: is-bigint: 1.0.1 is-boolean-object: 1.1.0 @@ -14162,53 +15165,52 @@ packages: is-string: 1.0.5 is-symbol: 1.0.3 dev: true - resolution: - integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + /which-module/2.0.0: + resolution: {integrity: sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=} dev: true - resolution: - integrity: sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + /which/1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true dependencies: isexe: 2.0.0 - hasBin: true - resolution: - integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + /which/2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true dependencies: isexe: 2.0.0 - engines: - node: '>= 8' - hasBin: true - resolution: - integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + + /wicked-good-xpath/1.3.0: + resolution: {integrity: sha1-gbDpXoZQ5JyUsiKY//hoa1VTz2w=} + dev: true + /wildcard/2.0.0: + resolution: {integrity: sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==} dev: true - resolution: - integrity: sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== - /window-scroll/1.0.0: - dev: false - resolution: - integrity: sha1-bAxIxiCPkGHtkOEPZYkTqZGxiYc= + /word-wrap/1.2.3: + resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} + engines: {node: '>=0.10.0'} dev: true - engines: - node: '>=0.10.0' - resolution: - integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + /workbox-background-sync/5.1.4: + resolution: {integrity: sha512-AH6x5pYq4vwQvfRDWH+vfOePfPIYQ00nCEB7dJRU1e0n9+9HMRyvI63FlDvtFT2AvXVRsXvUt7DNMEToyJLpSA==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-AH6x5pYq4vwQvfRDWH+vfOePfPIYQ00nCEB7dJRU1e0n9+9HMRyvI63FlDvtFT2AvXVRsXvUt7DNMEToyJLpSA== + /workbox-broadcast-update/5.1.4: + resolution: {integrity: sha512-HTyTWkqXvHRuqY73XrwvXPud/FN6x3ROzkfFPsRjtw/kGZuZkPzfeH531qdUGfhtwjmtO/ZzXcWErqVzJNdXaA==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-HTyTWkqXvHRuqY73XrwvXPud/FN6x3ROzkfFPsRjtw/kGZuZkPzfeH531qdUGfhtwjmtO/ZzXcWErqVzJNdXaA== + /workbox-build/5.1.4: + resolution: {integrity: sha512-xUcZn6SYU8usjOlfLb9Y2/f86Gdo+fy1fXgH8tJHjxgpo53VVsqRX0lUDw8/JuyzNmXuo8vXX14pXX2oIm9Bow==} + engines: {node: '>=8.0.0'} dependencies: '@babel/core': 7.12.3 '@babel/preset-env': 7.13.12_@babel+core@7.12.3 @@ -14246,79 +15248,82 @@ packages: workbox-streams: 5.1.4 workbox-sw: 5.1.4 workbox-window: 5.1.4 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=8.0.0' - resolution: - integrity: sha512-xUcZn6SYU8usjOlfLb9Y2/f86Gdo+fy1fXgH8tJHjxgpo53VVsqRX0lUDw8/JuyzNmXuo8vXX14pXX2oIm9Bow== + /workbox-cacheable-response/5.1.4: + resolution: {integrity: sha512-0bfvMZs0Of1S5cdswfQK0BXt6ulU5kVD4lwer2CeI+03czHprXR3V4Y8lPTooamn7eHP8Iywi5QjyAMjw0qauA==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-0bfvMZs0Of1S5cdswfQK0BXt6ulU5kVD4lwer2CeI+03czHprXR3V4Y8lPTooamn7eHP8Iywi5QjyAMjw0qauA== + /workbox-core/5.1.4: + resolution: {integrity: sha512-+4iRQan/1D8I81nR2L5vcbaaFskZC2CL17TLbvWVzQ4qiF/ytOGF6XeV54pVxAvKUtkLANhk8TyIUMtiMw2oDg==} dev: true - resolution: - integrity: sha512-+4iRQan/1D8I81nR2L5vcbaaFskZC2CL17TLbvWVzQ4qiF/ytOGF6XeV54pVxAvKUtkLANhk8TyIUMtiMw2oDg== + /workbox-expiration/5.1.4: + resolution: {integrity: sha512-oDO/5iC65h2Eq7jctAv858W2+CeRW5e0jZBMNRXpzp0ZPvuT6GblUiHnAsC5W5lANs1QS9atVOm4ifrBiYY7AQ==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-oDO/5iC65h2Eq7jctAv858W2+CeRW5e0jZBMNRXpzp0ZPvuT6GblUiHnAsC5W5lANs1QS9atVOm4ifrBiYY7AQ== + /workbox-google-analytics/5.1.4: + resolution: {integrity: sha512-0IFhKoEVrreHpKgcOoddV+oIaVXBFKXUzJVBI+nb0bxmcwYuZMdteBTp8AEDJacENtc9xbR0wa9RDCnYsCDLjA==} dependencies: workbox-background-sync: 5.1.4 workbox-core: 5.1.4 workbox-routing: 5.1.4 workbox-strategies: 5.1.4 dev: true - resolution: - integrity: sha512-0IFhKoEVrreHpKgcOoddV+oIaVXBFKXUzJVBI+nb0bxmcwYuZMdteBTp8AEDJacENtc9xbR0wa9RDCnYsCDLjA== + /workbox-navigation-preload/5.1.4: + resolution: {integrity: sha512-Wf03osvK0wTflAfKXba//QmWC5BIaIZARU03JIhAEO2wSB2BDROWI8Q/zmianf54kdV7e1eLaIEZhth4K4MyfQ==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-Wf03osvK0wTflAfKXba//QmWC5BIaIZARU03JIhAEO2wSB2BDROWI8Q/zmianf54kdV7e1eLaIEZhth4K4MyfQ== + /workbox-precaching/5.1.4: + resolution: {integrity: sha512-gCIFrBXmVQLFwvAzuGLCmkUYGVhBb7D1k/IL7pUJUO5xacjLcFUaLnnsoVepBGAiKw34HU1y/YuqvTKim9qAZA==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-gCIFrBXmVQLFwvAzuGLCmkUYGVhBb7D1k/IL7pUJUO5xacjLcFUaLnnsoVepBGAiKw34HU1y/YuqvTKim9qAZA== + /workbox-range-requests/5.1.4: + resolution: {integrity: sha512-1HSujLjgTeoxHrMR2muDW2dKdxqCGMc1KbeyGcmjZZAizJTFwu7CWLDmLv6O1ceWYrhfuLFJO+umYMddk2XMhw==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-1HSujLjgTeoxHrMR2muDW2dKdxqCGMc1KbeyGcmjZZAizJTFwu7CWLDmLv6O1ceWYrhfuLFJO+umYMddk2XMhw== + /workbox-routing/5.1.4: + resolution: {integrity: sha512-8ljknRfqE1vEQtnMtzfksL+UXO822jJlHTIR7+BtJuxQ17+WPZfsHqvk1ynR/v0EHik4x2+826Hkwpgh4GKDCw==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-8ljknRfqE1vEQtnMtzfksL+UXO822jJlHTIR7+BtJuxQ17+WPZfsHqvk1ynR/v0EHik4x2+826Hkwpgh4GKDCw== + /workbox-strategies/5.1.4: + resolution: {integrity: sha512-VVS57LpaJTdjW3RgZvPwX0NlhNmscR7OQ9bP+N/34cYMDzXLyA6kqWffP6QKXSkca1OFo/v6v7hW7zrrguo6EA==} dependencies: workbox-core: 5.1.4 workbox-routing: 5.1.4 dev: true - resolution: - integrity: sha512-VVS57LpaJTdjW3RgZvPwX0NlhNmscR7OQ9bP+N/34cYMDzXLyA6kqWffP6QKXSkca1OFo/v6v7hW7zrrguo6EA== + /workbox-streams/5.1.4: + resolution: {integrity: sha512-xU8yuF1hI/XcVhJUAfbQLa1guQUhdLMPQJkdT0kn6HP5CwiPOGiXnSFq80rAG4b1kJUChQQIGPrq439FQUNVrw==} dependencies: workbox-core: 5.1.4 workbox-routing: 5.1.4 dev: true - resolution: - integrity: sha512-xU8yuF1hI/XcVhJUAfbQLa1guQUhdLMPQJkdT0kn6HP5CwiPOGiXnSFq80rAG4b1kJUChQQIGPrq439FQUNVrw== + /workbox-sw/5.1.4: + resolution: {integrity: sha512-9xKnKw95aXwSNc8kk8gki4HU0g0W6KXu+xks7wFuC7h0sembFnTrKtckqZxbSod41TDaGh+gWUA5IRXrL0ECRA==} dev: true - resolution: - integrity: sha512-9xKnKw95aXwSNc8kk8gki4HU0g0W6KXu+xks7wFuC7h0sembFnTrKtckqZxbSod41TDaGh+gWUA5IRXrL0ECRA== + /workbox-webpack-plugin/5.1.4_webpack@4.44.2: + resolution: {integrity: sha512-PZafF4HpugZndqISi3rZ4ZK4A4DxO8rAqt2FwRptgsDx7NF8TVKP86/huHquUsRjMGQllsNdn4FNl8CD/UvKmQ==} + engines: {node: '>=8.0.0'} + peerDependencies: + webpack: ^4.0.0 dependencies: '@babel/runtime': 7.13.10 fast-json-stable-stringify: 2.1.0 @@ -14327,83 +15332,76 @@ packages: webpack: 4.44.2_webpack-cli@4.6.0 webpack-sources: 1.4.3 workbox-build: 5.1.4 + transitivePeerDependencies: + - supports-color dev: true - engines: - node: '>=8.0.0' - peerDependencies: - webpack: ^4.0.0 - resolution: - integrity: sha512-PZafF4HpugZndqISi3rZ4ZK4A4DxO8rAqt2FwRptgsDx7NF8TVKP86/huHquUsRjMGQllsNdn4FNl8CD/UvKmQ== + /workbox-window/5.1.4: + resolution: {integrity: sha512-vXQtgTeMCUq/4pBWMfQX8Ee7N2wVC4Q7XYFqLnfbXJ2hqew/cU1uMTD2KqGEgEpE4/30luxIxgE+LkIa8glBYw==} dependencies: workbox-core: 5.1.4 dev: true - resolution: - integrity: sha512-vXQtgTeMCUq/4pBWMfQX8Ee7N2wVC4Q7XYFqLnfbXJ2hqew/cU1uMTD2KqGEgEpE4/30luxIxgE+LkIa8glBYw== + /worker-farm/1.7.0: + resolution: {integrity: sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==} dependencies: errno: 0.1.8 dev: true - resolution: - integrity: sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw== + /worker-rpc/0.1.1: + resolution: {integrity: sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg==} dependencies: microevent.ts: 0.1.1 dev: false - resolution: - integrity: sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg== + /wrap-ansi/5.1.0: + resolution: {integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==} + engines: {node: '>=6'} dependencies: ansi-styles: 3.2.1 string-width: 3.1.0 strip-ansi: 5.2.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + /wrap-ansi/6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} dependencies: ansi-styles: 4.3.0 string-width: 4.2.2 strip-ansi: 6.0.0 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + /wrap-ansi/7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} dependencies: ansi-styles: 4.3.0 string-width: 4.2.2 strip-ansi: 6.0.0 dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + /wrappy/1.0.2: - resolution: - integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + /write-file-atomic/3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} dependencies: imurmurhash: 0.1.4 is-typedarray: 1.0.0 signal-exit: 3.0.3 typedarray-to-buffer: 3.1.5 dev: true - resolution: - integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + /ws/6.2.1: + resolution: {integrity: sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==} dependencies: async-limiter: 1.0.1 dev: true - resolution: - integrity: sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== + /ws/7.4.4: - dev: true - engines: - node: '>=8.3.0' + resolution: {integrity: sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==} + engines: {node: '>=8.3.0'} peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ^5.0.2 @@ -14412,57 +15410,67 @@ packages: optional: true utf-8-validate: optional: true - resolution: - integrity: sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw== + dev: true + + /xhr-mock/2.5.1: + resolution: {integrity: sha512-UKOjItqjFgPUwQGPmRAzNBn8eTfIhcGjBVGvKYAWxUQPQsXNGD6KEckGTiHwyaAUp9C9igQlnN1Mp79KWCg7CQ==} + dependencies: + global: 4.4.0 + url: 0.11.0 + dev: true + /xml-name-validator/3.0.0: + resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} dev: true - resolution: - integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== + /xmlchars/2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + + /xmldom-sre/0.1.31: + resolution: {integrity: sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==} + engines: {node: '>=0.1'} dev: true - resolution: - integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + /xtend/4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} dev: true - engines: - node: '>=0.4' - resolution: - integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + /y18n/4.0.1: + resolution: {integrity: sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==} dev: true - resolution: - integrity: sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== + /yallist/3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true - resolution: - integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + /yallist/4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true - resolution: - integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + /yaml/1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} dev: true - engines: - node: '>= 6' - resolution: - integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + /yargs-parser/13.1.2: + resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==} dependencies: camelcase: 5.3.1 decamelize: 1.2.0 dev: true - resolution: - integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + /yargs-parser/18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} dependencies: camelcase: 5.3.1 decamelize: 1.2.0 dev: true - engines: - node: '>=6' - resolution: - integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + /yargs/13.3.2: + resolution: {integrity: sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==} dependencies: cliui: 5.0.0 find-up: 3.0.0 @@ -14475,9 +15483,10 @@ packages: y18n: 4.0.1 yargs-parser: 13.1.2 dev: true - resolution: - integrity: sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + /yargs/15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} dependencies: cliui: 6.0.0 decamelize: 1.2.0 @@ -14491,127 +15500,12 @@ packages: y18n: 4.0.1 yargs-parser: 18.1.3 dev: true - engines: - node: '>=8' - resolution: - integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + /yocto-queue/0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true + + /zwitch/1.0.5: + resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} dev: true - engines: - node: '>=10' - resolution: - integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -specifiers: - '@ant-design/icons': ^4.6.2 - '@babel/core': 7.12.3 - '@formily/antd': ^1.3.8 - '@formily/antd-components': ^1.3.8 - '@monaco-editor/react': ^4.0.11 - '@pmmmwh/react-refresh-webpack-plugin': 0.4.2 - '@svgr/webpack': 5.4.0 - '@testing-library/jest-dom': ^5.11.5 - '@testing-library/react': ^11.1.1 - '@testing-library/user-event': ^12.2.0 - '@types/classnames': ^2.2.11 - '@types/jest': ^26.0.15 - '@types/keyboardjs': ^2.5.0 - '@types/less': ^3.0.1 - '@types/lodash': ^4.14.164 - '@types/node': ^12.19.3 - '@types/pubsub-js': ^1.8.1 - '@types/react': ^16.9.55 - '@types/react-dom': ^16.9.9 - '@types/react-router-dom': ^5.1.6 - '@types/styled-components': ^5.1.4 - '@typescript-eslint/eslint-plugin': ^4.5.0 - '@typescript-eslint/parser': ^4.5.0 - '@welldone-software/why-did-you-render': ^6.1.1 - antd: ^4.14.0 - antd-dayjs-webpack-plugin: ^1.0.6 - axios: ^0.21.0 - babel-eslint: ^10.1.0 - babel-jest: ^26.6.0 - babel-loader: 8.1.0 - babel-plugin-named-asset-import: ^0.3.7 - babel-preset-react-app: ^10.0.0 - bfj: ^7.0.2 - camelcase: ^6.1.0 - case-sensitive-paths-webpack-plugin: 2.3.0 - chart.js: ^3.2.1 - classnames: ^2.2.6 - css-loader: 4.3.0 - dayjs: ^1.9.7 - dotenv: 8.2.0 - dotenv-expand: 5.1.0 - eslint: ^7.11.0 - eslint-config-prettier: ^6.15.0 - eslint-config-react-app: ^6.0.0 - eslint-plugin-flowtype: ^5.2.0 - eslint-plugin-import: ^2.22.1 - eslint-plugin-jest: ^24.1.0 - eslint-plugin-jsx-a11y: ^6.3.1 - eslint-plugin-prettier: ^3.1.4 - eslint-plugin-react: ^7.21.5 - eslint-plugin-react-hooks: ^4.2.0 - eslint-plugin-testing-library: ^3.9.2 - eslint-webpack-plugin: ^2.1.0 - file-loader: 6.1.1 - fs-extra: ^9.0.1 - html-webpack-plugin: 4.5.0 - i18next: ^19.8.3 - identity-obj-proxy: 3.0.0 - ip-port-regex: ^2.0.0 - jest: 26.6.0 - jest-circus: 26.6.0 - jest-resolve: 26.6.0 - jest-watch-typeahead: 0.6.1 - keyboardjs: ^2.6.4 - less: ^3.12.2 - less-loader: ^7.0.2 - less-vars-to-js: ^1.3.0 - lint-staged: ^10.5.1 - lodash: ^4.17.21 - lodash-es: ^4.17.15 - mini-css-extract-plugin: 0.11.3 - optimize-css-assets-webpack-plugin: 5.0.4 - pnp-webpack-plugin: 1.6.4 - postcss-flexbugs-fixes: 4.2.1 - postcss-loader: 3.0.0 - postcss-normalize: 8.0.1 - postcss-preset-env: 6.7.0 - postcss-safe-parser: 5.0.2 - prettier: ^2.1.2 - pubsub-js: ^1.9.2 - rc-menu: ^8.10.6 - react: ^17.0.1 - react-app-polyfill: ^2.0.0 - react-chartjs-2: ^3.0.3 - react-dev-utils: ^11.0.0 - react-dom: ^17.0.1 - react-flow-renderer: ^9.1.1 - react-i18next: ^11.7.3 - react-query: ^3.9.8 - react-refresh: ^0.8.3 - react-router: ^5.2.0 - react-router-dom: ^5.2.0 - react-use: ^15.3.4 - recoil: ^0.1.2 - resolve: 1.18.1 - resolve-url-loader: ^3.1.2 - sass-loader: 8.0.2 - semver: 7.3.2 - store2: ^2.12.0 - strip-json-comments: ^3.1.1 - style-loader: 1.3.0 - styled-components: ^5.2.1 - terser-webpack-plugin: 4.2.3 - ts-pnp: 1.2.0 - tsconfig-paths-webpack-plugin: ^3.3.0 - typescript: ^4.0.5 - url-loader: 4.1.1 - utility-types: ^3.10.0 - webpack: 4.44.2 - webpack-cli: ^4.5.0 - webpack-dev-server: 3.11.0 - webpack-manifest-plugin: 2.2.0 - workbox-webpack-plugin: 5.1.4 diff --git a/web_console_v2/client/public/fed-favicon.ico b/web_console_v2/client/public/fed-favicon.ico deleted file mode 100644 index 4d5f19db9b61914d904fa617e5e5537068ffc2aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2974 zcmbVO%TgOx5WVsysbr&6A`(}nDq@r9g%BWt(8E@hKOp%7v$V}aG8lu6u`X{MYzc!6 zSw;Lo{=o4Um@n|K$U4XsB#oq*xt-HjBNryv!5&pJXlB&so^!iTj}RVwE`KlZ*Dp4| z6rx865kOoe+C==CB3vZVH}C~H5HtKAC4&dYP&we~{ndaF2Sw3dcc zYb8#=q4vfwfeUa(l>H<{(2UehTB&ClrS>`)CsQF=p9~r&6M@`$dHI?-AImlG#SC*H zrdkgNsVxT;aHzeSAZsn5>`mkc7whpTYo@t4JJ9P{R`WP10Angdz@gfB;KgZPW+C9O z&-7IqccaRj?nqn@FwVTP+3lUkr z9j1C7F&R{~i2&6me=1cAey=$lt~6#MWX|>}bFQD7U5v#6)dY^_!kVoQleLj3qsDrj z$El)S8m-#fDb!d-F@`xEk({S-JFM!3E)H`FzA_8p3gb8K^bxp_IT!8VK(i(^Yf0#} zJXA4dte5KF`N{F+iM(Da=e$liU3Q+Pl~o3h*{E#HA_wD4yEu7hHx)GM`A~&%^*ubp zT+DmmvoOG#9dd49c*yFJWcL^drO(zkIrO67e&jIcViN09jl0Of>veG&`Os^|z*#kJ zM-+20nah0t5$bCU7#?c-#@yV5^O^9yAo|X4-j51Iv3ai_dm1Ip=T3(Yv@`0HfeLV_ zQ3$KXbl*{PHc|kF2N;4(XwzBuncVMSaV-jjY(igMGxPo6<6_P98K|>^`XqD%pK2k{ zYl6339<68%Iv5|y&-V;-aZn&+i|Yh6sd1opN4u!;&&?lyson_m0>cBplQ#B{d+ehy zBrqf}&{Mij9-v+p&;zX9T=X^fpZz>LrgpPOx~Je1+{*+XbhY|l9TP)2Tgoe5Kafut3Q6I=qej4L!@f%e@8d49tScTa8=}@IHdq z7FkC=bl=&&>KQD9=-Z$9etRqFK^$0{BicV1hy7G@S{mwLoYIIroV}dA1AcgDEhVmM z9OmTs${k+5>wFD0$kA(+Qiqsf+W#$hnzaEe=CcY9T8Y25%jpBIBMTa_-|1&~J@7%j z7H|gNZ7Rgx&OF-B354I?h0d%od<*^sPsaM7Gjj0EA!|LM+A9eI-dSX?*7v}HXNBf~ z{e$OrH6`@eG5k``Jlf8ErS`IaU=G6Dv6r2CA;(>7HGdMY0N#mV;l{qiCmD_8>o&9q2 zH#>FS?Wc|xv9}h&CBkd~Cok<~%bF9{=Z>r0la%%f=5Y3~R^qw#QvA@ZJ9sbpRdawh z@Qj&(cTNKXK3ba!9XBR|dCU*NGeqrOIfQQS&3zWD`EV!?4%M#5X+PGU9U(n~Fjs36 zdahFKX5aay6sxcVrU(Ca5^Z)<= diff --git a/web_console_v2/client/public/index.html b/web_console_v2/client/public/index.html index 555c38039..22875b6fa 100644 --- a/web_console_v2/client/public/index.html +++ b/web_console_v2/client/public/index.html @@ -2,11 +2,11 @@ - + - + \ No newline at end of file diff --git a/web_console_v2/client/src/assets/icons/logo-bioland-colorful.svg b/web_console_v2/client/src/assets/icons/logo-bioland-colorful.svg new file mode 100644 index 000000000..fefc7961e --- /dev/null +++ b/web_console_v2/client/src/assets/icons/logo-bioland-colorful.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_console_v2/client/src/assets/icons/logo-bioland.png b/web_console_v2/client/src/assets/icons/logo-bioland.png new file mode 100644 index 0000000000000000000000000000000000000000..b2e7d6cd26c17156fc1d980c7c620d5565bac469 GIT binary patch literal 17119 zcmYg%Wmp`&`}HiYi@PmSv_Nt9^039-wY0dqvp|tjtS#;?#a)U+ad&qqzPJ`%e*aJJ zheeH7}|tHcT{7~MD=NmBrpBK>v*fCwT0GSmd4h!91@ z>~~Wg8zm-Ap-oT_&^eqinxrtrL)ersLGF10-`Jwv>b9$vHX~udwLssYOHWFpCxGrF zDvn_R0Qvo7*R!BR-2UFq=AOmA-PzTj+-F<3Bb_+y`TBEf6ao@}t2}J<#4-C|J)xM_%DWKa*Max@GVi~ zx$f5{RXU}9?`HMy?`a1Uw(t)ExzA}5|Dpf%#0JX3R63O#Q0s^7Is;BY2;acEQyze1 zz$JSWIp%i&*OHgp`xXFxcHGBGvVnlnr2K6FX#GoSSR7BH*oOuH@3VtgtE5n|x(LfU z!RcL8t(_zYW+?9^u^2iVy^8Z zc^$PFtRc5(QsJ+=8{i)qV-B0-0$W5ani)og%bI@GC1>m>Hs96d(~jjcr_vShAfo8Z zG&@6MK==kDI7T!6g4=Bu92Y02tEeke{N|Xx4!;FW2R-BqS2q*g&tjFjH$M3Hy2(01y7yQO2Ns6UY4l^MfsP8OolokuN@lYn`shcob(p zxVD2<11p0heQ%7ziBtLUIM}n(NGSX#VoMP{mq9&09$%jCv&1e^K^C`kO7<_c(UQy= zv|kazS^6LBvprNfHNr~>c->RqjHVIG4Ck}w%}%6RTUy^*aUQs}ps$8d%O*`EP1*iM zJ=i}8x$?xq4#N)Vhkj%xvnBH-Dr*hdykOWeo(`CM0e&P5yX=&fzC(no`u`(s59 zlcS|Gcc%==jJFxr!uGHEz9oXhvL)bN8ShM*F)FfgVjm7 zYx}FbYm&pWxwAr|p_7J{CY(0hP+Xo?-s_QOzfQPC7|Sxna;@R8#C801jB%WLjB+A$V|MfA#{OoU zYJ?J>YEHyW#LvaeeZ<4gsr4v-leed&x1{Gl$CAP_$2H@UkEBo`ZWuqwBZ)-Az9_QD zAa+DiGN{mkl!*dPDWM8aQCd;= zW!yfev4^pq@najcN^Lr0=G;5`f#qI|CaC3IMeLGjSqxWdp7IPk364flSY}4T4VOeHl zr*3C{oYn+u`qdQV6@Djso`mm4z<_@8b*FcsSM2MyE5g2ADo^@Os%D~_dByHZZZpZ= z!Ps!7r>GvEUYws`Xfl3Mjl^yUL|T=TRAslwL5Ygs;!9enifnssbgo);W0Sn-kG%}- zgd4$C5r3Hnt1y-S-1~UYRn*zMzmxrFroU9uem}QXHooY*$SxG09-p44j%}S-$2R}2 zk-1UDf{pDb_Kx?S{Sd{^@S)os`g_;QWg@#!#mT%n^$Jxmu_9+No0kEpfmai>4XQuG zvu^Qxcu$WROzhVdSEbriRlCsSrFXtT*CG3xnEsmvi6;k_k$TG~FKhUe|FG3?P2xY* zEnRi33mfm1ix~Bgse%F-wqPCJgp&BdVTXz<{bq9_vxHAtXEsT*2HJtFBnc*EPG!Xv zcUAv2?QQ8Oo6P!Md)qH9t;shIog6`r@{b(KZuL4U<=bqeW+%en*yR-G(Yq8T_VR0P zIX*VmF{5>(L!*d%W_%|GBMoKC7diZSH$EAGM#I|w%1ayD7a5o4I#xY>FTo*HL?UZv z@yD(8Y+gFHZLN^`lM8VAw+XE@ za6I~aJA9%eTH$u3bR;r)HXZn2(>d!pe>cxzl;z!a)^|+N=)ky2+4QF6`p)C#gv1l$ zRB^?~C)TU-seS3+A?gFpVPM^J_2*9ejXTp$)A1fXoHHLd>D+N&w*V1^{3o0C4~EnjZjw`#S(QGz9?RWB?#>{9*FvKL8NK`tV*#(`)J2 z-`j0f&ilsoY<9MNcy3E+Ekp2YZTQ=_A0YX(47kh+ECxZ+nD*8R_sZ4^DhAp6c_s=h zMFpx>K@EiJ4U*^}1i?@AVD!b~g>&Mw8usNMWkxNxW4HZ}ih+5}Jf2B!zj}{1%Qd(K z4v(~6wmyn*6%ZmIV-PY=bmw~|2TKOM-ax3CRkL495vZ?r)oftve?~${(f>0d#q>BSVqXGjV-?T_k~fzA2-4gl-njzH#GQhPATQ9o3sL`ckN?VW zwLfKN$Iiwsu=Ncl46Gn*cmQdj8Bj6G1dvT6i3wxdXNhP;^umGZ;OZuTObtL!CJ4X2 z*#NEhosOX5#)>wY&UKKNa5%gP)QJk>)$44IonLc}+5gYk`~?KVY^tcI^!#y&43Hq) zozns5$<<-a?X|?6*Mt_pgq8ok9?(xbkUS5%lX))NW3n!Dq=zMenkYIeUf(APA23GR z&@%K(J4D-}0+h#lqL*bEJ*-(~y(=7%s|EpHB~*;22J}uJ3}8Ne6!j3XDoJ{2zuONP z=7=EZ67%*#30Q-Erquv&muG+t5XPv6ce?@hh5%r=ckde(Txk|`yvhVT0iAL#$FXcI z#fTA{ABC|u$QQN*TK3XeHxCs7vh3#dt2DGPbdCX$eDI4vA755@AuU+P` z`>3P=Tx;NVve)py?29{KjIj|8F0Uum5J*7PRahnb^VGg@w$sATk9mvCAYKiUbfq>6 z7L}GiH#UFja6AbJMX)XgFnjw$=rvKozU_1gBAW>fg6RR2OFv5@m?whn&wXF2nK>N9 zJ9)CpEZf#n+Uke~ZYfb&WQOR0%ClSptCY;GONGsGr2U;N2uBaFMf53x1BzP{jhVu7 zK|Re&Q|j_gnhHiDgOD+Z58{+9H@g*`(%fgW2f@WL#=qj9<#^|y;NOkPdIC+s+IlR$ zwUHE0GC(Gqw5ly6mDRO9>=I^=2unk^2t_NoM!Hxn_Ji@Dpo_O7JLs&~Sv?y8#>PRu zR3P{|sFw|Aiv)Cs`95IN4Lyx2qUNoin-GjM=MG4o(|uh^h_LbP6-$Q^&qD+!7KR>E zj$&|UANSd$%rLDsgX3RV`cO|^HKn$)@HlldO6PB^(fQWOxsUd{JE3vex2JkerBO09 zSq++?kuN9rR8ny67QpQqW_*Q`yBC)rw) zKLGnVaFSPEx4|5G2pTuuD#xEBOzi zYoiDnA0fId_o$60QrJ(VnNjdN>Tn4MxGk_2mD2o_ni>A-^f)(d<0tSu;?mp;0Zrci zbd(66GA9h3fG`O9jo^N?0na=BKyVSuP5lmRnN7cN&PvlA44Tw9>!fCG|3~u$iJfP~ zFY+a;Jdv0sLgbhg`q{J~^CxX`2Q|JQFJi15$S)X@7PkD^E0HGc6o4By)vAR7et19Z zks}@t*J(?_JH8zwWb*;yotA-Jm#y*>T+orfco;>^duLelcl+21@2BAg>^Mb>62A4Y-+|TW`VLg*SF(tc!gLvf;~@KUdkj39$rLkT-^t z$*M$U?OW5xTd5uoLN)&~dfTtREjvMD2(jhtszXUo8N5~tesV>@9~rfEM#9aac=ZMA zRxyRn4ivEkwEnvCC!1AdKgP=-pL=^gUD)90THci~;_|{@LRPNsYiRW$?NeZ=&u2uK zqXvO^Qbnm6!kD0hdmQD9{@594XB=Ylg)pd>t9G@MSIT+9b@f2ciK!P-;=%8zEHg!n zbNRwYJ?b<=>PQ8nplu7_@bvSfRG+!(XdWZZK!OYGBqh=Rc5xL7l@aNcThoz>n+;WN zk$&)KaoNkI3Rc!%K(rRhdBoiv62pOr%gxB~oHK)t4O?j9uWNN%sMR5pAas7JEz364 z-Xll~dMPbCGRG$4GhP0OsE&gR1#I4B`Vcm+H@1H&G!x_Jz+#j^j_SnD;9iT`=>e_H zJ5$X|^%%Lu2OMFa>}E*Q>cga8I_}Q(WM*rTAlKjkGXA>HCKfYjK^z}wUv%QQU6b&u z5%J&qN2V6pN*o7&%KO_+f8(itU?Z5d?)#%PU!JsayuJ>3D$XvP2j?o`6NaE0?u?6b;fXH0vN`T-pcPafrMe1-F`j;M(X{VW8+Acm^!pDKvalv;&Gt3xGTAVM zgLk1l%~rW{P|!8jdgZX!J`zJ#2Pe!cTir^GY%QOV&)H=EcR#Hjp~2e#1KIf=2;Edb0McL?>0Xmjn`T|ds+@IC@Q3MW z+D0J?JRoJ)a^{k_{$27 za8$u|>2LRiZ+G7d*p`Ng+`JpJ)4M#&**LKDE))YR{*q#TqA+gPWB1giu8%9tjm(aq#(xjOAy#N!X0{U>kH}n32*(t#~{(5l>1FaQf$(rY1pxH!jh5; zr;(JS`wIPbbA>K>W8JnH^mxO8aQ;#D{IOYIWyKNp88iRvpV9zT?1Dm2&3bkFYHNSN zoEYK#Q`$_s9E2-~1sW$%`IUxQHOCFTYkmezMH<1I#JgfLC{-z0uTyROv7P{86qNTN z_%A`&J$7){F4CV(J<2mMw5oz)3fk;eFd;%ar$EWcp+ZOe^_fzsiJ21-OD-7-@JZhg8J8ve*X8Jp%8NzdiSrWos3 zK()_zr>3kOebC06h9T?Uky8;%qJkFXkpifd>WA}oF1o(BrxbTZ<|r*g68YRN{(EFk zF8!tsM0Bhdp0is~jAC|l2z9Y4`-$|Y+qwxzz|aJvp=d)0mbC^GUxRS$p1P7o08`dy z8!554?XibAk)F@|%%F|=1Abl5!J7;27U*f{r$RsEAL4f??Uva42NJa6E7(p+Ikvbo z!l;n7g9&`p`}UR|(`dxMOrZ3Qj&MO#&cefVkYQ*~oZs!ZdQ)dkNm^U{>CQ?kkxRqh`xxJfU^kgIlplo; z8t?u8B1WLH+J=}!9uw!XK-Go;@4?3V|7hFI(-MDt-==nL${b&i`{|caO}hlqLuYv( z)VM%7`o{vwx5+KT7D%OZdM527_7Y+GZ11o@;?i^U)7NIx(f?gAf?rN;ws(V`ZE;sa zsO{`n76|0E?L+MgGFY?FDz_Pd$famM6}Tv!}3ALqB(=X9=bzjC&wJV z1U8X3h}J9D=q=ypbP-fT9Oy$Ov5{vX`}=tB!$W?T%^O%XKeOdGjtqDhVJ9YfXNhk~HnN*-vnRh@->WdPCF-D%`@(7-U(+-Hc6co zcDG`^Y|VX&L~K!D!B-DgBJRjX-w@e!oI{cs76*M0sI#UARZsF1J(%idMCw|?PlP*^$_aLx^k_a+y9;ZX&wAf0cgV(02_-hPeEK*SyI%s`})d**&g?xJWJT*40? z`w#j%lop!xGaPIFPkl0(<-rbL>?^h*#`kOjTn1bu7~0_kP@m0AjHnJ8wI!>X&VOr$ zg$5PLVy}CzNju(@g=u5oPhcwn5uV@QkwAwS%dU#+n9bNQ^w1{0bO@>EG{NiDvpH<} znWt2~A{gzR{LkCQ=%KidAWUybFnn$fV4(O+Q9mwH+*N)F7)j)|Qy2Q0enstdKmFG$ zq`SX|v9eEic4^VVG>4!5x~08|by0bWYOS5BL8*YeQz1iS82FQTtd+jEH-t~Ww(3MIx z9+M%VNav9k5c*+?<}mhw%V3o1a@Mf+Z!N-@1i^P+KTaE$-@cnM0wqmj*ThOx^4}E( zaYXvOUldaEYvk{*RlcB3$Yp{A?6VEtvh-UHxvc+f%V5Ca?=rd;e&0?dQ0C#kcfz%O z+u~)5zMePiMM4E#wPyYD%4$&ETC9~ha7`jo?;x*+|`X>neSI-s096yz-H9`k~}J(Ep39&S&mRzWxfF>(JEU2S=V3w zV55XLgtlT!1)0O1lZPw~6lNn%gMI_Ru=Jig?z0TDVck&Av@^oJc)*xI-z1nN_aL`E zXS~|bG3buC<=1~KP$RkDRKfWf+Nu9-awrbVS#HGovOJe3BXG2NHsH5vahT|4UzCI~ znvUd2flW6bdX4n|u-G2kZh|=uFV5YlH&PrAl3H5*c%H}@LRA~(zK!%N4C7r>vxJ5< zQSXk~rk~Z~|B(0O>N@O)TbBJ5{YxJgz+H;H2E7p9KR@fIlx|DytTa+;&twbV9P?mJ zm{b3I$kq3@3vI_CZCM(#yI^2e<`C5fPm6@4hJT4@S&Hq(L2!u^6}J+LZ*QVZ9jWGz zSBqcN$nQC`2g?DcelC&GuNG;HB!7Kvm0FWE98&R+agyk52n-cePj<*F<=j~RMW9L) zMvZH?SSfr`5C(DUL|>_+RFbtyj@VXn2rsy%`2tN?z=C)AF8g2Ih)AT z|H0boViF(Lj}=E-knKm?vcZdKBUZ0o*j>a$eRnI%mN4bxAzg#Gq-LzDPb-Xc|1rQ% zk4XjoQ(E(Q^I~*Qof)=;6DEjV!tBq5fmwKnp{L`#Om@^BE49>=S{?cZ*W}i}Zbq5x zQI3YV4Xox7DV8-ZS);>R9%sdt7vGlZm@y=jZHRg41)MMDXhW}7_>x-Y;hl6;z5@9| zvBOuH!TAJW&Q50~sf)!%+M!i;)LtT~qu3I&hrhV_B)0^~i?M|ZSd{i32r;&%C0stt2DE%&3`exuaVQ;X?-Yh$hQ#`wQtdf*2E_JpmQ@Dvph^UKNiixQERGv z-j~(56}Tbl*_tA@8wq<*xYeJNtdQy$`bZEKfnqq`A2;yiuO}e})PE?b0a5h78z04? z%D15?PFgYunIV1egV1SbF#GzvPs6*~O&Cvb)=F$ECp6cPNpU^|nEavPm=S z12F|rGCrm2N+fEzP~6IW(F`<*xQH8z5eA1a`uRG0U-q62l%uv(-StOWHO@C4VpTLg zX#}szLXn)fyul?%!CVr)poxm4FKOpAQfgZ*yh50!Ig@;wbD%LVLivPj^JSb)UaZWy z>^C;dpE3kp|ADvI`tW3oTrFgxuXF@8K6wEbT8FLq$y)r4b$0K;kVG>gPAx;fyWGb27hf9r9t792eqR z5#(Y<($pgI*msss-V+mvY@n@M)nV!)5&9&BoRB%o48&e0N{2`qsVQPxmX$2-vS{?C zkob8si;C7$wihFeP?ry$17-aq#_I(@(Cjg7DP$TH^~wzBrpf|I3ze72Jo|KI4CCbN#HA>|R-?!w=uK(?KC zn`S*9Q}zr4@J_Y+nYs>9BY2e%bBR*Us@BgUqpMVh#k+g@3(z?r&UGA6Hu zu0QwitMS`>rS4lP)@8r#0OBb}UoO?)?v`kWAM9}<=-Rl@-?&t5TFgqWG0)&B0TG#Z z>yqy}f`Zj)py|U`4zRn4$}dQ>9x^TPe*(RphBh|&bHu+llxeOfsWA$45WS-RkhCo7 zm24E);`0kW-)KN3FWkQ0Mo}PqZn#xO_;cGmh`i(M`VoOhr46Y@d3?Qo(aY+4lup*i z3#Pb+YCfSlIE^}wA=ax8Wo?d5Yx|m|uJIz-`OE~}`wI3t5{IZD-iaoJ=v?d%A)GCP zS&r6vZC(?Z`jh=Vf| z{a?i110AUjX*!S>JZt^sN7VZ1oK2=^wQrH zS^hoi)W0v13OfRL?w={b?>x^~q%i08P?wuh3ZqAx5t}tuw?5hE{9Zt5QW;}O{0|~m zRvywTbumebyu?E}9fB+2UJ_J0{bW}}{Niu3pCrX?I!A_ZLF1y+`RJSoV32(=zBs{-Y)hG-<4l8IF10AEA6+Ldu84Ufn18o}#n49(zWG08k{IP6IEFMAMbGS2t^7Y-^SF%+>Ami)H1nq-Bn&qP zW)REm8uUbb^q(N|Ai+9Gp_Q6CJp+6>ggGo9qUZ2iRXRxfHTIh41I6$f}?=NdCQpXR-8!m0PBi*_h* zW2kmg3K|?2rhNu+5aTO-Ij~wlI$?^!DgT#DkW1K}$k#G==oR2P#jjI2uIq7+VAY`R>tUMWk8 z!F}zZX}b*CGyIP{U&KTFvRis9Ua=sh!6tCd1&yNR!&&1(`5Nx7cUA&9oep81v?rHr z<#v0M|9wm;Q)m^@8vlw^$}bKpF`HjoZrX>vH+D^l-H#4-mz!@99D}`OIY>i`qE1;a zg%EnPMv*QzXNUD;qiRMSt{(SmUER0i6;}i0sPN^u+6O!GAh(-G)}7*pJa5up-%UGE z@Ha55!@m6_-*CuUlAA!+6#RpYJ%4PTxfdWJ7)zML@yT-~y>QmZ1?Tw{M%ijQhNahA zYe?ja9Tm~`TM$XpBb}FrUGbMBjw_M|&ZGW`@!@T<;f}HIj_wiUTJE7xitDsvv$%oB1=K$0)qx!846)^=?ue{x3^?J!T-Q ze4pJySEc9z4f8~~+P6GbCep8uQd_?p7y8fecJMXcG5abT!-YSg7KhQESiO+{$$CV^ zv{Py!bg@fXgZY?C0M-LLOOg~2s?(MMEd6(OA-qn)CsimuwD)C4w4f#T

VM{o2e> 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 LNkvXXu0mjfSCPGA literal 0 HcmV?d00001 diff --git a/web_console_v2/client/src/assets/icons/logo-bioland.svg b/web_console_v2/client/src/assets/icons/logo-bioland.svg new file mode 100644 index 000000000..ced772ee4 --- /dev/null +++ b/web_console_v2/client/src/assets/icons/logo-bioland.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_console_v2/client/src/assets/icons/successful-status-icon.svg b/web_console_v2/client/src/assets/icons/successful-status-icon.svg new file mode 100644 index 000000000..3df10d67b --- /dev/null +++ b/web_console_v2/client/src/assets/icons/successful-status-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_console_v2/client/src/assets/images/dataset-publish-bg.png b/web_console_v2/client/src/assets/images/dataset-publish-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..c1c19282cd1828687f2ca1b84ac71d8f5381e6be GIT binary patch literal 12849 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%0DI8S0@=AMeGzyD!&l2Wsp-)AkQJ^V zP}4=?OE1u7FyvJDwpVQi{I8d8!f(!c_n}6?@rsl)gx8TVX=AO`)HdT?VgI}s!ku18 zJ3B`|c{{gkMPVR{j=&7_E`uNw0}mh!k5DN6Q263eXIu?f?J>DSaE%k3YE_8^zLKHWmtIWv7`zgLH@*^B zo;9Yyj>;m=G3(N$_c|<2sOgbY-qp~`XWPf*tEd|_QlC@PQnz6nZ4~Rj99B7gqIp>> zd92M$Q^R@yG1cPhn!DDiVa(Tl*jfW{Ls+5+Gt*aHkUwn0sD)WSufd|Vq*&l9tWoe& zR&W@A_d5+92nJujaMRdmk9*;}ykT==DAXyX`0>x4r6PPTSlpyL(1{(|vwX zoKBzOpfHS?zJQqI)^+&LGs=C80l5G&0Dlx=flIPi=R`}^a~rLuoTM3oX{wC1Y&d~$ zd>Im}8LEB}-ZB$*Mc}pE$HQJI>KxA2qG z#Lw)vxbK?edrN?2iQknHSe-r&5f5SQBYZBxoW&8cv#dsXRfokl68NS}x!0k|VD6qm zTV()1ZWkR{(O}^9!(I|aJw7`nBb_XYsOJd1TEioMGz>qG#8jWb1Ki<6N%HVIa#k72hVi`cr=Eq9n2And^8ATQd#B*lCC2IK}|% zBdl*NOOhVK4AEq)v)#@L%clf$jL*vTstgJtMy|uhRa_H_At&4BwRMmJ<`7c2RdL zPB7sB>=7TTg?wz-A+9AC2%ru;E&d1Z#F$vbY+o9KX+9dRgw}RE1Rn@Ww*kISjA*qTBU)`4cmQEoWfPh~95t2-!8BZsiZ6lhz|MHe3V5d&yea>V zSD8f15WIw+os6>!Auk0giz&O#BUdpxI~G$-(Fs}*p~#JkNtGt@gM#u>I*E!%c! zDW>UTAULKIOFEX>RpG;wbP*<-#XhhF*JF-aizS!|PkenqISEa5x~!6nKm@#z^++0r zcaGhHgH-`;;M;T9WP?3SEvG%FjN00sjcuJEKDusO>m%K;&HR62oGBkg8D@+S^GcNk z%|VL6xCO`e2C$V@Che@LAqk=jq^W~&W`rao`-ebS_bCWAwtAr9N>agvn0*y2`@T63 z!HaDk#ShmUYK!IzF(kbYzcUNidJgDXeDz9vAD;Mv>*ZKh2IY0;wB%iW^{0K7`}7>~ z4j*7x&2lMLG4b0Nq8L|ABZrk%CN08D4jWOvmw|Q-&6^0LrE*G zLw9_ZJg&SnW3IU7;j@En(NZU8X|LOn!TxJ!ogMLYRNTmMA>T#pOYUkf{gTDPKJR_c z(;DU1NsigS)T6N-e<8kx+cQiQ-ZY|QOwCrI@_d` z1Xd@w@T$aIbNtSA5`IpDmOs>;>vpR)5QWp*>SZl&&5SG+*X{eiW-EcSeLTPzaMB$8 zIUq#xC*Rp~Czf8in9kAjj0L(JQq9wT$KQK=`<%L)y>8TDn$M%*%TmR;g#=2dW>9=VE)^aMwq7;d$YY_ z*}WdYyUS|6QnEj1< z91HQuT(w77fk}Yz0F9mk*tiv<(I_UDtqWciQRk8ouWOK4L{xtGr_E1}_-C zvUdAHO9##XAgGI9xj^mVX^H;Roj$|b6wd+{SRAKrg>U%j+iR#B3u4YpV;%~n-EpEL^heyhLso>h1w%*3cqjF zRYc#Op~@Y>qUQ{PP0!UZ8;|l2d@+X#o`si(KTmk+@_vHd9Mj8iafGkmK8i0(CCM@q zy6GM3eA8s>7i=5MAIL9x^>tZr#aceHrD0LR-*4hjjTINR7?s8v)S@Qy2rI8CwAHl} zT4pK7M0Afj_gfMymg_9NQ9KG#dE$dAn}r_lXIjmSH`8u@9lgjy(Pg`OkS+KQ7U9eI zG7VQGY zU1SYkPK4zx4Q4XSkTa7Vg@RN0OO6V%t$Fr%zm*W$AaBUz#uXxMbTsQs+3sgqCR~FUe9J z%@kpp(Ka3^BeU-Wqfw~UX5q9ocBE1Ef|eXsCRxcAMA-ugvvw5rPZ6)m%b zQXCO*>b6(JmZi-uTf}^?lVA4e>n&ZB+rw9uUu_-gX|n^biMC%h&Wc|Fm@>S~uzvzr zjJB6yRdEP2n#D_kOEcP3EUVn^O~>;?+hb^&u`Cs=ppl;=1Jbiff*lU;Rrj+enh5`1 zc!z9Th2p}%>0a?gI)E?v_rmR+U;Y5fFR8ZM)ww-q+X6m?-?(6!0_Lr0-;G$R%m5rS z%qN%&KugT9slkgf5%%rDv*TnTX)rLP)t*bOb~CcoNavce!WgiM6_A*33|1+Q7|l#G zeLTDjbCphSoZ=iZf@0|X_;Snl2zp8>xwM%Xy<*ggx_hL2^3_EiaafBBw5Laa4FRd{ z$&%t)$gq%Frj;HMCMnYZdys#c(_nJypIrz6wm@0a;+siSPy1g2joJOoNG{q%EpMtv}zZ6zItAjef^DC#X zvp5AWwx)d)I#AmX;(1`iH?I&eX(wQ5yilF775ugEv`l$L4(NN7xR!Jf+U zLUshJU?aP7#Ad!0vis@9mCzKAucL@&?7j7N z2`|j*m5hJ1$xL<*v&+w{?3;K=!}%%GSyxZwfn`+l$T`0@h(_~sC_Y@pfE5jEb(S>U zUw$-2zaGDB>A;|tv!>Ze98Ba`6JiJ{_JDWQb$bHOFuW-ZsfLH{B&pi8w~N$7XRT@= zx;9y_GN~>J)*1>g{o<|ciXJkV8SFaD?#cGIB!LmY5$(-t8c13qJ^3yO&ws!(nT(!Yf z=O0)jF%oL9(2C)z1u<4ezG*&Fi8*znO^^M;i`ABHq0ff55MV;QBE?JTCq7zniP5#! z+10eFC10yxw2#O0sVG=(2`xFRy!*T)7?w(I?5eGzJI14NiWlD*E#?=6c5y71=R z!`>5f6i*dwBt%DuUSJY4b`=XXape#5r5mi>aL}@MkZGfI{?h!m1y*VWE-?!^wn0oT zXAb~ot{nQEQ!;Ggl1Wlcx-84YGBm5I$So<1s-n57Wn&k^*rfz3jY`qV=+-Q{$%}KW zy7d^kt(W1-y!RpD9Vomz$8h-lIA;_`uHwuQ$&5-oxZ{EK@+-Wd`AlP(Zf|Qp`Ysk7 zV!w<7Y0I0fLV7|-@sb!r9=u{Wh$+65WN0cC6U*p!bJd$Hn{=1g!0HZE>%Xfhn^~4S zbhABVvTElW8$?%MgYIE(m&KKb+7WB(NWwvSW7n5MnKgEPy{+tb2&`9JCXbxLa#VZ% z-uS(RuClCjC>2FFp#d_wN->t78!=w9wb315@ec7M$yQY`rG`cF)Os#e(S71n6ce*d z{x-u_O**dlmSEY7u32vAn&Aj;nj5+XyoB9j#g(u$3|Th)`4o%W)CpspIbs;fD{`=d z;exr~Ge4-_>GWM< zY|&0^Ls>;|v!gA8vMaa1^CQ8q)XCMS(FM$EBUg7)#|FkysNAn$mjzlMIlhiyva(~7 z?d)f&`~BE@HtswO#BtSY(Zj$G%<$BGjhUCWiB_^0& z!fqkE`{CWB-11ai7V^31hGJ-zK!(;d|t_EiDV#CHwvD zSJ~$D5>s-z`uy8nZQRN?5I1;KCgZy2j&dWL?5d7@)`q5N8ZsLgq=Gdamd!L@O0D)D z<3a>(%LBJ;(qVGxF27)5@^=*nYb*I-;A8aqQHu%N@Fj@eR`$-WKSE+{9%c+>24(#D z-SfL>Oe6jA_V%MJIlY3A<8_cR$1z|X+!0cKFAOqHi4*#@0)D+3ESCXm)bp0L zS3VM5inFBgUGqEttwa6khqt#km1R1&aO_F;91Ez>sx?;?GzyvsnxM1WDK=3Z@2|NX zOr@a=t>Ew-%O<8;O4I3@S&o&BL1?Y_xQ*P7e(_7d4ZP*;&NEHs18Y}dv|i5vq3Nez ze&_(d{r02E6U+2{HAGjB+QjC>jhY&tX)dLt_Nb%KI_b(#(lzIFz2;o=e;Y%qR?rsP zQqx*euDP6tl2SQeQmv{fsaY=n_Fe`l41_Qgr4+j9B8bW4lS#b)F>6X;=!m5t?L)Hg z{X}=KoP)l4B&S0Sjo@@de%_w@;a zg@L}o!g(sio6E$+(1sYoL(1CQhBlo@38y24BZ8OD&L1LwhNIoC_e \ No newline at end of file diff --git a/web_console_v2/client/src/assets/images/empty.png b/web_console_v2/client/src/assets/images/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..da635b4620db2d51c974c0be78c025b395ab8f0b GIT binary patch literal 5171 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 \ No newline at end of file diff --git a/web_console_v2/client/src/assets/images/logo-black.png b/web_console_v2/client/src/assets/images/logo-black.png new file mode 100644 index 0000000000000000000000000000000000000000..0504bdbc1d373b1d8bc5d8ee1ab6b180736e3b96 GIT binary patch literal 23107 zcmbTd2{=^m`#(OE$do7?BzxH%N=Vs_B`wCj9$RHicE*;ak&HEKWF4X+6lEE*jV(Lb zO4;{y23d;#(fj@RF2CRZy8hSopR0=*-S_jn?$`ag@8>z^Jo8NZj{0d@Hd+J%aavPD zRR@7M1tAcW7Bt7;zkK<641j-SYTwdB!N0Zszg`IEL&VGsY<2X;`S8EY5i_&@{008s zqkn(R{{8vSUmhLVBmVjM*Z05e`xpQ9`o|LfnZAXnSo?Pt_~Vm3gy*05bNAa|h~geP zcXSboL3Qi&S>u-CTs=WSF6;Cq+OZqrJ@h?U;+B?K8ZLDu+Fav`v31L_C0ye!+p9IJ zp{odlF|VeolAh1#a(ys75F#k+&uic{<j^lS;Q8oL23x*Ji3x=r$JnuDk~khyn+rvL~;|J|E1sBBkKr zE~cBPecLH{x%K2h4E&L*$W+a6!#>{4qA&^mIOAlhw!|{ip}wVsKqy}I7R3|kf8O#{ z+lPrWal2%nukyQOjkQgk0->$7Xg5&Zu`BS)7lBB4PAaS(YZCC5znQ^|2vT-a!$(Uz zkD){aJ?42Vx%eg9)`Js9rqXAp_j|R{mXDJPp?LZl9{ouravG07_*l)YdgX-Wh@S#q z%PQ(!YNlW~%kcYIgyy&X&obSz)Hg3*I6MDo4)e~kIG5V-!sdO}L5rt~wrRex{)`C3 zjrdl+6&KSp)fJq_j*!I`r`zfI$}d9lw1^;=%%`f!SM3Ejw@MKRu9214`B#@nXau5! z{zY#A+4J^jyZRqN1VZC0B|sqqo#yH}Gkch`N_y@b@!p`Zl!{9=cAMzOVmJ+4~rRp6?3VR?Y0b&>i8t8`zbHCkdu)j$(s}}mZ+VQ(H_g1ZY z^%KTs{S2?LPoK9R-Vam|=6@7c9U89o@DJO$Hg!>=>lz_QeQB>UT38bh*B!bJ`l!7< zu&8bGV%LxqlH=S;uDM?C$wm``7`^i2+)k`f5TY~!<)m}{mjZj5#&O!;8}u#aa$9%A zLyCQbR^PE3ao%sb(@&Ls|7t*_KRUXgcf^bG9$r)G`McID&ux@q{6Dvge0_VeSNEDw zp8R5bER}3%&-1Xfo1Y!AcdU!-7fa)|j?WoJoWEU~Se~0%dM*c5774toNuwW&;~i8- zfBbEQ4yq1))Y$p1i$i(I`|BxbZTDKHR?dQ7KBZq7@g3UJtZl_=-4mR7KuR7uW~Lx( zuOLQw$#IvV;$u%t;=wCu`}zkqyWelrM;^FvfKrI5_-D4?k-cYq$vj?vGzJv9QDKAPedZI=oq`!HNZiC$sEsJS_P84+mVuuM`LOFrCQ z4hr8wzx}p!N?YY>7`CUSbae42_N8g!mn@^~8+qw1xI2%mEFB9za45BvD?N6FcntYE z`U?EcOv7%FQ-j5pH2=QK$*6nhl`ehj#Xho*e5mi4$~EorxYCK`0YTgf5ngocky}E2 zowfAOf{|Zt%A0y(6ATXm62AE}Y^AomMi$D822`C)FF)Pie854AS-ywj^f87 zo|kdI9USD2jTdoV^tr)Z#hbiw{IW$wQA~ud{+8PNQzp~J&}R84^W`_#fCIzJP>S}k zjtkr`B|7OBthY&zn3=1CtE6c4AJ{Yc1`POZ2rhcA@O?3YosO4_X;d|P-aR>mrIM@r z&d|WxA9)ifd&p;TU@hGeeF1Zw+gE`9{-C(0WT#;v8ViA4FjTSOai&YV;uu=sZ^{cPT|8zA_Ue%d44 zCzSY&Vx$~2#=a@wx8Esb^c>$!-U6yC>F$}<=b_y~j5)W=*c-wDn-}D42PoE~ccUh0 zqv5#C&-^uXcSF*+~H4lg#})R9Fj}^BbBjX?fEh3)#t_=yi-JW%MI&k>rZz@ zq8f^0_ISFuey^g|)`j#S!upuj7oSpj8?FAImKkvy)^sI4^+e~))z2>w>4H(#0SUb5 z!?q^s`L2bEK;sSTVufpCeYOOn^wPYAN8;VBJ`#{tC*ME!@)dpz`|u++ z8h+fUzh<7W#sb#9_x5hHKc++<^pu$L`M3>@tI!U&m|@)e^ruR{6wEZZUcZmKR}^W` z|KU%%WzVsK^3+S+kV!M{$L#_3R#sMUQjv(u=GYA!jOT>aU-Yt#AitPgyeAOkQ>uMx z$gR%DwH_KRAGajfH8!W~+H%?6BZP-Cf)9KrtB9b2NEAtjbL;kvlc|GEjn~h3{u+%0 zn8Hpxe#iHiy_=Ul%iVtJFd)#AHAbsVV7~0gqI^si>twyhA z+zmf_R&dX&Xq&66)rWyv2y!X?#tY*q))H*o6WC}BB$VvLn*{*PK5@37=oG43&4Ipw znRN<#LFuNk(OqTVf%q2}?^RWdXv_sNhp!S(lQ*nA@&rP{#i8jDI0X!+_i=>`bw}|I zA5dYYl5k@OBF1d`Gj#7W2GId}J!KE*_AD1UK3@69-qlz>LwURt#)1_1izP`7^EPI0 z@|C=hUCocj9kUVEV$_iU=16K~R+Hs1bQAv?9~bl5C)>x^@q4~6jv+k<3tNyciK{k( z%u}e08n|<=?EKNHQCNQf(@4b#Hk&PbQNyZN`cc0p0Q_V7Hje#Y)V(3U8j;vEm_fEX z1S5M#VeWV%wylhO%3fPnZaW=!pjdC{#<&%sK->#5Xz+1Wf}rdt|C*0mf4ZIp_uJ<^ z?!m3=wH7#cr48`ib6~R=1|X zW}MrxHGWbg5M{ihVIR~vKmBHkwlBN%qo?q_UT2t?*8w0srT|XA-ui~#d*{-$4W5=G zMH;cPLjuE*k-)>CCZw2`Ut^!Z&n$5@mAp)S$W*GIgMCVS;D`+qOs5aJF56B=1ce%cH!C$1g<-Y1lHu`JsaI^MJS1 z?Olve&k|mw1(zkx?AGLiu5S(TFD;Hd5s2_xF|k z7%=W;?f!*GJb%%c)8fvpn)R%dclLV;mMc8KGW&J)@Iyi9 zxN+en0S=H^(3-3q+dRL)qwvbzeuk6$v+;f%+`iTM*Y^Z0Bc`Z&W>Hz^u|!<6pS>K! z_h3HXmHyHs2e@fjr9vdGYPpU2|K#=!P%^Ih&by~kXEdm%OMomrJl+=gpfO-k3$A}! zLARDNOTw3+9kd|-d3fc3+^&VcLvoFwsg#ene>*V|)7Xm}LlJSl>ySwduwZQbB!!~c z2t()zU5JgKf4B4Du;kU;OqPQxLGn%EUcuetS`P`@e{RVKsutw>NSv9jDdPa$93(;I zfn#;bZQ~otXI?q(Dv+gp8!ivHMZDo=vG5A7)d|enLRV~+i8wmkc81D43c`JQJJS>& z`y64sAN#pBY2ieW#EG;-^Q@HB4cr*=YWc^fJJ}&xc=`Af5NEkql7OhfyXOKopKW>}ZJ`?LN%7osG{v!LhQgK0> zGiJ;02F$a5=Wq52^i2+NFgOJ0<@mV1*tzjX&BbY+!$w`9%HaWBFvsK(4=TC6MRPGk#v;BU8ndi_Sp}&l$HA=KBiu}$pOBbj{)FVF0ozB zP}ZP+|J(p&KTssC@GD4s34fM|Gs!XfPS7^VGO_n58B6IP6v7glf4=%;>+Jn__ej&* z=Pt$oJTx0QueUsT4!ZBEW9$c}XyNtqnGt8A0F?Xim;;r%y6o!+Aa${V4%UwRD4*#| zn+D%>R?En9aw7ZZ+wSi$ug=^%R$u!2XW$R+J{>kJ#JQEU-qeh{%ISwk^!kRY>k?R2 ze)ahhjJ_FZP_|)j3ID z1T;tzaj^2bpN!okr070Yl|FkPDc+osK^zyNKk=&ixAiZ#50#-bJ$dM2rbM;uA@x5z zl5d-FBR}2@-@0iZ0i5T(0<-4p6wk@U8K0?sW}&5E!Sv)p7<-r^HOXSxnI`sYKN@07 z?2D1(sn{!!+z7cyYr2n;BTC0!B_gF5zeT@5Us{%$MD=Ut`rh(8T@8shZ~A4$=QMNX z1}e~;8zbSy_#GVL^YLQhTKL-dJCC@Q>?aoSn?z);#egm$^Nk?H*rTO&KH;qD`HhhH z&+&bS0#cb)JT0jPaMEr!A$n|OQV?<7TAUK{RAC{*>2$0(`wwu z?0HY*lzgau@aVq16pAU~CQ3uEF7x(}!wHi_t+N^tD$P4DH~jBf3Z7eI@9tpzb-#@! z!=AAwfHAq2CK0%-RsFRlJe$tTrVSY@HeFHX>0{7e)hLeHh=6XC%p_YUqq~gyKR3QB zt^09zZ`ZF1$gpMn82ZQPa)=#U`K$wxCOIG>k}#joWpdwrto0nFA}J?LisJns;{$^y z5>+atB2H<_1Rw6tjDF!BYG0#7E2%%q;5trJdpZkB_ZQqIeTv`s5^PDq|zT^)pPvbg9#k?{vmwGQBN}H5G&B(3 z1)KDx!aMn_He_j$uX!TD$2F&^l}-bi9@qME>@l-c_-D0tkjRm21@7yNuarSYlZYdk$RN=_4s-Vm%W#>tXJ^^(8|y zPW;%DVMH$1=Qh~_lMwO_l9cfN2eSy;_y8Lv7`O^P}9FZMl=Lg3MQ ztVM)*^L6~Ez0AJ4K*~wh%s$g8tVx_PGT(lne5!64|0CoQ_~hfFr~V$LbJ0cW6VCz6 z)q|YI3@p=R`T)$@)0Ev?nM#2vHkQSu+{fefyI+E=O43m#?w_om5an;GLN8kfOuuYu z+I{(ve^LM>glItw)5b>EH6Oi=w}=>1dsxDra@*x29D3+!FH`RUUg-yHOfUACc=@)` zlq6+`x6rY5*PhG6QG8%WGBfR}NXJm0gy?6+Z@7mEHh>-KfPKNxDvH@ z=Gr&)BAKI*QF2thv;b(rA61-i%8R!R*DSxU;bLn=J%2b_O+TG%4f6Qu4$wxtJ%s0e zcG`>cc6RRdMc+1>&?DUrgz_q?Lbt41p_5DZ`A;rFn6OsS~^ z^R>-S_n3!c_&)n^yDy*VS(uNmGQUhBx>{|ci|d{PP{CB5AYInRos5i4wvZ&iot%dX zxrSU43Rc015bt1`Uxdweem1gPJD#Ny{<-vBrdQp^*ysI{iw&jq_MDz;>v6Hb6;bcX zjCyB240_fuO+5_uCi($KCA0DE`dg8`Id|G+#JvPJv5$zxGO8IVVVDGwJBE?pulevz z6ANaXD*D=iq^M5UCB4iRB;Et6i;r)uQO(Q zayE509$(+EEsT-jP1E@KjB4AI>qPa{<8%Su1H~74&0e7$Yu!iHvzL%3F=!$Cc|FPD z(|jP?=r4Sog!%h1{U+lS&Is{lJf&3brfPs%Iz9C^a~pNF{?0arv|50>$m;};ASSp) zRTYr%gfJf*|Jo>YY`rT$oRq_Lun4}Quc)0$!m*>#*`L>I=O`2#K0Ju= zo?UenGHeSF_pN`aW%`6hBekv!c}@&%wLfB>FL#ymp!SDR2AkTuNY<{Mg6^MXeyhMb5^@uByi}k~mfj{U&*f|pwK{sHE*4^;GEM01m=K{5@)>@n8hyUPC+IutF z+6~lf;CBjsDlTAS96>Sj(_T~aRPJI$n+>~Sw`7#DHF#R{7b)k3x>gJJ!x(0J3NCn` zS96)dpPffd5Rcow(s%JdWLX<38j8-u^&(Y9oH$w-%{YAZe?;*{r) z@VpX)3PGq zE}h)usrP%EaYFi*c(lFtX2`W<17GeM`aaxL(7PS`;mf4FJHtF5{heqg&Kyz6q2VjB zc4%G1Uw@81FgOP|H)&?FfNhj}D$_CUsP0Q4K74xPvt!d3Iw7vCGk#ZT109wN7$0KB zget5Z5Lxq7Tf2nKkegsTiu;bvMcrSUj&mW!C37jl8S8KVd_MI!Lf4qjhAyf~;w}tN zPNi9WYY2xrl&&mpL`L>LqOZy7%1eZ_7qI!vUq03Sl-B2Fu5IYzG9;X+j%iYAu zq@{p0a`%MBt&mLh%jxEj1yv#-$IxFKD zIm@YTf)PCt<(oOkEuOVzNmcapg_N(`%uHJpHJc%JLvd6TdLA3T=zb=tTqri+y~1%t zZ#{vP4iUkX23@;>=(Y1|&Znaed>KaLG{MOi_+NgEdFih#Yqyo*EUZ7x;x3mp<^sJ5J0IY^$59$K8#uk_gIc(2G?dzWUVJ}{ zEV+I&8?6B|8K8=bSu6rxY)&$btY7i9#ea;Copsg(a{;I{6mcV()!b?>i2uWt)uu)` z36C-MbJHq~tDy!YmVOM%`AMZFE#P0&^45e$T%fYNwzHYj+>gw$4-I6t zHKmPem@2oKZjW7GM7hG{L|I&YA(T-DR&MYozCL79&KH$yJxpFvzfk@vUh&`2;&;?n zmOqPJmt1^`oh`CCE+qfP2wYsARQ3%X8jDt<{ijZv8oQd-FXM%m*~C67hmJkT$7`tI zmDT!$y*y;U#fXH24gm@S%hV3dMu?U778AT@}gDnKX!|D3SMkk zrw0_~0X}0ZEV*@+Wqrs6)Q#m^PSl^=J+M)~rVOT}`^C7PINI7y7=F?C-c#Vsaa}EF z5>N0&A0eq*a&FNdt?)&^HQk5>sIwr>V3M1^3 z3i}|I|4*M1Ca+_^md~&)ub$3SIxX>g1|M+T__U$OVG_H-F1y?d^Nm^g4(y z>^cX*a(O}0E0VZGv{nN(o`N~^sQ4q!VV`N=(u+THlVSE6tXe3HVpI)=Pde321bko- z>sw?*T+*Q%#D(CK5Vjrz@xj^!fWBAhq2#kFQ9{-AauTY!^gpaKZ`1T^C73h(cP%hAi{FvhyHpFUdHDu(Q zUBAlVOm6!=I_+;S`0TO$rxD(n@-Z1({LL-vcyp)Rf1J@)fnp8D2r>`cs(YByHyefu z#cZBnWVA*GF?NE_GC5E$*uHvtTRE?6&%%{Q|6eHx$Nf#gjRdXt4b7u`unZR#3esXk zc{lp)Eo9YJEycbH590K_T!m{Cm!0zmVe*G~UQ&sJupX(*dmoauTWQa0fzMA=?-yj< zRUbJ$w}aZ;Daen0-W{g$0xJ9vqvjht{6FhsFXeD$LTLHIApWOvCW5W0FQZc~-@?DV z)g?^L)9{4q3rKI|spb*H*IU-O-BKD`ijHTS%D(l69-bUGRrH6M2JkHz7V?Q$)$KqB zK}<=-4ex}ydwtPa!2a^2jxWW?|D27zW1wGO5Buk7jr+ITyfh(p-6Dlnnz5&KoZ6Du zjX#pCwT>Mgx>mOGNS$0x9D9=?`xqhlEx_`Xtm1{~A>!&45nWqmucVjBOPJ^wp3_lP zjg^CAYM}4cSY=;|Yh!du9{-g6PGd9AtB+0vwC5e{y%T>}Da7ub4C+Tq8>n|}puyR5 zST%}yRR6fZ0@bv~_2a#+Vk@6DNrmwlo63=p$I?KOhW@a&FN3bF5c$mBf&fw7O349G z%tjxZCyHFP=B{Z)gY)k8odye6{`w3sU8tjO$OG;>v<*hL&Cl(M-Sd#Wxq;S*4O6Rk zTH;UzXOBgF`!J1!w+v<`A27eo(*F9<&l6AUTk(pn3=&t_4A%EhY@A!2zc)VSPj~jr z=K)q(VFs5hG(i99J{|tHx?5KKiau7x);1E4EbR2KHI$=}cYM`lq_OtoPyffG=t)o> zUKr?oOF8Bh|MZRggmugLw@+ae3hDaF$INNJbsETp8PrEa5vQV`tCOi5?Gh$>wZM-m za!rl;q$isV>POp{Vo2L?l!2ja{=sWeJ#5sbhm9Rmp-55K_bGfv$GK0%eriy?GJbCf zCGkD#qoRsnjomANiF5|V84Figh0<4CLpdZ8cR8HJgi5$xO=Sb!kM9v^?7@vptKZ@0 zx(Ujk{dcuFwbP4J)Em~e@PPWOHN2m&9!;=__D?v+$hLL2>PX>}i;~=hw zU-3tiIVx^%pno-Yi{_hu=MGbYmv-yDd0#mHo9|{%l{f?%IZ*Qrsfgtxq>5Su=;F|3_;o{!eF+t`_eg4v zzlFi)?yEty^#qpZ({`>&xr#fK!lkUBa{U?k?ZnO(jG`veh*`4f7JWyW> z4O<|7*ayD_B4QjOmGQ9)*#L_9%2a}HspgK}U)m)%VpSXZaPvl>^_t(sJ|tbpRn!Jr zVWhfx>{UG%P2>KEYu_b$mt)FBdRcmmTXU9E@bmbtQ6Xoc$6gNIK68~6+Ih}C=o?Z7 zF)uxXud`j11ev)HI%V!dMl17Pqj~y}rGL0E_#hVewmXa3Kcfq2Ye)sB$?=Zv3JmrZ zsD}0k*l`eZ&($F||x(IlS#c&JTR-b^Lb?Je3)f9VbJda<`#o9|)cuc3}#J#BtS zD4?iy9W{?7RKPb}e5%XZkuy}s&d1G}bV`HI{~}A`-|-(vFRgSCkYgbnU`i==n(e)n zNY>Qxz=oH7BG8UJA(ay~XWfRA^s%{%QQ{dn2Q8lH|~8WnOl<}a!N6<)Xmw!8cX zQg4_0#peWp->eNw_jxU%najT9?h4#vi;_Ja3z*iO`7jkSO=RW=3%V*u*1qjHn{aZ@ zx|yweC$7&V0bqQ-m9;BycIfoQo!$Z~OzDk{!x27at#LoS#%UDOqhte2Ho?+zE#<&4 zhpP=)I0QYZWf!$ewhvl+8L;uqC^lLxK#I18Z0+9O;yJWaod2LgJrVY-GdG#Rl0t%O zZ1yakUDSk}zQ<*V;QX(R)`{uX?G|WFt$)b&c*yQMM-+^4!2WS|NcHmwjt6Wr9@1*r@6p8d-s0z&0QRkOp?K%oBUR= ze>G6W<-b8Jz@UQz1XOF;A6h2cr+ksi`y3YIDH)L`2G6#g@H5|Hm%x|SOvw*YuJ#Fm z3BW0*TX}Pnt+?ac_l*A$f_0ZVLr4FCBTE3DC5kNp&|14^WxmtFm!|6Wk|6L0l^~rf z06QLcx>Y_Wo8OANQo`v6d!F!mabmB1*GKx`C|^IM0w5>-~wylo~_8%Vy7P;O?wbO05QR>$Tz~M;sxAc#}0yK>+UQTm(6d!Y*Xn{qJ9wYaK zxdZUsS%=-Y0q$yO^2PEE!yX>Db=>m_swO`?q0IKEEwGUICDvoU^5U3WLsNY0`mQf?9E3sxx{o z%=JadC{tDau&p`aY= ziweT9064yi#w7qE7LvM9mNs-My9?K)!gL&iezgN%4gvSKH}&3)!>BF}+yn2#2MEgWTE?u~S-Q*06UMe|H; z7!v`n*To7Q6%|y(1v1)uUoQy%A;duJAr{3JnEmLoaP1RgwONIU?I?{QYYd(dI76 z3&n+Vc;{2~S8dJx`Pic(x^R_8(|%pm2#A~eOQkEpiNs_5f>|Cku&XZn=!Fq3SL!_~ zPot)3R6YUW=kemYo`uu*i63&}8kk@ujB5s_bY0tVhfsI^UC+DvG~@Hko$B_I5vW^G zVFF+oBbJyiVQL4H;mTJAM3E>jgB>dR^O(}(zn}F~EZ6O&r`gN5U)nCoYr4k9lm?Uw zgY1jF_ZR5C0#Vn$S5t=xfQkZOmn3MwK`RH+o+&Sr{$y9(=5URnqh*s=KEv}Ebdilo!>YdoV#*Z;{CMx z_R-OhUt(VD{Pb5(Q!x!No6w(i$UAx-qbQ`H%{1vW?f&4`tY3&(jlNed`N;;4KS9GG zv3?4rJN5Oh@?^REqNdpN(2t9s63gbAaTyzwj-K}Vr6g2dO&MqeK~OL z`BGoOl_^w<4@G&^<{mA-MZ#*H`K$iT%cn%3@XgmH<*7wF_F7g1n)BeS!&j9ST%lSN zebnk$R~~q|yjvvdeZ@V3kwOyK_xnK zgSU?J^%=z9hun{xy~X~-_76a8GYaOzQ^cIQgE2c1%~$=wtTi`qRc>#bAh`%1m=Y~{{75p1FErL-`M~k(9dFaO z17{_Yxg&u{z%%akmP2_khtqvfRH?!`V4$SBM(c+bz6%EkUr>suVd1+*F%Ejg#DhA$ z&}gLJ(u<7-$_Y#T=WVaQ(qc-Iue_!C%z882*?$&S?3Nm#h5w;-e9)(Nkws^@_K;7# za^$svC^K!dvUsY`7*86U1KQxsE`Xr-U5Hl|54=o8nz{PTeD#WEkNK@O2It@N4ClcjG!w1PckBj~^AdMiQMxfCr zek=b0meoKf#ju1oVw<6#!Y=v5Z54f)!`;C#qoWU=3rwLRMI$#Tz31r^eGcSLq|MU| zs{hIC_h86|iSh>7nvq#ko+c*oO$F;WJf>>-+I<-ABW(zE7pjiYXMB#Sza%@kxZj1h z*+M76n6(&ZXA3%fg53$&JC?x%TEg#x&0n#ABJ^|y7f%W;Tb`pTjM)>&t64VpjT7(K zP@ath#9Pu97@6}v&zzTNEx|YYSOxzlu=#?PbgfTrG+KB*IzK{e*#Zm56+N^h$t+V- z#$*4zyX*ggvT8Ng7s{@Ou}?VuyruKli;c-AQ@eI~f7HVk@~$S|e|R4;g^K-O;R-wm z7{Dix7cgtR?g@PY&B(b8>(}uw@SA-Hy;;umkpMFd_1ovinY>EiC*p8PJ!JuZH{%NX zk1R~cTD&b1km^VS3+VE674c+l&8Fn>2XF7|505FagtbJk{I0mIBBUzF&e$6g6z{rv zI2oZw7vXYBWpLa7!%z2Ptv)kj($SYx*G8?{!n=2C-Q`e%S&E@WeZ*f*kycVN0u!7L2&WI^cu6D=`j7dsHW9+zXlJ$<0Q0AeRY7i)EvujbbgQ4XLgvH z1eis(&W?9nLX&bB(hNkCE=OV*rWu(%qVJ{5;7$h3KtK+o{!F)>x+Jpy0~kcC0d zpL(ci^V=gemP>>>fF)2yodaUE_Qm79mpPnjBe=3{T%%dHNy#a9`j^i ztR^NJ-t#G8&4*P>ThwbdQG+V@cNe*4sUAR11D5Tz+SS(I-J1+fh=UC;8&ZHk!2Haa zUz|2p=Un|6pQ=$OSe7GGX5GkL4u-lo@qoJARADU7rdZ{ERAV~%n5Kdha?bI`>q(?yZu z0nfMDjD5UaUvSxXheidx`aIhMtsrsmJrDng(1oe_{cAU)rPp24ym6K4wrWpY7+FMtVg&?#1GomWOaLIoe<8V9i5;(kQ*#cUMb zkxCwXr^zA?he0JXi@MQ)JfC~o-#__6t~*^+1QwG`yY1n&K~gT)fDN6O76QdVe@i%k zNlR!L@o-$-)qkMKfAkN9&M+){9AJ5upZ~nS#E_ZEJ_AO$MFQ5|d9sR8ZbLm8%Jtt;y?a_fD!1s>vg3?#ISe+1B31PFM-Q z{*i~CCPoi>(2^#wGuD3ge`cvZwZ;R}SQ%1|{4^HVLnR;W*jel2>}WrLd2=3P_3CPR z&iA#-?~uvosqmPVY3zB75)?$Bc@;LnRVBYD7SJ+f(5Lc-9bAu#0v9+C2W)YJ{`bVz15zzGxAI@) zp}!7MVK!p=JZQ-*q1*P%<$ylyx^h=v{PU4~k!)j08ul}f&$gU!1d&oCt_*qiu zYA`{(|HU2T`F7(tmFQn4MIi*wHI>|H#cDq zY;_8p`ksmVOLMC7Uoetl>&1?N0Ru}_lkiE;*90%ER%}LuRvC8DZV%&OCED#(Ez{e{h*l6!2@G**l#xq!5A~5|bT7aAByZ%ikoRHy8E_Oq0}z@bH?q@^?GJ2J#N? z*hZ@`!!t-De?7VNKiJgzjr7-XgeBydq^8LWcJRH@wGCZ-{t0x8o2xFZotM`NXho88 z1hePpTX75e$-w(1yzl;`zwm;$11(%K;^_zPy&qkFsG`IeW_#W1&MW(KtyFo`+6;wnKfIL!yc37J1i79NRPnj) zuGqZ)z{pEAe67~y4&+zF-Ta)`s^H;w)O7Y`S~%fyg|_;qeBJKrrkBA7SPTONnR4er1e?IFKhbP_$#?%uu{uz4$9@|QajD|1eLNM8j2MYt`5e~pr z4ELDMhKd0k!1fPl<^@1=hFa^{UoaEDDZM8jaFEg7HX3be_!J=KyFxk}_Npg_`~8zM z#*F+-Y>y02kSQl7ECnTwAaT947>C%O#){2}vw8BLlmPW=6w*J^I#!PoVlVo(hBo-V z*1lCcTTifrrW*+1L73q6fZ%-w5BWudgMFDW50LdA7iL<=<6THN5i4S%?{r%$Z`r^- zTE_}fts^Mz)Ye_7yr5k5{4s|N8&f!rU^Gt%e;wK|O4o(lf$!&ZFzg#1431pC{6BHQ z8OexYi>_D4bMRdRFr|aW#u0hWc_r!NnBZ-c#4fK^6!wERW|pdr)x?qf^VS?u6$8YAwJA)HsPjz(eU5#TSA2I5)UlQ+$DuL_%TXh zKbN3q#!&04ul$Tt<|f~pr^EkSgEX_Ku-yXCMkDj0=q&aTH8If;zJCDZ9Y~vNUa$q* zI*?DEhUUSWIha>C7hPeowXibSX0eXuZ%eQQp(9}oR+m-$;CXNaTJJAtcuUecL*Lsi zk%MdUtS79mn7ISZSR_1pV$er_Sr+xpaycVb7=Ij&WC7s|e7E0kuqU-FRjX@WHJIX@cR+{ajtJcr zb9&H?{A_ZXY%NMgD{zu^*5L>!&9y(!=eHuCmD5%BdfTtC6_{QoTcgkCU0=R<`@8V% z!Uev#2OBkZ98BcNkYD-ie|$PL+m2#!p)UWXsSKqAMvjWsY6~^^c%Th6mZ@!HzDJ^+`=%W8HDklZ+|kI7{A@( zaDv&erhFO0Hfc9-ZWhHrNUeNA5Z}gXW%S5({aL$SWL8Ho>J}{|OTR|Vlnywc_pdxm z_-aC~e*o8p+OI{jpvrECdQJSK1n7b(e{-*a5NojieX_cYXN(4HB?v4shk00bu!EX6 zhL3hfbNKZDz#1ASIh4PU1aoUhr_xZfhgr_&I*|Ss$ks*e@<(Rakrv%(Qc8?a0K7M) z=z80We$|Pzw+IaTQiwC;b#l85(pF)w^ofio zq;Ved2}>WYG*uVbDFI9kv0Tme9K86}Tzm_uSeUNH;{);Xb)T zV0$8C2x)xJP-s-^q`Mp<*JCR{rp%P4le4k<*M7>^y`5pzf54D?mg+|FiR+pjNJk3~ z1e`DEw(PZ*BS^ConPlC8`C$tcQ-j7|xt?-eK`*lp2K z@AKK8BL)Je)6JMee;9xsOm1x$D{KjT1&)r#zqElEJ&?ut zstaafOYG?YVckAn%{STM8DY5XH-@5AOV~{>MHIe(tG%>T3=t$*8;mLyc5x)#m&ygk{y^LvqXR*Sd zyS6?9`F4bYreJ^`PR8|@)YO<@i{;ETSmBqzR@~71H+l&X3l~WTB4N0oNQe#GUL|O6 z`69tr2QR0Ism@l47Nk3HlJnyL`)T6$01looNzSbu-C+j|cls!8Gfszq>2(N{Khqa# zrV16#F%LEvB@pO2meJFwvly#~Gi3C!9)CuDd&5p#{HT!fhIO^;9JW#Hp>^|1*~;+0 zO@Iqz{`TgK6uc96krz^kRx?+HzPIA=qe3bhIrQGpX7t_dV;#B)grMS{m%bFZf1#sa z#`uQJSC{CMbM#H0Y7p$2GJ(7yv6afRvu#RHong*PCX(_KYClFyoGO$uZ`h z8Za1)m!$_6Pb0wZUiRHg5v23>BuaUbb!E|>C4AjNyx@C9^;A8;9!1-Q3zP-f%FAOK zad^afPXDr(eCA+{8T>@ezys-MS<8q0uRtgIpK@Ct9EmA46i7^U;QxL8)w*^5M=vWM zSNh{nfoyH)kbpETiL#dVV1^skLmQGCygccj@XLRIR~>=4^bGJT|EfBH8sk~d z`TASRtyn0bMF2x#L48Py1#S#aV`G6ka!oH|s)#T;Unxtd;PogD(0Th>j284&8b@Vk z-2I@VbTEk}q~Oi3ad*BJ>vfRKe?=IDl5{siZ$o@>091oqK>pk<_|)(Kwtpog4Y3al zUOE8^1&^cF_AGze8>q9QpeI$>l~3)^sad>+;Z!{b>d4dBReo_O-hva`S&xfPm95;! z(KAKSKm(#Dz?+?`T;7hwTt5@lAcF7t#YqQoEr+4We;r3$<%M{o)&5AEK^_ioyr)pv zAKU2(^HiY#U3Hm#yrl}H3Qw0X_NhoDuZMb{{U}}cT6P68IbP#1KD}>>8lr&96LfE0 zRqaA)Hx+#*O0?1jgTKSi(hQ6BwylM04Z`Gy=UQ=m@VVbEbyrE+7~s#twR@eBgaYS7 zYq_#+ks(o9P|y5lU7BE;{xmArlm%9r2+1;&7ku7|?B=MYqxjGbJ1*vFV@hjQfZtyu z2{NyXm@|Qzp!EiuWT~R#7_V5MTMbg=*Am>UmsdaH0}iYGZxe7-BIRhC@~cDaUk4d7 zfW)lz9GBVwMlQ|=7r~4eg5sSdo%zr|{G>^XY3w75u<#eNco{a&i`qwDCZBJ`ZEx2% zvqb=&$VS|c-OAeDLN@pUfoH6t6?qX%@+s+#12m_x?wMiXo$wPQX!z0gY6;PvE23fj zcD$rLB~fZelL4j2Nc=`TJp6WxzLRTY=E3uhm#kg;=3ZC(S8kNZotQ-BH#>Yzeq2Kh z=57Q|31<7a0xa+yyF5=v@m0oYqV+VD$8#-B&SgQ8bu?i;f{eb)z2DVaV0pwsU}H_d zbpFky;n|wMu6_r3RiX;TxaT8n!l|w-?u&!WrEab_s-KTjDc+U8hJ1LVU zb3cmJD&$4bcfWemKZ`m)PXCQV6cg7c8cEP5@w9}>sU11?`X%Oq%3bO;=a?e^{=$|jsZb(yob`B(GVR$Q*L6?{1xH&f5S3&PD|9-eO|kOoP_Rq2>Usm@>v ziYQ(Hp@@x~==WMnUL)(?7eSpWY#eYCn)WGywQ`L_nRPdY(a-9r{U*_4BnnEoFT=-@ z^eM?X#c@99rqBJBWJf6%M=Pyq5Mf^G_iS~lgV!X{ z_~Gt>?pVxYDL`ow_4e%&zBX8IQWeVeK=OhKgw_qqyK%ssX)0y7MHw7*68OeZ)YaP0 zqdKcixIvuKHi(h^Jz_=`0p#yITCzQ~=;Zka?KCQh+Ao5T!V8M~9%_`L;Ktm}!U2w+ zKD2dXNCJTJuQ{X~MiJ=yVbIKme zWrEk(e><&GHsj{2%WPz4+-chWk0c;tAqAb~LySqZ+K9>&YH6cktLEdQIbzl;A&V0i z_MJ{pOreUFmhR25t=&zE2L%7owA+p0zep+0&EXH$a3wB;d7<*b{l`dt5OS*1j%vk0 zJw6ohn(e0IBYY8L<^{9j&2H`;G3q`|btjWkaGTur438G3oI(}Pnjj#v0RxIw+?&uT zsyeY1l50s(NZkLJ3r^!X48_1x^@z^eal3)4EFX!2N;PI#>Ivd@w-?~>MwT>EM%jW4 zGB=5o)dE{xbNHMF zO?8>$VO8R9-2Xz_@Qe@ySMKAzOCs#xV&MK6_tTxY26x$Zf!C7-w*9nRAeWatDs28I z1!m*c|0;NdFdSentWuNnbl~2FZQp^Up{e6|7a7gp+iSc$#?whS?${VPE5%w!Z4a%C(84fDbz?}hCznncr_lOm`Oyr zYv!E$x#xa9_pf`ef0n)0`n|sEw^@5VzV;cBAz$z1uj2X1^DOriH}D&$b>WpQ=up$# zoi|KOC%76xFKHZWqxuZqD0T-va_!4RrLa(UY73H>R8wwVGEm#sf}CqXx2CP`*jW3P zrl13t56)9-O>PzEZHF7akhOHR?wP@J9n6B7<$Q+sJ-^3#uxkrt$}Vlck}m9+2iCh= zkgbN1U`Bo`y0EpjD>ZefjcUvzXUO&hI1vr}@YTUG%2g-un+o(`14S-7vX}9fxi9{v zKc1T8n$^$Q&t=3#7qTwhxqR-*>q4Aw`UqBC zdHhxcQ|&D1(U$ckLRXWb3)}DA!B%ZjT;J%4m;dPSY(PO5R>9x6t~`9>mBEpR2JlwT z#2rmr?NV%8&<=vdda#u6=^tm=gtxYRrMpft5&A_F&n4DZKQ30?bWSecyS=+4s9+DA z-a-+t+%5U=2$x84Q2<|bq>+Edtr(4jKr`XPqrS8##x2pH*Gye ze5{EvI|e?@{#%S~<9$_kEUv_!4N5mtkB%={r_7Lssc#%Gdho5;Keg@=SZ7q9wXP-b zEZ=*&t9UO3H`5dtH0L!Bb?9Z6S`fV!blwMJ(+TaMIU1_6=ZhlE4%38$ZdrEu96g=; z_=RMqVqoZbO(v3ZA!FI|@TjIm<;6N$=>@R6Bgs=`r1bgYrLrLR%lnUCU=`x}`6G=Ef;d0=hX{cZ`MHg<=KA*q0(es{|>nJg!ro2A$`CBmo%*T#P(#=~hT{o>R%NP24<7tRM zVgnPcfp=dMt;_p5(WN?6uovzG>092xb<>4+<~1nJF?L{{oeulNVd=t59hmRl4esC+ zbn;XU(;;oub2W+6v^Kp=4977hceg;Ga+gfEeVVGs zq@?~_^ZXuEbFn9=2}vw8i3q|U%XdImUr&|gVrL(ex-(9{cptED>p6eHx1Yme?DMhv zRz)#Sbl}`1T9Z`YLn~gxPtV?gVTN>S7W6K^!2oeu?~h+XHZB@-834gk%1)TRB$*V^ z<4p9-iwv-QGsqU*L@d6S9&m zu+LZ*OW3{A#nHqaq_i1v&$?pb(H7*@WM2E3UT0bB?xX=9tsPgbPf<+EA9a>IO@Gq0 zv|LqTziNeF&ZRXBy)VXJNtoc69=YMN1Z{ArTjkrsfRRHKT-Z80aI?r?x)5@?|NN~d zz2GaQ^>t&sG@edsXlf2cp9Z9|1t7D96OZLL^jD7*mg z{^EJ&C^*<2=8I;DRvkLfTs z(WVK#JnEYD<3yoTKIZPq5k+jOVR8|)!^tn95c)i@Lq7xjpJ3;-F2Q++PfV!=UqT{z(0KGQkVp=1OS)wy7(y9a--TPbvSL!myD6500otAsc}-IhX8U z13OqD*%QnXDYzv9E_$7M5?(&h?Bz`gWJfRW8Frn!%2%Do9KDoQ1Pr7=ZHa}%3 z)i|0#lDKR03o$1Q2J0&jbVrTvgDCC>x?q-5;~y8TW>F?fYD%?VI~{mQ^dkH%e0m%! z!A4@KruG!v<~Qxg$mPveu+)Gz-;OZY$+^CL$<=YRQpCr8TKn{d&riWYXYos$G{Q~U zi0}Nma)HoV{!niR)q+nZFDz8@6OO?#1YZ`)@c^CJWiBIe0LGJjzJluuz9-z6x}Pd< zF8zrM?(}UK!=8t=lktv0FDr|o7Q$f4elZ<&^W4Rb74F)_(CRsbL)@5RC|l^(wN+$D z&xvf=j@;GIt*9sAoxd;)W>7AN!65j4TIM(!0>I9ej;JPdT?1?j{Y2R^EfL7xOdu2P)+Y#i<_dE2W7#zj^?sw{M z2`yHUfzN*hAa;KJBLPS_zUsnB)D8SM{SJFUZ+|MvWnGvr@$Ex!AX<|%EL1P`PMhL= zq1*Pd;aY3h-l=GMG4sx_CilQ;fzN;mA)$PN<^ozsHwvlDv#H2E5)k z(d~==OeRAQ8MxQuMDIICZ4bbl@8mKKRLMwRgJPB`$le|DlU~J;=FQ1wV{v778zoo8 zF03s5oBm0$2}VO?^$FvGVrY_3c1Wo%o#t_j$nI9BUrb4$C-Oy;fR$H0CRKg<2`b5$ z+5xZ9hOT$W`p-%W+ZEHn3y-~O)L*6%L=(mJd5xmot1pcuMuo_Ool6s<&E~UYi05h> zj-}f*WqJPk5HJpm+J$dYp|U422GPHkwH}i2)H1c)7PU0|O*KMHXQE}%{b4LzS<%5P zf=XxgsQd;Re)4Qwf&B{)&Kd{BQa(9O(Zq^iVO9DL7t={Hl$FRG!$zEyC*d#Hi0-05 z-0$SqA_-bg&j13CS}6)7-pMg$qcI5wnc4HSswUQz$Qh-fWEpyp%Vrfe(yAWiHqHq4 z8^`vFqAcznttw3#61&DH-&8cQCDdD%zB7iD=P(O}ZPZOLLHFR1az{eD!hz~^Jf|Dk zhyr1ZVGQo=y<}idC~TBIw>#CGU=jBj>^Ao>)J*w)xY_{SamL*3%v}!fAvs5wEd^}Y zRF$d|nIddt_o>NERjk%wkAqwpE>ewg9R^Nj^I5OS(UNgkVm<*`(yD(S5dXM$Bh$ zt5Xz*Ea6^P2`ZmlF>|Jh-w=;47B_dMv}!*YmK0rC3Woxa}0FMb2ESjLDea1JjgVFd^gpJ+< z{tzYl+zCHvI-N3u@k>V2IbF=XnRu1#>M9Sype1|;1i;XK;(w9#`QHYN^+Nn-%74gi z*u@_*=+af|K-NZdtI~}Q?xSZhK@8Qz@8cDx>r(luG5*(#vTar91NBE^I-`;|K?Kso zn0X0gBDeh~8x5Wa30hO3zAu8$M}fOqmMNP@PAJ9m6OzL&nP}=yh%52^ZclJZ$FWyx zQoIP~a^eVOTd3@Q%m*Es^%ZNvj9Dj#m4;7~J$;_Z`qjUBbqXOKO%FP}*};IGb#H$9 zHh#VhDX)&b%dIRG$FBIM_AgWe&CBPhcPGhlV)UR8+q{Et+D^|W8g%eRz5n7Cd`Ntk1lbRYl$zsTplME8ib(I7G_bYjAK7Q#W*#GEQnV@q^#`KBP+SLWtHwYvp zHeTlI^7*?t$hN1UjXQiszx`4wpiB^Y%#zLp3@&1t&>GS8Rx@tFQ}Q38_dBb=e(h5un&B8GX*8i57I@mA%Jsm3A|3rx@rkscV<$T^A^i;;N*aWp9dhy=mfvO&eDUI-Z4(mx88-7kn=qNV zV$p@nXD?1FC|10Q#Y^q%AT`kJR>sD})6U+3Bvc!=J3jJz;1W!N%Am*sp@G7%KsqpR zJ(0$F;mFQ{=oUXcLIhx#Eo>_YJcN9sONNS$3#H{5O`fw3`6(*a7#whOLiboj!{IGA< z&#R4=B``9wz!jfWoo&>JG6TGn#C(gObkodfM#cgR=u z^1@%0OES^A&z3$12Kv73&DK+aFR;PfKHDc7$doyeF8FywGF@hdo=RL8YDt2Ae*zB@W2et$@rsbYWvPI-aSZv z79&P{&ReA2nc52yOggnWwm+p17u8|VpchH1O0fSX@#z@ZI5EEJ**Wi3-?g7=DMv$N zMsCydHiSP*_6~oo@{v57PR)z#_!Drh^P;#IMEw5%y#P{b=*khkIYrJ9oYMZAG7!_sKlvwB}=a%f88hiN?-S z&p__Rt$V@@da8AKZXR8`A7nIakfzm-5U!lL)0J0K-rM=haU-7PzRy1hkY)lt&vWjL zw!Z)Te%T&O*|!R+QKi!}Q%~w3z%n}D%G~@>MM_LW{yWO9P-MHkRkGccMfmLsYiZ(C zZvV$2L*=+H8-0}n6va^}W7@Dx2_V+2i(Ay}&_tn{{BPYac?$Fe?<-Psa0eA5v6ZkR zVEc?4oWorLT%+d~td&DR`TRKfb}(>BXTY>sA5dhTCt$?|8hqYdP8Zal=prab>;QD6 zM^l4>0h^{d-eJ@36yV>ZsrKe#P@z(@r#DNWS*7;%-y&xl=0b#S)n(?H*!apyilVAz zWXo}~Xt{R&;xdckV3#K+9m(Ayws{W62DYfa?`ySzFP%Cg^g{xp>1AA4P__jqN2~zU zrEm4Qp>rS_t?ai;w{1Yr{}c>tk`eavQdS2(#oeABEBZswFNb;hG++WPD2a9yw!a~1 z?*~3pLs*d*p@B+!0TwpGhc-8%G^&@6dj^E7XRojZ0Qki!l2=9Kf6K_;+(^vAW{<>9 zU#U@%6vgH!wlIG54TUP9P_m?h0yT+~MQq?L%Bg!+P5|}*Wup-4<*!A;GAc@dXaVW8 zV5szWXNGN!05R;mNx=(|%=oijfP9d@J%tU}P*ETF6sEDD4ES5DN7#BoUx4~tzil5V zd>k^HZFd1BrB9uN;~u+6-GWk@>?>Q7s3VWU$;6#7JZ32`Yq|2<@Ap`H(l+ZigPC8~ zoR>hIJrfid*$&(p(6}Q=KiMKz!IO&3J2J>0^P9Se0AJn8xz-%u0%7OWvGoGGdk+em z(|V=%tpnYjJ+G1p2i0SSCxz0D+XSJl$i@obkk3zT{sD&VTU7=902}mjOV<)Kzft%x z_Z*~oTu8IC$IG5^UfxwbjAOg7Wl{!Um0oOI&gByd!xnwK~Ha@ r7yt@|vRL-6QgarDDtP=KetfpvXg=({E7mVu(0BlQ$odaUpM?Ja6|)yp literal 0 HcmV?d00001 diff --git a/web_console_v2/client/src/assets/images/logo-sso-cas-default.jpeg b/web_console_v2/client/src/assets/images/logo-sso-cas-default.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..ac6a03261006e45ee6cfb285aa891e56153c78d0 GIT binary patch literal 23312 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 literal 0 HcmV?d00001 diff --git a/web_console_v2/client/src/assets/images/logo-white.png b/web_console_v2/client/src/assets/images/logo-white.png new file mode 100644 index 0000000000000000000000000000000000000000..5b34e3c5e1ff6f074a4bc3c2881ddaa69787ff89 GIT binary patch literal 4106 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 literal 0 HcmV?d00001 diff --git a/web_console_v2/client/src/assets/images/project-list-bg.png b/web_console_v2/client/src/assets/images/project-list-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..4d3030cb6e67dce195409bfebd995d60a2faca79 GIT binary patch literal 53489 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

g+<{p{48!=6}iMb@hH7&CyAS*y6h`w+E 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 literal 0 HcmV?d00001 diff --git a/web_console_v2/client/src/components/AddTypeSelect/index.tsx b/web_console_v2/client/src/components/AddTypeSelect/index.tsx new file mode 100644 index 000000000..4998bdacc --- /dev/null +++ b/web_console_v2/client/src/components/AddTypeSelect/index.tsx @@ -0,0 +1,228 @@ +/* istanbul ignore file */ + +import React, { FC, useState } from 'react'; +import styled from 'styled-components'; +import { giveWeakRandomKey, transformRegexSpecChar } from 'shared/helpers'; + +import { Select, Button, Input, Message, SelectProps } from '@arco-design/web-react'; +import { IconPlus } from '@arco-design/web-react/icon'; +import { Check, Close } from 'components/IconPark'; + +export interface OptionItem { + label: string; + value: any; + isCreating?: boolean; + id?: string; +} + +export interface Props extends SelectProps { + value?: any[]; + onChange?: (val: any) => void; + onInputConfirm?: (val: any) => void; + optionList: OptionItem[]; + addTypeText?: string; + typeInputPlaceholader?: string; +} + +const Footer = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding-top: 4px; + border-top: 1px solid var(--lineColor); +`; + +const LabelStrong = styled.span` + font-size: 14px; + color: var(--textColorStrong); + white-space: normal; + word-break: break-all; +`; +const LabelIndex = styled.span` + display: inline-block; + width: 30px; + font-size: 14px; + color: var(--textColorSecondary); +`; + +const ItemCotainer = styled.div` + display: flex; + width: 100%; + justify-content: space-between; +`; + +const Left = styled.div` + display: flex; + flex: 1; + align-items: center; +`; +const ButtonGroup = styled.div` + flex: 0 0 60px; + display: flex; + justify-content: space-around; + align-items: center; +`; + +const StyledInput = styled(Input)` + flex: 1; + background-color: transparent; + &:hover { + background-color: #fff; + border: 1px solid #2761f6; + } +`; + +const AddTypeSelect: FC = ({ + value, + onChange = () => {}, + onInputConfirm = () => {}, + optionList, + addTypeText = '新增类型', + typeInputPlaceholader = '请输入算法类型', + ...props +}) => { + const [tempList, setTempList] = useState([]); + + return ( + + ); + + function onCreateClick() { + setTempList((prevState) => + prevState.concat([ + { + label: '', + value: '', + isCreating: true, + id: giveWeakRandomKey(), + }, + ]), + ); + } +}; + +export default AddTypeSelect; diff --git a/web_console_v2/client/src/components/AlgorithmDrawer/AlgorithmInfo.tsx b/web_console_v2/client/src/components/AlgorithmDrawer/AlgorithmInfo.tsx new file mode 100644 index 000000000..c48595b2e --- /dev/null +++ b/web_console_v2/client/src/components/AlgorithmDrawer/AlgorithmInfo.tsx @@ -0,0 +1,149 @@ +/* istanbul ignore file */ + +import React, { FC } from 'react'; +import styled from 'styled-components'; + +import { CONSTANTS } from 'shared/constants'; + +import { Table, Collapse } from '@arco-design/web-react'; +import CodeEditor from 'components/CodePreview'; +import { IconCaretRight } from '@arco-design/web-react/icon'; + +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { AlgorithmProject, Algorithm } from 'typings/algorithm'; +import { useGetCurrentProjectId, useGetCurrentProjectParticipantId } from 'hooks'; + +const SectionTitle = styled.p<{ hasMargin?: boolean }>` + margin-top: ${(props) => (props.hasMargin ? '20px' : '0')}; + font-size: 12px; + font-weight: bold; + color: var(--color-text-1); +`; +const RequiredAsterisk = styled.span` + display: inline-block; + margin-left: 0.2em; + font-size: 1.8em; + color: rgb(var(--red-6)); + line-height: 0.5em; + vertical-align: bottom; +`; +const StyledCollapse = styled(Collapse)` + .arco-collapse-item-header { + position: relative; + border-bottom: none; + padding-left: 0; + padding-bottom: 0; + } + + .arco-collapse-item .arco-collapse-item-icon-hover { + left: 3em; + right: unset; + // note: 和下面的自定义 expandIcon 相对应 + transform: translateY(-65%); + } + + .arco-collapse-item-content-box { + padding: 0; + background: transparent; + } + + .arco-collapse-item .arco-collapse-item-icon-hover-right > .arco-collapse-item-header-icon-down { + transform: rotate(90deg); + } +`; + +const CollapseItem = Collapse.Item; +const tables: ColumnProps[] = [ + { + dataIndex: 'name', + title: '名称', + render(val: string) { + return val || CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + dataIndex: 'value', + title: '默认值', + render(val: string) { + return val || CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + dataIndex: 'required', + title: '是否必填', + render(required: boolean) { + return ( + + {required ? '是' : '否'} + {required ? : null} + + ); + }, + }, + { + dataIndex: 'comment', + title: '提示语', + render(val: string) { + return val || CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, +]; + +type Props = { + type: 'algorithm_project' | 'algorithm' | 'pending_algorithm'; + detail?: AlgorithmProject | Algorithm; + isParticipant?: boolean; +}; + +const AlgorithmInfo: FC = ({ isParticipant, type, detail }) => { + const participantId = useGetCurrentProjectParticipantId(); + const projectId = useGetCurrentProjectId(); + let Editor: React.FC; + + switch (type) { + case 'algorithm': + Editor = isParticipant ? CodeEditor.PeerAlgorithm : CodeEditor.Algorithm; + break; + case 'pending_algorithm': + Editor = CodeEditor.PendingAlgorithm; + break; + case 'algorithm_project': + default: + Editor = CodeEditor.AlgorithmProject; + break; + } + + if (!detail) { + return null; + } + + return ( + <> + + 超参数} + expandIcon={} + name="table" + > + record.name} + /> + + + 算法代码 + + + ); +}; + +export default AlgorithmInfo; diff --git a/web_console_v2/client/src/components/AlgorithmDrawer/index.tsx b/web_console_v2/client/src/components/AlgorithmDrawer/index.tsx new file mode 100644 index 000000000..e11510ac0 --- /dev/null +++ b/web_console_v2/client/src/components/AlgorithmDrawer/index.tsx @@ -0,0 +1,164 @@ +/* istanbul ignore file */ + +import React, { FC, useState, useMemo } from 'react'; +import { useQuery } from 'react-query'; + +import { fetchPeerAlgorithmProjectById, fetchProjectDetail } from 'services/algorithm'; +import { CONSTANTS } from 'shared/constants'; + +import { Drawer, Spin, Button } from '@arco-design/web-react'; +import AlgorithmInfo from './AlgorithmInfo'; + +import { Algorithm, AlgorithmParameter } from 'typings/algorithm'; +import { DrawerProps } from '@arco-design/web-react/es/Drawer'; +import { useGetCurrentProjectId } from 'hooks'; + +type Props = DrawerProps & { + algorithmProjectId: ID; + algorithmId?: ID; + algorithmProjectUuid?: ID; + algorithmUuid?: ID; + participantId?: ID; + parameterVariables?: AlgorithmParameter[]; + isAppendParameterVariables?: boolean; +}; +type ButtonProps = Omit & { + text?: string; +}; + +const AlgorithmDrawer: FC & { + Button: FC; +} = ({ + algorithmProjectId, + algorithmId, + algorithmProjectUuid, + algorithmUuid, + participantId, + parameterVariables, + isAppendParameterVariables = false, + ...resetProps +}) => { + const projectId = useGetCurrentProjectId(); + const algorithmProjectDetailQuery = useQuery( + ['getAlgorithmProjectDetailInAlgorithmDrawer', algorithmProjectId, algorithmId], + () => fetchProjectDetail(algorithmProjectId), + { + // 对侧算法algorithmId为null + //TODO:后端修改接口,对侧算法algorithm改为0,删除algorithmId为null的兼容逻辑 + enabled: + (Boolean(algorithmProjectId) || algorithmProjectId === 0) && + algorithmId !== null && + algorithmId !== 0, + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const peerAlgorithmProjectDetailQuery = useQuery( + [ + 'getFetchPeerAlgorithmProjectById', + projectId, + participantId, + algorithmProjectUuid, + algorithmId, + ], + () => fetchPeerAlgorithmProjectById(projectId, participantId, algorithmProjectUuid), + { + // 对侧算法algorithmId为null + //TODO:后端修改接口,对侧算法algorithm改为0,删除algorithmId为null的兼容逻辑 + enabled: + (algorithmId === null || algorithmId === 0) && + Boolean(algorithmProjectUuid) && + (Boolean(projectId) || projectId === 0) && + (Boolean(participantId) || participantId === 0), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const algorithmProjectDetail = useMemo(() => { + return algorithmId === null || algorithmId === 0 + ? peerAlgorithmProjectDetailQuery.data?.data + : algorithmProjectDetailQuery.data?.data; + }, [algorithmId, peerAlgorithmProjectDetailQuery, algorithmProjectDetailQuery]); + + const previewAlgorithm = useMemo(() => { + if (!algorithmProjectDetail) { + return undefined; + } + + const currentAlgorithm = algorithmProjectDetail.algorithms?.find( + //旧模型训练可能没有algorithmUuid + (item) => item.id === algorithmId || item.uuid === algorithmUuid, + ); + + if (!currentAlgorithm) return undefined; + + if (parameterVariables && parameterVariables.length > 0) { + let finalVariables = parameterVariables; + if (isAppendParameterVariables) { + finalVariables = (currentAlgorithm?.parameter?.variables ?? []).concat(parameterVariables); + } + + return { + ...currentAlgorithm, + parameter: { + ...currentAlgorithm.parameter, + variables: finalVariables, + }, + }; + } + + return currentAlgorithm; + }, [ + algorithmProjectDetail, + algorithmId, + algorithmUuid, + parameterVariables, + isAppendParameterVariables, + ]); + + return ( + + + + + + ); +}; + +export function _Button({ text = '查看', children, ...restProps }: ButtonProps) { + const [visible, setVisible] = useState(false); + return ( + <> + {children ? ( + setVisible(true)}>{children} + ) : ( + + )} + setVisible(false)} /> + + ); +} + +AlgorithmDrawer.Button = _Button; + +export default AlgorithmDrawer; diff --git a/web_console_v2/client/src/components/AlgorithmSelect/index.module.less b/web_console_v2/client/src/components/AlgorithmSelect/index.module.less new file mode 100644 index 000000000..8f9ee5c64 --- /dev/null +++ b/web_console_v2/client/src/components/AlgorithmSelect/index.module.less @@ -0,0 +1,16 @@ +li:has(.second_option_container){ + height: 54px; + line-height: 24px; + +} +.second_option_container{ + > span{ + font-weight: 500; + } +} +.second_option_content{ + color: var(--color-text-2); +} +.text_content{ + font-size: 12px; +} diff --git a/web_console_v2/client/src/components/AlgorithmSelect/index.tsx b/web_console_v2/client/src/components/AlgorithmSelect/index.tsx new file mode 100644 index 000000000..2d453f1b0 --- /dev/null +++ b/web_console_v2/client/src/components/AlgorithmSelect/index.tsx @@ -0,0 +1,371 @@ +import React, { useMemo } from 'react'; +import { Cascader, Select, Grid, Input, Space, Divider } from '@arco-design/web-react'; +import { + AlgorithmParameter, + AlgorithmVersionStatus, + EnumAlgorithmProjectSource, + EnumAlgorithmProjectType, +} from 'typings/algorithm'; +import { useQuery } from 'react-query'; +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useGetCurrentProjectParticipantName, +} from 'hooks'; +import { + fetchProjectList, + fetchPeerAlgorithmProjectList, + fetchPeerAlgorithmList, + fetchAlgorithmList, + fetchProjectDetail, +} from 'services/algorithm'; + +import styles from './index.module.less'; + +const { Row, Col } = Grid; + +const ALGORITHM_TYPE_LABEL_MAPPER: Record = { + NN_HORIZONTAL: '横向联邦-NN模型', + NN_VERTICAL: '纵向联邦-NN模型', + TRUSTED_COMPUTING: '可信计算', + UNSPECIFIED: '自定义模型', +}; + +interface Props { + value?: AlgorithmSelectValue; + leftDisabled?: boolean; + rightDisabled?: boolean; + isParticipant?: boolean; + algorithmType?: EnumAlgorithmProjectType[]; + algorithmOwnerType: string; + showHyperParameters?: boolean; + filterReleasedAlgo?: boolean; + onChange?: (value: AlgorithmSelectValue) => void; + onAlgorithmOwnerChange?: (algorithmOwnerType: string) => void; +} +export type AlgorithmSelectValue = { + algorithmProjectId?: ID; + algorithmId?: ID; + algorithmProjectUuid?: ID; + algorithmUuid?: ID; + config?: AlgorithmParameter[]; + path?: string; + participantId?: ID; +}; + +function AlgorithmSelect({ + value, + leftDisabled = false, + rightDisabled = false, + isParticipant = false, + algorithmType = [], + algorithmOwnerType, + showHyperParameters = true, + filterReleasedAlgo = false, + onChange: onChangeFromProps, + onAlgorithmOwnerChange, +}: Props) { + const projectId = useGetCurrentProjectId(); + const participantName = useGetCurrentProjectParticipantName(); + const participantList = useGetCurrentProjectParticipantList(); + + const leftValue = useMemo(() => { + //支持选择合作伙伴后algorithmProjectId不唯一 + return [algorithmOwnerType, value?.algorithmProjectUuid as string]; + }, [value, algorithmOwnerType]); + const algorithmProjectListQuery = useQuery( + ['fetchAllAlgorithmProjectList', ...algorithmType, projectId, value?.algorithmProjectUuid], + () => + fetchProjectList(projectId, { + type: algorithmType, + }), + { + enabled: Boolean(projectId), + retry: 2, + refetchOnWindowFocus: false, + onSuccess(res) { + const data = res.data; + const algorithmProjectId = data.find((item) => item.uuid === value?.algorithmProjectUuid) + ?.id; + if (algorithmProjectId && value?.algorithmProjectId === undefined) { + onChangeFromProps?.({ ...value, algorithmProjectId: algorithmProjectId }); + } + }, + }, + ); + const preAlgorithmProjectListQuery = useQuery( + ['fetchPreAlgorithmProjectListQuery', algorithmType, value?.algorithmProjectUuid], + () => + fetchProjectList(0, { + type: algorithmType, + sources: EnumAlgorithmProjectSource.PRESET, + }), + { + retry: 2, + refetchOnWindowFocus: false, + onSuccess(res) { + const data = res.data; + const algorithmProjectId = data.find((item) => item.uuid === value?.algorithmProjectUuid) + ?.id; + if (algorithmProjectId && value?.algorithmProjectId === undefined) { + onChangeFromProps?.({ ...value, algorithmProjectId: algorithmProjectId }); + } + }, + }, + ); + const peerAlgorithmProjectListQuery = useQuery( + ['fetchPeerAlgorithmProjectListQuery', projectId, algorithmType], + () => + fetchPeerAlgorithmProjectList(projectId, 0, { + filter: `(type:${JSON.stringify(algorithmType)})`, + }), + { + enabled: Boolean(projectId) || projectId === 0, + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const algorithmProjectDetailQuery = useQuery( + ['fetchAlgorithmProjectDetail', value], + () => fetchProjectDetail(value?.algorithmProjectId!), + { + enabled: + leftDisabled && + value?.algorithmProjectUuid === undefined && + value?.algorithmProjectId !== undefined, + // 旧的工作区编辑时服务端不返回algorithmProjectUuid + onSuccess(res) { + const algorithmProjectDetail = res.data; + onChangeFromProps?.({ + ...value, + algorithmProjectUuid: algorithmProjectDetail?.uuid, + }); + }, + }, + ); + + const algorithmListQuery = useQuery( + ['getAlgorithmListDetail', value?.algorithmProjectId], + () => fetchAlgorithmList(0, { algo_project_id: value?.algorithmProjectId! }), + { + enabled: + !isParticipant && + leftValue?.[0] === 'self' && + (Boolean(value?.algorithmProjectId) || value?.algorithmProjectId === 0), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const peerAlgorithmListQuery = useQuery( + ['getPeerAlgorithmList', projectId, value?.participantId, value?.algorithmProjectUuid], + () => + fetchPeerAlgorithmList(projectId, value?.participantId, { + algorithm_project_uuid: value?.algorithmProjectUuid!, + }), + { + enabled: + !isParticipant && + leftValue?.[0] === 'peer' && + Boolean(value?.algorithmProjectUuid) && + Boolean(value?.participantId), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const algorithmProjectList = useMemo(() => { + return [ + ...(algorithmProjectListQuery?.data?.data || []), + ...(preAlgorithmProjectListQuery.data?.data || []), + ]; + }, [algorithmProjectListQuery, preAlgorithmProjectListQuery]); + const peerAlgorithmProjectList = useMemo(() => { + return peerAlgorithmProjectListQuery.data?.data || []; + }, [peerAlgorithmProjectListQuery]); + + const configValueList = useMemo(() => { + return value?.config || []; + }, [value?.config]); + + const algorithmProjectDetail = useMemo(() => { + return leftValue?.[0] === 'self' + ? algorithmListQuery.data?.data + : peerAlgorithmListQuery.data?.data; + }, [leftValue, algorithmListQuery, peerAlgorithmListQuery]); + + const rightValue = useMemo(() => { + if (value?.algorithmId !== null && value?.algorithmId !== 0 && algorithmOwnerType === 'self') { + return algorithmProjectDetail?.find((item) => item.id === value?.algorithmId)?.uuid; + } + return value?.algorithmUuid; + }, [value, algorithmProjectDetail, algorithmOwnerType]); + + const leftOptions = useMemo(() => { + return [ + { + value: 'self', + label: '我方算法', + disabled: algorithmProjectList.length === 0, + children: algorithmProjectList.map((item) => ({ + ...item, + value: item.uuid, + label: item.name, + participantName: '我方', + })), + }, + { + value: 'peer', + label: '合作伙伴算法', + disabled: peerAlgorithmProjectList.length === 0, + children: peerAlgorithmProjectList.map((item) => ({ + ...item, + value: item.uuid, + label: item.name, + participantName: + participantList.find((participant) => participant.id === item.participant_id)?.name || + participantName, + })), + }, + ]; + }, [algorithmProjectList, peerAlgorithmProjectList, participantName, participantList]); + + const rightOptions = useMemo(() => { + if (filterReleasedAlgo) { + const releasedAlgo = algorithmProjectDetail?.filter( + (item) => item.status === AlgorithmVersionStatus.PUBLISHED, + ); + return ( + releasedAlgo?.map((item) => ({ + label: `V${item.version}`, + value: item.uuid as ID, + extra: item, + })) || [] + ); + } + + return ( + algorithmProjectDetail?.map((item) => ({ + label: `V${item.version}`, + value: item.uuid as ID, + extra: item, + })) || [] + ); + }, [algorithmProjectDetail, filterReleasedAlgo]); + + return ( + <> + {!isParticipant && ( + + + { + onAlgorithmOwnerChange?.(value?.[0] as string); + onChange({ algorithmProjectUuid: value?.[1] as ID, algorithmUuid: undefined }); + }} + disabled={leftDisabled} + renderOption={(option, level) => { + if (level === 0) { + return {option.label}; + } + return ( +
+ {option.name} + } + > + {option.participantName} + {ALGORITHM_TYPE_LABEL_MAPPER?.[option.type as string]} + +
+ ); + }} + /> + +
+ onConfigValueChange(value, 'name', index)} + disabled={true} + /> + + + onConfigValueChange(value, 'value', index)} + /> + + + ))} + + + )} + {showHyperParameters && isParticipant && configValueList.length <= 0 && ( + 对侧无算法超参数,无需配置 + )} + + ); + + function onConfigValueChange(val: string, key: string, index: number) { + const newConfigValueList = [...configValueList]; + newConfigValueList[index] = { ...newConfigValueList[index], [key]: val }; + onChangeFromProps?.({ + ...value, + config: newConfigValueList, + }); + } + + function onChange(val: { algorithmProjectUuid?: ID; algorithmUuid?: ID }) { + const rightItem = rightOptions.find((item) => item.value === val.algorithmUuid); + + const config = rightItem?.extra?.parameter?.variables ?? []; + const path = rightItem?.extra?.path ?? ''; + const algorithmId = rightItem?.extra?.id; + const algorithmProjectId = [...algorithmProjectList, ...peerAlgorithmProjectList].find( + (item) => item.uuid === val.algorithmProjectUuid, + )?.id; + const participantId = + peerAlgorithmProjectList.find((item) => item.uuid === val.algorithmProjectUuid) + ?.participant_id ?? 0; + + onChangeFromProps?.({ + ...val, + config, + path, + algorithmId, + algorithmProjectId, + participantId, + }); + } +} +export default AlgorithmSelect; diff --git a/web_console_v2/client/src/components/AlgorithmType/index.tsx b/web_console_v2/client/src/components/AlgorithmType/index.tsx new file mode 100644 index 000000000..930f8898c --- /dev/null +++ b/web_console_v2/client/src/components/AlgorithmType/index.tsx @@ -0,0 +1,86 @@ +import React, { FC, useMemo } from 'react'; +import styled from 'styled-components'; +import { Tag, TagProps } from '@arco-design/web-react'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; + +type Props = { + style?: React.CSSProperties; + type: EnumAlgorithmProjectType; + tagProps?: Partial; +}; + +const Container = styled.div` + display: inline-block; +`; +const StyledModalTag = styled(Tag)` + margin-right: 4px; + font-size: 12px; + vertical-align: top; +`; + +const AlgorithmType: FC = ({ style = {}, type, tagProps = {} }) => { + const [tagName, modelName] = useMemo(() => { + if (type === EnumAlgorithmProjectType.UNSPECIFIED) { + return ['自定义算法', '']; + } + + const [modelType, federalType] = type.split('_'); + let tagName = ''; + let modelName = ''; + + if (federalType) { + switch (federalType) { + case 'VERTICAL': + tagName = '纵向联邦'; + break; + case 'HORIZONTAL': + tagName = '横向联邦'; + break; + case 'COMPUTING': + tagName = '可信计算'; + break; + default: + tagName = '-'; + break; + } + } + + if (modelType) { + switch (modelType) { + case 'NN': + modelName = 'NN模型'; + break; + case 'TREE': + modelName = '树模型'; + break; + case 'TRUSTED': + modelName = ''; + break; + } + } + + return [tagName, modelName]; + }, [type]); + + const mergedTagStyle = useMemo(() => { + return { + fontWeight: 'normal', + ...(tagProps.style ?? {}), + }; + }, [tagProps.style]); + + return ( + + {tagName ? ( + + {tagName} + {modelName ? `-${modelName}` : ''} + + ) : ( + '' + )} + + ); +}; + +export default AlgorithmType; diff --git a/web_console_v2/client/src/components/BlockchainStorageTable/index.tsx b/web_console_v2/client/src/components/BlockchainStorageTable/index.tsx new file mode 100644 index 000000000..d0e56b5ef --- /dev/null +++ b/web_console_v2/client/src/components/BlockchainStorageTable/index.tsx @@ -0,0 +1,171 @@ +import React, { useMemo } from 'react'; +import GridRow from '../_base/GridRow'; +import { Statistic, Table } from '@arco-design/web-react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { useQuery } from 'react-query'; +import { fetchDatasetLedger } from 'services/dataset'; +import { TABLE_COL_WIDTH, TIME_INTERVAL } from 'shared/constants'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { + DatasetTransactionItem, + DatasetTransactionStatus, + TransactionExtraData, +} from 'typings/dataset'; +import { formatTimestamp } from 'shared/date'; +import { useTablePaginationWithUrlState, useUrlState } from 'hooks'; +import { PaginationProps } from '@arco-design/web-react/es/Pagination/pagination'; +import { SorterResult } from '@arco-design/web-react/es/Table/interface'; +import { get } from 'lodash-es'; +import StateIndicator from '../StateIndicator'; +import { getTransactionStatus } from 'shared/dataset'; + +type IBlockchainStorageTable = { + datasetId: ID; +}; + +const StyledStatistic = styled(Statistic)` + margin: 12px 0; + .arco-statistic-value { + font-family: 'PingFang SC'; + font-style: normal; + font-size: 16px; + line-height: 20px; + .arco-statistic-value-prefix { + font-weight: 400; + } + } +`; + +export default function BlockchainStorageTable(prop: IBlockchainStorageTable) { + const { datasetId } = prop; + const { t } = useTranslation(); + const { paginationProps } = useTablePaginationWithUrlState(); + const [urlState, setUrlState] = useUrlState({ + timestamp_sort: '', + }); + const query = useQuery(['fetch_dataset_ledger', datasetId], () => fetchDatasetLedger(datasetId), { + retry: 2, + refetchOnWindowFocus: false, + refetchInterval: TIME_INTERVAL.FLAG, + }); + const totalValue = useMemo(() => { + return get(query, 'data.data.total_value') || 0; + }, [query]); + const list = useMemo(() => { + return get(query, 'data.data.transactions') || []; + }, [query]); + const columns = useMemo[]>(() => { + return [ + { + title: t('dataset.col_ledger_hash'), + dataIndex: 'trans_hash', + key: 'trans_hash', + width: TABLE_COL_WIDTH.NAME, + ellipsis: true, + }, + { + title: t('dataset.col_ledger_block'), + dataIndex: 'block_number', + key: 'block_number', + width: TABLE_COL_WIDTH.NORMAL, + }, + { + title: t('dataset.col_ledger_trade_block_id'), + dataIndex: 'trans_index', + key: 'trans_index', + width: TABLE_COL_WIDTH.NORMAL, + }, + { + title: t('dataset.col_ledger_chain_time'), + dataIndex: 'timestamp', + key: 'timestamp', + width: TABLE_COL_WIDTH.TIME, + sorter(a: DatasetTransactionItem, b: DatasetTransactionItem) { + return a.timestamp - b.timestamp; + }, + defaultSortOrder: urlState?.timestamp_sort, + render: (date: number) =>
{formatTimestamp(date)}
, + }, + { + title: t('dataset.col_ledger_sender'), + dataIndex: 'sender_name', + key: 'sender_name', + width: TABLE_COL_WIDTH.NORMAL, + }, + { + title: t('dataset.col_ledger_receiver'), + dataIndex: 'receiver_name', + key: 'receiver_name', + width: TABLE_COL_WIDTH.OPERATION, + }, + { + title: t('dataset.col_ledger_trade_fee'), + dataIndex: 'value', + key: 'value', + width: TABLE_COL_WIDTH.NORMAL, + }, + { + title: t('dataset.col_ledger_trade_status'), + dataIndex: 'status', + key: 'status', + width: TABLE_COL_WIDTH.NORMAL, + render: (val: DatasetTransactionStatus) => { + return ; + }, + }, + { + title: t('dataset.col_ledger_trade_info'), + dataIndex: 'extra_data', + key: 'extra_data', + width: TABLE_COL_WIDTH.BIG_WIDTH, + ellipsis: true, + render: (val: TransactionExtraData) => { + return val?.transaction_info; + }, + }, + ]; + }, [t, urlState]); + const handleOnChange = ( + pagination: PaginationProps, + sorter: SorterResult, + filters: Partial>, + extra: { + currentData: DatasetTransactionItem[]; + action: 'paginate' | 'sort' | 'filter'; + }, + ) => { + switch (extra.action) { + case 'sort': + setUrlState((prevState) => ({ + ...prevState, + [`${sorter.field}_sort`]: sorter.direction, + })); + break; + default: + break; + } + }; + return ( + <> + + + +
+ + ); +} diff --git a/web_console_v2/client/src/components/ButtonWithModalConfirm/index.tsx b/web_console_v2/client/src/components/ButtonWithModalConfirm/index.tsx new file mode 100644 index 000000000..263b83b12 --- /dev/null +++ b/web_console_v2/client/src/components/ButtonWithModalConfirm/index.tsx @@ -0,0 +1,51 @@ +/* istanbul ignore file */ + +import React, { FC } from 'react'; +import i18n from 'i18n'; + +import { Button } from '@arco-design/web-react'; +import Modal from 'components/Modal'; + +import { ButtonProps } from '@arco-design/web-react/es/Button'; + +export interface Props extends ButtonProps { + /** Alias onOK of Modal's props when isShowConfirmModal = true */ + onClick?: () => void; + /** Show confirm modal after click children */ + isShowConfirmModal?: boolean; + /** Modal title when isShowConfirmModal = true */ + title?: string; + /** Modal content when isShowConfirmModal = true */ + content?: string; +} + +const ButtonWithModalConfirm: FC = ({ + isShowConfirmModal = false, + title = i18n.t('msg_quit_modal_title'), + content = i18n.t('msg_quit_modal_content'), + children, + onClick, + ...resetProps +}) => { + return ( + + ); + + function _onClick() { + if (isShowConfirmModal) { + Modal.confirm({ + title: title, + content: content, + onOk() { + onClick?.(); + }, + }); + } else { + onClick?.(); + } + } +}; + +export default ButtonWithModalConfirm; diff --git a/web_console_v2/client/src/components/ButtonWithPopconfirm/index.tsx b/web_console_v2/client/src/components/ButtonWithPopconfirm/index.tsx new file mode 100644 index 000000000..c89d7b89c --- /dev/null +++ b/web_console_v2/client/src/components/ButtonWithPopconfirm/index.tsx @@ -0,0 +1,51 @@ +/* istanbul ignore file */ + +import React, { FC } from 'react'; +import i18n from 'i18n'; + +import { Popconfirm, Button } from '@arco-design/web-react'; +import { PopconfirmProps } from '@arco-design/web-react/es/Popconfirm'; +import { ButtonProps } from '@arco-design/web-react/es/Button'; + +export interface Props { + onCancel?: () => void; + onConfirm?: () => void; + /** Popconfirm title */ + title?: React.ReactNode; + /** Button title */ + buttonText?: React.ReactNode; + /** Popconfirm ok button title */ + okText?: string; + /** Popconfirm cancel button title */ + cancelText?: string; + /** Arco button props */ + buttonProps?: ButtonProps; + /** Arco popconfirmutton props */ + popconfirmProps?: PopconfirmProps; +} + +const ButtonWithPopconfirm: FC = ({ + title = i18n.t('msg_quit_warning'), + buttonText = i18n.t('cancel'), + okText = i18n.t('submit'), + cancelText = i18n.t('cancel'), + onCancel, + onConfirm, + buttonProps, + popconfirmProps, +}) => { + return ( + + + + ); +}; + +export default ButtonWithPopconfirm; diff --git a/web_console_v2/client/src/components/CheckboxWithPopconfirm/index.tsx b/web_console_v2/client/src/components/CheckboxWithPopconfirm/index.tsx new file mode 100644 index 000000000..8070b5664 --- /dev/null +++ b/web_console_v2/client/src/components/CheckboxWithPopconfirm/index.tsx @@ -0,0 +1,74 @@ +/* istanbul ignore file */ + +import React, { FC, useState } from 'react'; + +import { Checkbox, Popconfirm } from '@arco-design/web-react'; + +import { CheckboxProps } from '@arco-design/web-react/es/Checkbox'; + +type Props = { + /** Popconfirm title */ + title?: string; + /** Checkbox text */ + text?: string; + /** Popconfirm cancel button title */ + cancelText?: string; + /** Popconfirm ok button title */ + okText?: string; + /** Checkbox disabled */ + disabled?: boolean; + /** Checkbox value */ + value?: boolean; + onChange?: (val: boolean) => void; +} & CheckboxProps; +const CheckboxWithPopconfirm: FC = ({ + value, + onChange, + title, + text, + disabled, + cancelText = '取消', + okText = '确认', + ...props +}) => { + const [isShowCheckboxPopconfirm, setIsShowCheckboxPopconfirm] = useState(false); + + return ( + <> + + + {text} + + + + ); + function onCheckboxClick(e: any) { + if (disabled) { + return; + } + + if (value) { + onChange && onChange(false); + } else { + setIsShowCheckboxPopconfirm(true); + } + } + + function onCheckboxPopconfirmConfirm() { + setIsShowCheckboxPopconfirm(false); + onChange && onChange(true); + } + function onCheckboxPopconfirmCancel() { + setIsShowCheckboxPopconfirm(false); + onChange && onChange(false); + } +}; + +export default CheckboxWithPopconfirm; diff --git a/web_console_v2/client/src/components/CheckboxWithTooltip/index.tsx b/web_console_v2/client/src/components/CheckboxWithTooltip/index.tsx new file mode 100644 index 000000000..b2fff16b7 --- /dev/null +++ b/web_console_v2/client/src/components/CheckboxWithTooltip/index.tsx @@ -0,0 +1,35 @@ +/* istanbul ignore file */ + +import React, { FC } from 'react'; +import { Checkbox, Tooltip } from '@arco-design/web-react'; + +import { CheckboxProps } from '@arco-design/web-react/es/Checkbox'; + +type Props = { + /** Tooltip title */ + tip?: string; + /** Checkbox text */ + text?: string; + /** Checkbox value */ + value?: boolean; + onChange?: (val: boolean) => void; +} & CheckboxProps; +const CheckboxWithTooltip: FC = ({ value, onChange, tip, text, ...props }) => { + return ( + <> + + { + onChange?.(checked); + }} + checked={value} + {...props} + > + {text} + + + + ); +}; + +export default CheckboxWithTooltip; diff --git a/web_console_v2/client/src/components/CodeEditor/__mocks__/index.tsx b/web_console_v2/client/src/components/CodeEditor/__mocks__/index.tsx new file mode 100644 index 000000000..9d8b86297 --- /dev/null +++ b/web_console_v2/client/src/components/CodeEditor/__mocks__/index.tsx @@ -0,0 +1,109 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import { noop } from 'lodash-es'; + +import { EditorProps, Monaco } from '@monaco-editor/react'; +import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; + +export const VS_DARK_COLOR = '#1e1e1e'; + +export enum Action { + Save = 'code_editor_action_save', +} + +export type CodeEditorProps = Omit & { + /** Code text */ + value?: string; + onChange?: (value: string) => void; + language?: 'json' | 'python' | 'javascript' | 'java' | 'go'; + isReadOnly?: boolean; + /** Get editor/monaco instance on mount */ + getInstance?: (editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) => void; +}; + +const CodeEditor: FC = ({ value, onChange, getInstance, isReadOnly }) => { + const keyToModelMap = useRef<{ + [key: string]: { + code: string; + dispose: () => void; + }; + }>({}); + const $input = useRef(null); + + const [innerValue, setInnerValue] = useState(value); + + const isControlled = typeof value === 'string'; + + useEffect(() => { + if (isControlled) { + setInnerValue(value); + } + }, [value, isControlled]); + + useEffect(() => { + getInstance?.( + { + saveViewState: noop as any, + restoreViewState: noop as any, + setModel: (model: any) => { + const code = model.code; + if (isControlled) { + setInnerValue(code); + } else { + $input.current!.value = code; + } + }, + } as any, + { + Uri: { + file: (key: string) => key, + }, + editor: { + getModel: (key: string) => keyToModelMap.current[key] ?? null, + createModel: (code: string, language: string, uri: string) => { + if (isControlled) { + setInnerValue(code); + } else { + $input.current!.value = code; + } + + const model = { + code, + dispose: () => { + delete keyToModelMap.current[uri]; + if (Object.keys(keyToModelMap.current).length === 0) { + if (isControlled) { + setInnerValue(''); + } else { + $input.current!.value = ''; + } + } + }, + }; + keyToModelMap.current[uri] = model; + return model; + }, + }, + } as any, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + { + const { value, fileKey } = event.target; + + const model = keyToModelMap.current[fileKey]; + if (model) { + model.code = value; + } + onChange?.(value); + }} + value={isControlled ? innerValue : undefined} + data-testid="input-code-editor" + disabled={isReadOnly} + /> + ); +}; +export default CodeEditor; diff --git a/web_console_v2/client/src/components/CodeEditorDrawer/index.tsx b/web_console_v2/client/src/components/CodeEditorDrawer/index.tsx new file mode 100644 index 000000000..b329e3e3e --- /dev/null +++ b/web_console_v2/client/src/components/CodeEditorDrawer/index.tsx @@ -0,0 +1,122 @@ +/* istanbul ignore file */ + +import React, { useState } from 'react'; +import ReactDOM from 'react-dom'; + +import { Drawer, DrawerProps, Button } from '@arco-design/web-react'; +import CodeEditor, { CodeEditorProps } from 'components/CodeEditor'; + +export type CodeEditorDrawerProps = { + visible: boolean; + value?: string; + onChange?: (val: string) => void; + isReadOnly?: boolean; + language?: string; + theme?: string; + drawerWidth?: string | number; + container?: HTMLElement; + onClose?: (params?: any) => void; + codeEditorProps?: CodeEditorProps; +} & Pick; + +export type ButtonProps = Omit & { + text?: string; + children?: React.ReactNode; +}; +export type ShowProps = Omit; + +function CodeEditorDrawer({ + title, + value, + visible, + container, + language = 'json', + isReadOnly = true, + theme = 'grey', + drawerWidth = '50%', + codeEditorProps, + afterClose, + onClose, + onChange, +}: CodeEditorDrawerProps) { + return ( + container || window.document.body} + > + + + ); +} + +function _Button({ text = '查看', children, ...restProps }: ButtonProps) { + const [visible, setVisible] = useState(false); + return ( + <> + + setVisible(false)} /> + + ); +} + +function show(props: ShowProps) { + const key = `__code_editor_drawer_${Date.now()}__`; + const container = window.document.createElement('div'); + container.style.zIndex = '1000'; + window.document.body.appendChild(container); + + _hide(props); + _show(props); + + function _renderComp(props: CodeEditorDrawerProps) { + ReactDOM.render( + React.createElement(CodeEditorDrawer, { + ...props, + key, + container, + onClose() { + props.onClose?.(); + _hide(props); + }, + }), + container, + ); + } + + function _hide(props: ShowProps) { + _renderComp({ + ...props, + visible: false, + afterClose() { + window.document.body.removeChild(container); + }, + }); + } + + function _show(props: ShowProps) { + _renderComp({ + ...props, + visible: true, + }); + } +} + +CodeEditorDrawer.show = show; +CodeEditorDrawer.Button = _Button; + +export default CodeEditorDrawer; diff --git a/web_console_v2/client/src/components/CodeEditorFormButton/index.tsx b/web_console_v2/client/src/components/CodeEditorFormButton/index.tsx new file mode 100644 index 000000000..8f07a9611 --- /dev/null +++ b/web_console_v2/client/src/components/CodeEditorFormButton/index.tsx @@ -0,0 +1,69 @@ +/* istanbul ignore file */ + +import React, { FC, useState } from 'react'; +import styled from 'styled-components'; + +import { Button } from '@arco-design/web-react'; +import { IconCodeSquare } from '@arco-design/web-react/icon'; +import { FileData } from 'components/FileExplorer'; +import CodeEditorModal, { Props as CodeEditorModalProps } from 'components/CodeEditorModal'; + +export type Props = { + value?: FileData; + onChange?: (val?: FileData) => any; + disabled?: boolean; + buttonText?: string; + buttonType?: 'default' | 'primary' | 'secondary' | 'dashed' | 'text' | 'outline'; + buttonIcon?: React.ReactNode; + buttonStyle?: React.CSSProperties; +} & Partial; + +const Container = styled.div``; + +const CodeEditorFormButton: FC = ({ + value, + onChange, + disabled, + buttonStyle = {}, + buttonText = '打开代码编辑器', + buttonType = 'default', + buttonIcon = , + title = '代码编辑器', + ...resetProps +}) => { + const [isShowCodeEditorModal, setIsShowCodeEditorModal] = useState(false); + + return ( + + + + + ); + + function onButtonClick() { + setIsShowCodeEditorModal(true); + } + function onCloseButtonClick() { + setIsShowCodeEditorModal(false); + } + function onSave(fileData: FileData) { + onChange && onChange(fileData); + } +}; + +export default CodeEditorFormButton; diff --git a/web_console_v2/client/src/components/CodeEditorModal/Tab.tsx b/web_console_v2/client/src/components/CodeEditorModal/Tab.tsx new file mode 100644 index 000000000..13e1fb807 --- /dev/null +++ b/web_console_v2/client/src/components/CodeEditorModal/Tab.tsx @@ -0,0 +1,104 @@ +import React, { FC } from 'react'; +import styled from 'styled-components'; +import { Tooltip } from '@arco-design/web-react'; + +import { MixinEllipsis } from 'styles/mixins'; + +import { Close } from 'components/IconPark'; + +const IconContainer = styled.div` + display: inline-block; + margin-right: 8px; + overflow: hidden; +`; +const Name = styled.div` + ${MixinEllipsis(80, '%')} + display: inline-block; + font-size: 12px; + font-weight: 400; + color: var(--font-color); +`; +const StyledClose = styled(Close)` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); +`; + +const Container = styled.div` + --bg-color: #f2f3f8; + --bg-color-active: #fff; + --icon-color: #86909c; + --icon-color-active: #468dff; + --font-color: #1d2129; + --border-color: #e5e8ee; + --border-color-active: #1678ff; + + position: relative; + display: inline-block; + height: 36px; + line-height: 36px; + padding: 0 32px 0 12px; + + min-width: 80px; + max-width: 200px; + background-color: var(--bg-color); + border-right: 1px solid var(--border-color); + border-bottom: 1px solid transparent; + cursor: pointer; + + &[data-is-active='true'] { + background-color: var(--bg-color-active); + border-bottom: 1px solid var(--border-color-active); + + ${IconContainer} { + color: var(--icon-color-active); + } + ${Name} { + font-weight: 500; + } + } +`; + +type Props = { + theme?: string; + isActive?: boolean; + icon?: React.ReactNode; + fileName?: string; + fullPathFileName?: string; + onClick?: () => void; + onClose?: () => void; +}; + +const Tab: FC = ({ + theme, + isActive = false, + icon, + fileName = '', + fullPathFileName = '', + onClick, + onClose, +}) => { + return ( + + + {icon && {icon}} + {fileName || ''} + { + event.stopPropagation(); + onClose && onClose(); + }} + /> + + + ); +}; + +export default Tab; diff --git a/web_console_v2/client/src/components/CodeEditorModal/index.integration.test.tsx b/web_console_v2/client/src/components/CodeEditorModal/index.integration.test.tsx new file mode 100644 index 000000000..3c759d0b4 --- /dev/null +++ b/web_console_v2/client/src/components/CodeEditorModal/index.integration.test.tsx @@ -0,0 +1,1031 @@ +import React from 'react'; +import { render, fireEvent, waitFor, RenderResult, within } from '@testing-library/react'; +import mock from 'xhr-mock'; + +import { waitForLoadingEnd } from 'shared/testUtils'; +import * as api from 'services/algorithm'; +import * as helpers from 'shared/helpers'; +import { getFileInfoByFilePath } from 'shared/file'; + +import { BaseCodeEditor } from './index'; +import { FileData } from 'components/FileExplorer'; + +import { FileTreeNode, FileContent } from 'typings/algorithm'; + +jest.mock('components/CodeEditor'); +jest.mock('services/algorithm'); + +const mockApi = api as jest.Mocked; + +const testTreeList: FileTreeNode[] = [ + { + filename: 'owner.py', + path: 'owner.py', + size: 17, + mtime: 1637141275, + is_directory: false, + files: [], + }, + { + filename: 't1.yaml', + path: 't1.yaml', + size: 17, + mtime: 1637141275, + is_directory: false, + files: [], + }, + { + filename: 't2.config', + path: 't2.config', + size: 17, + mtime: 1637141275, + is_directory: false, + files: [], + }, + { + filename: 't3.json', + path: 't3.json', + size: 17, + mtime: 1637141275, + is_directory: false, + files: [], + }, + { + filename: 'leader', + path: 'leader', + size: 96, + mtime: 1637141275, + is_directory: true, + files: [ + { + filename: 'main.py', + path: 'leader/main.py', + size: 17, + mtime: 1637141275, + is_directory: false, + files: [], + }, + + { + filename: 'test', + path: 'leader/test', + size: 96, + mtime: 1637141275, + is_directory: true, + files: [ + { + filename: 't1.js', + path: 'leader/test/t1.js', + size: 17, + mtime: 1637141275, + is_directory: false, + files: [], + }, + ], + }, + ], + }, +]; + +const testFiledata: FileData = { + 'owner.py': '# coding: utf-8', + 'leader/main.py': 'I am leader/main.py', + 'leader/test/t1.js': 'var a = 1;', + 't1.yaml': `# Get Started with Codebase CI`, + 't2.config': '# coding: utf-8', + 't3.json': '{ "a":1 }', +}; + +const testFileKey = 'owner.py'; +const testFileContent = '# coding: utf-8'; + +const asyncProps = { + id: 3, + isAsyncMode: true, + getFileTreeList: () => api.fetchAlgorithmProjectFileTreeList(1).then((res) => res.data), + getFile: (filePath: string) => + api + .fetchAlgorithmProjectFileContentDetail(1, { + path: filePath, + }) + .then((res) => res.data.content), +}; + +function _getFileName(fileKey: string) { + const { fileName } = getFileInfoByFilePath(fileKey); + return fileName; +} + +describe('', () => { + let wrapper: RenderResult; + let $createRootFileBtn: HTMLElement; + let $createRootFolderBtn: HTMLElement; + let $editBtn: HTMLElement; + let $input: HTMLInputElement; + let $codeEditorInput: HTMLInputElement; + let $tabList: HTMLElement; + let $treeContainer: HTMLElement; + let $moreActionBtn: HTMLElement; + let $deleteBtn: HTMLElement; + let $fileInput: HTMLElement; + + let giveWeakRandomKeySpy: jest.SpyInstance; + const tempRandomKey = 'tempFile'; + let tempNodeKey = ''; + + async function _createFile(fileKey: string, isAsyncMode = false, isAPISuccess = true) { + return _createNode(fileKey, true, isAsyncMode, isAPISuccess); + } + async function _createFolder(folderKey: string, isAsyncMode = false, isAPISuccess = true) { + return _createNode(folderKey, false, isAsyncMode, isAPISuccess); + } + async function _createNode( + nodeKey: string, + isFile = true, + isAsyncMode = false, + isAPISuccess = true, + ) { + let $createFileBtn: HTMLElement | null; + let $createFolderBtn: HTMLElement | null; + + const { parentPath, fileName } = getFileInfoByFilePath(nodeKey); + + // Get existed button of creating through parent nodes + if (isFile) { + $createFileBtn = parentPath + ? wrapper.getByTestId(`btn-create-file-${parentPath}`) + : $createRootFileBtn; + $createFolderBtn = parentPath + ? wrapper.getByTestId(`btn-create-folder-${parentPath}`) + : $createRootFolderBtn; + } else { + $createFileBtn = wrapper.queryByTestId(`btn-create-file-${parentPath}`); + $createFolderBtn = wrapper.queryByTestId(`btn-create-folder-${parentPath}`); + + while (!$createFileBtn || !$createFolderBtn) { + const { parentPath: innerParentPath } = getFileInfoByFilePath(parentPath); + + if (!innerParentPath) { + $createFileBtn = $createRootFileBtn; + $createFolderBtn = $createRootFolderBtn; + } else { + $createFileBtn = wrapper.queryByTestId(`btn-create-file-${innerParentPath}`); + $createFolderBtn = wrapper.queryByTestId(`btn-create-folder-${innerParentPath}`); + } + } + } + + // Click create button to enter focus mode + fireEvent.click(isFile ? $createFileBtn! : $createFolderBtn!); + + await waitFor(() => { + $input = wrapper.queryByTestId( + `input-${parentPath ? `${parentPath}/` : ''}${tempNodeKey}`, + ) as HTMLInputElement; + expect($input).toBeInTheDocument(); + }); + + // Only 1 input dom should be rendered + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(1); + // Default value is empty string + expect($input.value).toBe(''); + + // Trigger blur event to save the value + fireEvent.blur($input, { target: { value: fileName } }); + + expect($input.value).toBe(fileName); + + if (isAsyncMode) { + await waitForLoadingEnd(wrapper); + } + + await waitFor(() => { + expect( + wrapper.queryByTestId(`input-${parentPath ? `${parentPath}/` : ''}${tempNodeKey}`), + ).not.toBeInTheDocument(); + if (!isAsyncMode || isAPISuccess) { + expect(wrapper.queryByTestId(nodeKey)).toBeInTheDocument(); + } + }); + + if (isFile && (!isAsyncMode || isAPISuccess)) { + expect(wrapper.container.querySelectorAll('.arco-tree-node-selected').length).toBe(1); + } + } + async function _selectNode(nodeKey: string, isAsyncMode = false) { + const $fileNode = wrapper.getByTestId(nodeKey); + fireEvent.click(within($fileNode).getByText(_getFileName(nodeKey))); + + try { + if (isAsyncMode) { + await waitForLoadingEnd(wrapper); + } + } catch (error) { + // If there is no loading, do nothing + } + + expect(wrapper.container.querySelectorAll('.arco-tree-node-selected').length).toBe(1); + } + async function _renameNode( + nodeKey: string, + newNodeName: string, + isAsyncMode = false, + isAPISuccess = true, + ) { + $editBtn = wrapper.getByTestId(`btn-edit-${nodeKey}`); + $input = wrapper.queryByTestId(`input-${nodeKey}`) as HTMLInputElement; + + const { fileName } = getFileInfoByFilePath(nodeKey); + + // Input should not be rendered + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(0); + expect($input).not.toBeInTheDocument(); + + // Click edit button to enter focus mode + fireEvent.click($editBtn); + + await waitFor(() => { + $input = wrapper.queryByTestId(`input-${nodeKey}`) as HTMLInputElement; + expect($input).toBeInTheDocument(); + }); + + // Only 1 input dom should be rendered + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(1); + // Default value is file name + expect($input.value).toBe(fileName); + + // Rename fileName to newNodeName + fireEvent.change($input, { target: { value: newNodeName } }); + expect($input.value).toBe(newNodeName); + + // Trigger blur event + fireEvent.blur($input, { target: { value: newNodeName } }); + + if (isAsyncMode) { + await waitForLoadingEnd(wrapper); + } + + await waitFor(() => { + expect(wrapper.queryByTestId(`input-${nodeKey}`)).not.toBeInTheDocument(); + }); + + if (!isAsyncMode || isAPISuccess) { + await waitFor(() => { + expect(within($treeContainer).queryByText(newNodeName)).toBeInTheDocument(); + }); + expect(within($treeContainer).queryByText(fileName)).not.toBeInTheDocument(); + } else { + await waitFor(() => { + expect(within($treeContainer).queryByText(fileName)).toBeInTheDocument(); + }); + expect(within($treeContainer).queryByText(newNodeName)).not.toBeInTheDocument(); + } + } + async function _deleteNode(fileKey: string, isAsyncMode = false, isAPISuccess = true) { + const $fileNode = wrapper.getByTestId(fileKey); + + $moreActionBtn = within($fileNode).getByTestId(`btn-more-actions`); + + if (!wrapper.queryByTestId(`btn-more-acitons-delete-${fileKey}`)) { + // Click more action button to show delete button + fireEvent.click($moreActionBtn); + } + + // Wait for delete button to be visible + await waitFor(() => + expect(wrapper.getByTestId(`btn-more-acitons-delete-${fileKey}`)).toBeVisible(), + ); + + // Find delete button + $deleteBtn = wrapper.getByTestId(`btn-more-acitons-delete-${fileKey}`)!; + + // Click delete button + fireEvent.click($deleteBtn); + + if (isAsyncMode) { + await waitForLoadingEnd(wrapper); + } + + if (!isAsyncMode || isAPISuccess) { + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-${fileKey}`)).not.toBeInTheDocument(); + expect(wrapper.queryByTestId(fileKey)).not.toBeInTheDocument(); + }); + } else { + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-${fileKey}`)).toBeInTheDocument(); + expect(wrapper.queryByTestId(fileKey)).toBeInTheDocument(); + }); + } + } + async function _uploadFile( + fileKey: string, + fileContent: string, + isAsyncMode = false, + isAPISuccess = true, + ) { + const { fileName } = getFileInfoByFilePath(fileKey); + const mockFile = new File([fileContent], fileName, { type: 'text/plain' }); + + fireEvent.change($fileInput, { target: { files: [mockFile] } }); + + try { + if (isAsyncMode) { + await waitForLoadingEnd(wrapper); + } + } catch (error) { + // If there is no loading, do nothing + } + + if (!isAsyncMode || isAPISuccess) { + await waitFor(() => { + expect(wrapper.queryByTestId(fileKey)).toBeInTheDocument(); + }); + } else { + await waitFor(() => { + expect(wrapper.queryByTestId(fileKey)).not.toBeInTheDocument(); + }); + } + } + + async function _closeTab(fileKey: string) { + fireEvent.click(wrapper.getByTestId(`tab-btn-close-${fileKey}`)); + + await _waitForTabHidden(fileKey); + } + + async function _editCodeEditorValue(fileKey: string, newValue: string) { + fireEvent.change($codeEditorInput, { target: { value: newValue, fileKey }, fileKey }); + await waitFor(() => { + expect($codeEditorInput.value).toBe(newValue); + }); + } + async function _waitForTabShow(fileKey: string, isActive: boolean = true) { + await waitFor(() => wrapper.getByTestId(`tab-${fileKey}`)); + expect(wrapper.getByTestId(`tab-${fileKey}`)).toHaveAttribute( + 'data-is-active', + isActive ? 'true' : 'false', + ); + } + async function _waitForTabHidden(fileKey: string) { + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-${fileKey}`)).not.toBeInTheDocument(); + }); + } + describe('isAsyncMode = false', () => { + beforeEach(async () => { + giveWeakRandomKeySpy = jest.spyOn(helpers, 'giveWeakRandomKey').mockImplementation(() => { + return tempRandomKey; + }); + tempNodeKey = `${tempRandomKey}`; + + wrapper = render( + , + ); + expect(wrapper.getByText('I am title')).toBeInTheDocument(); + + $createRootFileBtn = wrapper.getByTestId('btn-create-file-on-root'); + $createRootFolderBtn = wrapper.getByTestId('btn-create-folder-on-root'); + $tabList = wrapper.getByTestId('tab-list'); + $fileInput = wrapper.container.querySelector('input[type="file"]')!; + + expect($createRootFileBtn).toBeInTheDocument(); + expect($createRootFolderBtn).toBeInTheDocument(); + + await waitFor(() => wrapper.getAllByText(testFileKey)[0]); // file node + $treeContainer = wrapper.container.querySelector('.arco-tree')!; + + const nodeList = wrapper.container.querySelectorAll('.arco-tree .arco-tree-node'); + expect(nodeList.length).toBe(8); + + await waitFor(() => wrapper.getByTestId(`tab-${testFileKey}`)); + + expect(wrapper.getByTestId(`tab-${testFileKey}`)).toHaveAttribute('data-is-active', 'true'); + + $codeEditorInput = wrapper.getByTestId('input-code-editor') as HTMLInputElement; + expect($codeEditorInput.value).toBe(testFileContent); + }); + + afterEach(() => { + giveWeakRandomKeySpy.mockRestore(); + }); + + it(` + 1. create new file(newFile.js) on root + 2. edit the new file(newFile.js) content + 3. select the other file(t1.yaml) to trigger tab change, and save the value that belong to the new file + 4. edit the other file(t1.yaml) content + 5. select new file(newFile.js) again to trigger tab change, and save the value that belong to the other file + 6. select first file(owner.py) again to trigger tab change, and save the value that belong to the first file + `, async () => { + await _createFile('newFile.js'); + await _waitForTabShow('newFile.js'); + await _editCodeEditorValue('newFile.js', 'I am newFile.js'); + await _selectNode('t1.yaml'); + await _waitForTabShow('t1.yaml'); + expect($codeEditorInput.value).toBe('# Get Started with Codebase CI'); + await _editCodeEditorValue('t1.yaml', 'I am t1.yaml'); + await _selectNode('newFile.js'); + await _waitForTabShow('newFile.js'); + expect($codeEditorInput.value).toBe('I am newFile.js'); + await _selectNode(testFileKey); + await _waitForTabShow(testFileKey); + expect($codeEditorInput.value).toBe(testFileContent); + }); + + it(` + 1. select file(t1.yaml) + 2. select file(t2.config) + 3. select file(t3.json) + 4. select file(t2.config) + 5. close file tab(t3.json) + 6. close file tab(t2.config) + 6. close file tab(t1.yaml) + 7. close file tab(owner.py) + `, async () => { + expect($tabList.children.length).toBe(1); + await _selectNode('t1.yaml'); + await _waitForTabShow('t1.yaml'); + expect($codeEditorInput.value).toBe('# Get Started with Codebase CI'); + expect($tabList.children.length).toBe(2); + await _selectNode('t2.config'); + await _waitForTabShow('t2.config'); + expect($codeEditorInput.value).toBe('# coding: utf-8'); + expect($tabList.children.length).toBe(3); + await _selectNode('t3.json'); + await _waitForTabShow('t3.json'); + expect($codeEditorInput.value).toBe('{ "a":1 }'); + expect($tabList.children.length).toBe(4); + await _selectNode('t2.config'); + await _waitForTabShow('t2.config'); + expect($tabList.children.length).toBe(4); + expect($codeEditorInput.value).toBe('# coding: utf-8'); + await _closeTab('t3.json'); + expect($tabList.children.length).toBe(3); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect(wrapper.getByTestId(`tab-t2.config`)).toHaveAttribute('data-is-active', 'true'); + expect($codeEditorInput.value).toBe('# coding: utf-8'); + await _closeTab('t2.config'); + expect($tabList.children.length).toBe(2); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect(wrapper.getByTestId(`tab-owner.py`)).toHaveAttribute('data-is-active', 'true'); + expect($codeEditorInput.value).toBe(testFileContent); + await _closeTab('t1.yaml'); + expect($tabList.children.length).toBe(1); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect(wrapper.getByTestId(`tab-owner.py`)).toHaveAttribute('data-is-active', 'true'); + expect($codeEditorInput.value).toBe(testFileContent); + expect($codeEditorInput).not.toBeDisabled(); + await _closeTab(testFileKey); + expect($tabList.children.length).toBe(0); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(0); + expect($codeEditorInput.value).toBe(''); + expect($codeEditorInput).toBeDisabled(); + }); + + describe('Handle folder', () => { + beforeEach(async () => { + await _selectNode('leader/main.py'); + await _waitForTabShow('leader/main.py'); + // There are 3 nodes(1 folder + 2 file) in this folder before create + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(3); + + await _createFile('leader/newFile.js'); + await _waitForTabShow('leader/newFile.js'); + await _editCodeEditorValue('leader/newFile.js', 'I am leader/newFile.js'); + // There are 4 nodes(1 folder + 3 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(4); + + await _createFolder('leader/newFolder'); + // There are 5 nodes(2 folder + 3 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(5); + + await _selectNode('leader/newFolder'); + await _createFile('leader/newFolder/newFile2.js'); + await _waitForTabShow('leader/newFolder/newFile2.js'); + await _editCodeEditorValue( + 'leader/newFolder/newFile2.js', + 'I am leader/newFolder/newFile2.js', + ); + // There are 6 nodes(2 folder + 4 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + + // There are 4 tab(owner.py, leader/main.py, leader/newFile.js, leader/newFolder/newFile2.js) + expect($tabList.children.length).toBe(4); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect($codeEditorInput.value).toBe('I am leader/newFolder/newFile2.js'); + }); + + it(` + 1. select file(leader/main.py) + 2. create new file(leader/newFile.js) + 3. create new folder(leader/newFolder) + 4. select folder(leader/newFolder) + 5. create new file(leader/newFolder/newFile2.js) + 6. select leader/newFile.js + 7. select leader/main.py + 8. select owner.py + `, async () => { + await _selectNode('leader/newFile.js'); + await _waitForTabShow('leader/newFile.js'); + expect($codeEditorInput.value).toBe('I am leader/newFile.js'); + expect($tabList.children.length).toBe(4); + + await _selectNode('leader/main.py'); + await _waitForTabShow('leader/main.py'); + expect($codeEditorInput.value).toBe('I am leader/main.py'); + expect($tabList.children.length).toBe(4); + + await _selectNode(testFileKey); + await _waitForTabShow(testFileKey); + expect($codeEditorInput.value).toBe(testFileContent); + expect($tabList.children.length).toBe(4); + }); + + it(` + 1. select file(leader/main.py) + 2. create new file(leader/newFile.js) + 3. create new folder(leader/newFolder) + 4. select folder(leader/newFolder) + 5. create new file(leader/newFolder/newFile2.js) + 6. rename file(leader/newFile.js) => file(leader/renameNewFile.js) + 7. rename file(leader/newFolder/newFile2.js) => file(leader/newFolder/renameNewFile2.js) + 8. select file(leader/newFolder/renameNewFile2.js) + 9. rename folder(leader/newFolder) => folder(leader/renameNewFolder) + `, async () => { + // There are 6 nodes(2 folder + 4 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + expect($tabList.children.length).toBe(4); + + await _renameNode('leader/newFile.js', 'renameNewFile.js'); + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-leader/newFile.js`)).not.toBeInTheDocument(); + expect(wrapper.queryByTestId(`leader/renameNewFile.js`)).toBeInTheDocument(); + }); + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + expect($tabList.children.length).toBe(3); + + await _renameNode('leader/newFolder/newFile2.js', 'renameNewFile2.js'); + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-leader/newFolder/newFile2.js`)).not.toBeInTheDocument(); + expect(wrapper.queryByTestId(`leader/newFolder/renameNewFile2.js`)).toBeInTheDocument(); + }); + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + expect($tabList.children.length).toBe(2); + + await _selectNode('leader/newFolder/renameNewFile2.js'); + await _waitForTabShow('leader/newFolder/renameNewFile2.js'); + expect($tabList.children.length).toBe(3); + expect($codeEditorInput.value).toBe('I am leader/newFolder/newFile2.js'); + + await _renameNode('leader/newFolder', 'renameNewFolder'); + expect($tabList.children.length).toBe(2); + + await waitFor(() => { + expect(wrapper.queryByTestId(`leader/newFolder`)).not.toBeInTheDocument(); + expect(wrapper.queryByTestId(`leader/renameNewFolder`)).toBeInTheDocument(); + }); + await _selectNode('leader/renameNewFolder'); // select folder to expand folder + + await waitFor(() => + expect( + wrapper.queryByTestId(`leader/renameNewFolder/renameNewFile2.js`), + ).toBeInTheDocument(), + ); + // There are 6 nodes(2 folder + 4 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + }); + }); + }); + + describe('isAsyncMode = true', () => { + beforeEach(async () => { + mockApi.fetchAlgorithmProjectFileTreeList.mockResolvedValue({ + data: testTreeList, + }); + mockApi.fetchAlgorithmProjectFileContentDetail.mockImplementation((id, { path }) => { + const { parentPath, fileName } = getFileInfoByFilePath(path); + return Promise.resolve({ + data: { + path: parentPath, + filename: fileName, + content: `I am ${path}`, + } as FileContent, + }); + }); + mockApi.createOrUpdateAlgorithmProjectFileContent.mockImplementation( + (id, { path, filename, is_directory, file }) => + Promise.resolve({ + data: { + path, + filename, + content: file, + } as FileContent, + }), + ); + mockApi.renameAlgorithmProjectFileContent.mockImplementation((id, { path, dest }) => + Promise.resolve(null), + ); + mockApi.deleteAlgorithmProjectFileContent.mockImplementation((id, { path }) => + Promise.resolve(null), + ); + + giveWeakRandomKeySpy = jest.spyOn(helpers, 'giveWeakRandomKey').mockImplementation(() => { + return tempRandomKey; + }); + tempNodeKey = `${tempRandomKey}`; + + wrapper = render( + , + ); + expect(wrapper.getByText('I am title')).toBeInTheDocument(); + + $createRootFileBtn = wrapper.getByTestId('btn-create-file-on-root'); + $createRootFolderBtn = wrapper.getByTestId('btn-create-folder-on-root'); + $tabList = wrapper.getByTestId('tab-list'); + $fileInput = wrapper.container.querySelector('input[type="file"]')!; + + expect($createRootFileBtn).toBeInTheDocument(); + expect($createRootFolderBtn).toBeInTheDocument(); + + await waitForLoadingEnd(wrapper); + + await waitFor(() => wrapper.getAllByText(testFileKey)[0]); // file node + $treeContainer = wrapper.container.querySelector('.arco-tree')!; + + const nodeList = wrapper.container.querySelectorAll('.arco-tree .arco-tree-node'); + expect(nodeList.length).toBe(8); + + await waitFor(() => wrapper.getByTestId(`tab-${testFileKey}`)); + + expect(wrapper.getByTestId(`tab-${testFileKey}`)).toHaveAttribute('data-is-active', 'true'); + + $codeEditorInput = wrapper.getByTestId('input-code-editor') as HTMLInputElement; + expect($codeEditorInput.value).toBe(`I am ${testFileKey}`); + }); + + afterEach(() => { + giveWeakRandomKeySpy.mockRestore(); + }); + + it(` + 1. create new file(newFile.js) on root + 2. edit the new file(newFile.js) content + 3. select the other file(t1.yaml) to trigger tab change, and save the value that belong to the new file + 4. edit the other file(t1.yaml) content + 5. select new file(newFile.js) again to trigger tab change, and save the value that belong to the other file + 6. select first file(owner.py) again to trigger tab change, and save the value that belong to the first file + `, async () => { + await _createFile('newFile.js', true); + await _waitForTabShow('newFile.js'); + await _editCodeEditorValue('newFile.js', 'Edit: I am newFile.js'); + await _selectNode('t1.yaml', true); + await _waitForTabShow('t1.yaml'); + expect($codeEditorInput.value).toBe('I am t1.yaml'); + await _editCodeEditorValue('t1.yaml', 'Edit: I am t1.yaml'); + await _selectNode('newFile.js', true); + await _waitForTabShow('newFile.js'); + expect($codeEditorInput.value).toBe('Edit: I am newFile.js'); + await _selectNode(testFileKey, true); + await _waitForTabShow(testFileKey); + expect($codeEditorInput.value).toBe(`I am ${testFileKey}`); + }); + + it(` + 1. select file(t1.yaml) + 2. select file(t2.config) + 3. select file(t3.json) + 4. select file(t2.config) + 5. close file tab(t3.json) + 6. close file tab(t2.config) + 6. close file tab(t1.yaml) + 7. close file tab(owner.py) + `, async () => { + expect($tabList.children.length).toBe(1); + await _selectNode('t1.yaml', true); + await _waitForTabShow('t1.yaml'); + expect($codeEditorInput.value).toBe('I am t1.yaml'); + expect($tabList.children.length).toBe(2); + await _selectNode('t2.config', true); + await _waitForTabShow('t2.config'); + expect($codeEditorInput.value).toBe('I am t2.config'); + expect($tabList.children.length).toBe(3); + await _selectNode('t3.json', true); + await _waitForTabShow('t3.json'); + expect($codeEditorInput.value).toBe('I am t3.json'); + expect($tabList.children.length).toBe(4); + await _selectNode('t2.config', true); + await _waitForTabShow('t2.config'); + expect($tabList.children.length).toBe(4); + expect($codeEditorInput.value).toBe('I am t2.config'); + await _closeTab('t3.json'); + expect($tabList.children.length).toBe(3); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect(wrapper.getByTestId(`tab-t2.config`)).toHaveAttribute('data-is-active', 'true'); + expect($codeEditorInput.value).toBe('I am t2.config'); + await _closeTab('t2.config'); + expect($tabList.children.length).toBe(2); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect(wrapper.getByTestId(`tab-owner.py`)).toHaveAttribute('data-is-active', 'true'); + expect($codeEditorInput.value).toBe('I am owner.py'); + await _closeTab('t1.yaml'); + expect($tabList.children.length).toBe(1); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect(wrapper.getByTestId(`tab-owner.py`)).toHaveAttribute('data-is-active', 'true'); + expect($codeEditorInput.value).toBe('I am owner.py'); + expect($codeEditorInput).not.toBeDisabled(); + await _closeTab(testFileKey); + expect($tabList.children.length).toBe(0); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(0); + expect($codeEditorInput.value).toBe(''); + expect($codeEditorInput).toBeDisabled(); + }); + + it(` + 1. upload file(uploadFile.js) + 2. select file(uploadFile.js) + 3. close file tab(uploadFile.js) + `, async () => { + expect($tabList.children.length).toBe(1); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe( + 8, + ); + + mock.setup(); + mock.post('/api/v2/algorithm_projects/3/files', { + status: 200, + reason: 'ok', + body: JSON.stringify({ + data: { + path: '', + filename: 'uploadFile.js', + }, + }), + }); + + await _uploadFile('uploadFile.js', 'I am uploadFile.js', true, true); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe( + 9, + ); + expect($tabList.children.length).toBe(1); + await _selectNode('uploadFile.js'); + await _waitForTabShow('uploadFile.js'); + expect($tabList.children.length).toBe(2); + expect($codeEditorInput.value).toBe('I am uploadFile.js'); + await _closeTab('uploadFile.js'); + expect($tabList.children.length).toBe(1); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect(wrapper.getByTestId(`tab-owner.py`)).toHaveAttribute('data-is-active', 'true'); + expect($codeEditorInput.value).toBe('I am owner.py'); + + mock.teardown(); + }); + + describe('Handle folder', () => { + beforeEach(async () => { + await _selectNode('leader/main.py', true); + await _waitForTabShow('leader/main.py'); + // There are 3 nodes(1 folder + 2 file) in this folder before create + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(3); + + await _createFile('leader/newFile.js', true); + await _waitForTabShow('leader/newFile.js'); + await _editCodeEditorValue('leader/newFile.js', 'I am leader/newFile.js'); + // There are 4 nodes(1 folder + 3 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(4); + + await _createFolder('leader/newFolder', true); + // There are 5 nodes(2 folder + 3 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(5); + + await _selectNode('leader/newFolder'); + await _createFile('leader/newFolder/newFile2.js', true); + await _waitForTabShow('leader/newFolder/newFile2.js'); + await _editCodeEditorValue( + 'leader/newFolder/newFile2.js', + 'I am leader/newFolder/newFile2.js', + ); + // There are 6 nodes(2 folder + 4 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + + // There are 4 tab(owner.py, leader/main.py, leader/newFile.js, leader/newFolder/newFile2.js) + expect($tabList.children.length).toBe(4); + expect($tabList.querySelectorAll('div[data-is-active="true"]').length).toBe(1); + expect($codeEditorInput.value).toBe('I am leader/newFolder/newFile2.js'); + }); + + it(` + 1. select file(leader/main.py) + 2. create new file(leader/newFile.js) + 3. create new folder(leader/newFolder) + 4. select folder(leader/newFolder) + 5. create new file(leader/newFolder/newFile2.js) + 6. select leader/newFile.js + 7. select leader/main.py + 8. select owner.py + `, async () => { + await _selectNode('leader/newFile.js', true); + await _waitForTabShow('leader/newFile.js'); + expect($codeEditorInput.value).toBe('I am leader/newFile.js'); + expect($tabList.children.length).toBe(4); + + await _selectNode('leader/main.py', true); + await _waitForTabShow('leader/main.py'); + expect($codeEditorInput.value).toBe('I am leader/main.py'); + expect($tabList.children.length).toBe(4); + + await _selectNode(testFileKey, true); + await _waitForTabShow(testFileKey); + expect($codeEditorInput.value).toBe(`I am ${testFileKey}`); + expect($tabList.children.length).toBe(4); + }); + + it(` + 1. select file(leader/main.py) + 2. create new file(leader/newFile.js) + 3. create new folder(leader/newFolder) + 4. select folder(leader/newFolder) + 5. create new file(leader/newFolder/newFile2.js) + 6. rename file(leader/newFile.js) => file(leader/renameNewFile.js) + 7. rename file(leader/newFolder/newFile2.js) => file(leader/newFolder/renameNewFile2.js) + 8. select file(leader/newFolder/renameNewFile2.js) + 9. rename folder(leader/newFolder) => folder(leader/renameNewFolder) + `, async () => { + // There are 6 nodes(2 folder + 4 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + expect($tabList.children.length).toBe(4); + + await _renameNode('leader/newFile.js', 'renameNewFile.js'); + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-leader/newFile.js`)).not.toBeInTheDocument(); + expect(wrapper.queryByTestId(`leader/renameNewFile.js`)).toBeInTheDocument(); + }); + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + expect($tabList.children.length).toBe(3); + + await _renameNode('leader/newFolder/newFile2.js', 'renameNewFile2.js'); + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-leader/newFolder/newFile2.js`)).not.toBeInTheDocument(); + expect(wrapper.queryByTestId(`leader/newFolder/renameNewFile2.js`)).toBeInTheDocument(); + }); + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + expect($tabList.children.length).toBe(2); + + await _selectNode('leader/newFolder/renameNewFile2.js', true); + await _waitForTabShow('leader/newFolder/renameNewFile2.js'); + expect($tabList.children.length).toBe(3); + expect($codeEditorInput.value).toBe('I am leader/newFolder/newFile2.js'); + + await _renameNode('leader/newFolder', 'renameNewFolder'); + expect($tabList.children.length).toBe(2); + + await waitFor(() => { + expect(wrapper.queryByTestId(`leader/newFolder`)).not.toBeInTheDocument(); + expect(wrapper.queryByTestId(`leader/renameNewFolder`)).toBeInTheDocument(); + }); + await _selectNode('leader/renameNewFolder'); // select folder to expand folder + + await waitFor(() => + expect( + wrapper.queryByTestId(`leader/renameNewFolder/renameNewFile2.js`), + ).toBeInTheDocument(), + ); + // There are 6 nodes(2 folder + 4 file) in this folder + expect(wrapper.getAllByTestId(new RegExp(`^leader/`)).length).toBe(6); + }); + }); + + describe('Handle API response error', () => { + it(` + 1. create new file(newFile.js) on root but API response error + 2. create new file(newFile.js) on root + 3. rename file(newFile.js) => file(renameNewFile.js) but API response error + 4. rename file(newFile.js) => file(renameNewFile.js) + 5. select file(renameNewFile.js) + 6. delete file(renameNewFile.js) but API response error + 7. delete file(renameNewFile.js) + 8. upload file(uploadFile.js) on root but API response error + `, async () => { + mockApi.createOrUpdateAlgorithmProjectFileContent.mockImplementationOnce( + (id, { path, filename, is_directory, file }) => + Promise.reject({ + data: null, + }), + ); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe( + 8, + ); + await _createFile('newFile.js', true, false); + expect(wrapper.queryByTestId(`tab-newFile.js`)).not.toBeInTheDocument(); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe( + 8, + ); + await _createFile('newFile.js', true, true); + await _waitForTabShow('newFile.js'); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe( + 9, + ); + await _editCodeEditorValue('newFile.js', 'Edit: I am newFile.js'); + + mockApi.renameAlgorithmProjectFileContent.mockImplementationOnce((id, { path, dest }) => + Promise.reject({ + data: null, + }), + ); + await _renameNode('newFile.js', 'renameNewFile.js', true, false); + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-newFile.js`)).toBeInTheDocument(); + expect(wrapper.queryByTestId(`renameNewFile.js`)).not.toBeInTheDocument(); + }); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe( + 9, + ); + expect($tabList.children.length).toBe(2); + + await _renameNode('newFile.js', 'renameNewFile.js', true, true); + await waitFor(() => { + expect(wrapper.queryByTestId(`tab-newFile.js`)).not.toBeInTheDocument(); + expect(wrapper.queryByTestId(`renameNewFile.js`)).toBeInTheDocument(); + }); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe( + 9, + ); + expect($tabList.children.length).toBe(1); + + await _selectNode('renameNewFile.js'); + await _waitForTabShow('renameNewFile.js'); + expect($codeEditorInput.value).toBe('Edit: I am newFile.js'); + expect($tabList.children.length).toBe(2); + + mockApi.deleteAlgorithmProjectFileContent.mockImplementationOnce((id, { path }) => + Promise.reject({ + data: null, + }), + ); + mock.setup(); + mock.post('/api/v2/algorithm_projects/3/files', { + status: 400, + reason: 'Bad request', + body: '', + }); + + await _uploadFile('uploadFile.js', 'I am uploadFile.js', true, false); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe( + 9, + ); + expect($tabList.children.length).toBe(2); + + // TODO: mock upload API success + + mock.teardown(); + }); + }); + }); + + it('should only show one input when focus mode = true', async () => { + wrapper = render( + , + ); + + await waitFor(() => wrapper.getAllByText(testFileKey)[0]); // file node + + $createRootFileBtn = wrapper.getByTestId('btn-create-file-on-root'); + $createRootFolderBtn = wrapper.getByTestId('btn-create-folder-on-root'); + + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe(8); + // Only 0 input dom should be rendered + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(0); + fireEvent.click($createRootFileBtn); + + // Only 1 input dom should be rendered + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(1); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe(9); + fireEvent.click($createRootFileBtn); + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(1); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe(9); + fireEvent.click($createRootFileBtn); + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(1); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe(9); + fireEvent.click($createRootFolderBtn); + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(1); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe(9); + fireEvent.click($createRootFolderBtn); + expect(wrapper.container.querySelectorAll('.arco-tree input').length).toBe(1); + expect(wrapper.container.querySelectorAll('.arco-tree .arco-tree-node').length).toBe(9); + }); +}); diff --git a/web_console_v2/client/src/components/CodeEditorModal/index.module.less b/web_console_v2/client/src/components/CodeEditorModal/index.module.less new file mode 100644 index 000000000..c25155e30 --- /dev/null +++ b/web_console_v2/client/src/components/CodeEditorModal/index.module.less @@ -0,0 +1,12 @@ +.code_editor_model_wrapper{ + :global{ + .arco-modal-content{ + padding: 0px; + } + } +} + +.code_editor_upload{ + height: 18px; + margin-right: 8px; +} diff --git a/web_console_v2/client/src/components/CodeEditorModal/index.tsx b/web_console_v2/client/src/components/CodeEditorModal/index.tsx new file mode 100644 index 000000000..4bf154b54 --- /dev/null +++ b/web_console_v2/client/src/components/CodeEditorModal/index.tsx @@ -0,0 +1,1069 @@ +import React, { FC, useState, useMemo, useRef, useReducer } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; + +import { + createOrUpdateAlgorithmProjectFileContent, + renameAlgorithmProjectFileContent, + deleteAlgorithmProjectFileContent, +} from 'services/algorithm'; + +import { Button, Message, Tooltip, Modal, Upload } from '@arco-design/web-react'; +import { IconCodeSquare } from '@arco-design/web-react/icon'; +import { Resizable } from 're-resizable'; +import FileExplorer, { + FileDataNode, + FileExplorerExposedRef, + Key, + FileData, + fileExtToIconMap, +} from 'components/FileExplorer'; +import { ArrowUpFill, FolderAddFill, FileAddFill, Close, MenuFold } from 'components/IconPark'; +import CodeEditor, { Action } from 'components/CodeEditor'; +import StateIndicator from 'components/StateIndicator'; +import Tab from './Tab'; + +import { useSubscribe } from 'hooks'; +import { ModalProps } from '@arco-design/web-react/es/Modal/interface'; +import { Monaco } from '@monaco-editor/react'; +import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { UploadItem } from '@arco-design/web-react/es/Upload'; +import { FileContent } from 'typings/algorithm'; + +import { Z_INDEX_GREATER_THAN_HEADER } from 'components/Header'; +import { transformRegexSpecChar, formatLanguage, getJWTHeaders } from 'shared/helpers'; +import { buildRelativePath, getFileInfoByFilePath, readAsTextFromFile } from 'shared/file'; +import { MixinCommonTransition, MixinSquare, MixinFlexAlignCenter } from 'styles/mixins'; +import { getAlgorithmProjectProps, getAlgorithmProps } from 'components/shared'; +import { CONSTANTS } from 'shared/constants'; +import styles from './index.module.less'; + +function MixinHeader() { + return ` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid var(--border-color); + `; +} + +const Layout = styled.main` + --bg: #fff; + --font-color: #1d2129; + --border-color: #e5e8ef; + --action-button-color: #86909c; + --action-button-color-hover: #4e5969; + --tab-header-bg: #f2f3f8; + + --left-width: 272px; + + display: grid; + grid-template-areas: 'head head' 'left right'; + grid-template-rows: 46px calc(100vh - 46px); + grid-template-columns: var(--left-width) calc(max(500px, 100vw) - var(--left-width)); + + height: 100vh; + width: 100vw; + min-width: 500px; + min-height: 500px; + background-color: var(--bg); + overflow: hidden; +`; + +const Header = styled.div` + ${MixinHeader()}; + grid-area: head; +`; +const FileExplorerHeader = styled.div` + ${MixinHeader()}; + height: 36px; +`; +const TabHeader = styled.div` + height: 36px; + width: 100%; + border-bottom: 1px solid var(--border-color); + background-color: var(--tab-header-bg); + overflow-x: auto; + white-space: nowrap; +`; + +const Left = styled(Resizable)` + position: relative; + height: 100%; + grid-area: left; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-color); + z-index: 1; // overlay right layout + background-color: var(--bg); + padding-bottom: 40px; + + .resize-bar-wrapper { + &.resizing > div, + > div:hover { + ${MixinCommonTransition('background-color')} + padding:0 3px; + background-clip: content-box; + background-color: var(--primaryColor); + } + } +`; + +const Right = styled.div` + position: relative; + height: 100%; + display: flex; + grid-area: right; + flex-direction: column; + background-color: var(--bg); +`; + +const Title = styled.span` + font-size: 14px; + font-weight: 500; + color: var(--font-color); +`; + +const ActionContainer = styled.span` + > button:not(:last-child) { + margin-right: 8px; + } + > span:not(:last-child) { + margin-right: 8px; + } + .arco-upload-trigger { + display: inline; + } + .anticon { + color: var(--action-button-color); + cursor: pointer; + font-size: 18px; + &:not(:last-child) { + margin-right: 8px; + } + &:hover { + color: var(--action-button-color-hover); + } + &.anticon-close { + font-size: 14px; + } + } +`; + +const FoldButton = styled.div` + ${MixinSquare(24)} + ${MixinFlexAlignCenter()} + + position:absolute; + right: 8px; + bottom: 8px; + display: inline-flex; + background-color: rgb(var(--gray-1)); + color: rgb(var(--gray-6)); + border-radius: 2px; + cursor: pointer; + + &:hover { + background-color: rgb(var(--gray-2)); + } +`; + +export const MIN_FILE_TREE_WIDTH = 220; +export const MAX_FILE_TREE_WIDTH = 600; + +const resizeableEnable = { + right: true, +}; + +export type BaseCodeEditorProps = { + /** Algorithm project id / Algorithm id */ + id?: ID; + /** Modal display title */ + title?: string; + isReadOnly?: boolean; + initialFileData?: FileData; + isAsyncMode?: boolean; + /** On reset button click, only work in sync mode */ + onReset?: () => void; + /** On save button click, it will send latest fileData in sync mode */ + onSave?: (fileData: FileData) => void; + /** On close button click */ + onClose?: () => void; + getFileTreeList?: () => Promise; + getFile?: (filePath: string) => Promise; + onFileDataChange?: (fileData: FileData) => void; + /** Call this fn when they are some file are changed including create/delete/rename/change file content */ + onContentChange?: () => void; +}; + +export type AsyncBaseCodeEditorProps = Omit & + Required>; + +export interface FileTab { + node: FileDataNode; + model: monaco.editor.ITextModel | null; +} + +export type Props = Omit & BaseCodeEditorProps; +export type AsyncProps = Omit & Required>; +export type AlgorithmProjectFormButtonProps = Omit & { + /** Container width */ + width?: number | string; + /** Container height */ + height?: number | string; +}; + +export const BaseCodeEditor: FC & { + AlgorithmProject: FC; + Algorithm: FC; +} = ({ + id, + title = '', + initialFileData = {}, + isReadOnly = false, + isAsyncMode = false, + getFileTreeList, + getFile, + onFileDataChange: onFileDataChangeFromProps, + onContentChange, + ...restProps +}) => { + const { t } = useTranslation(); + + const [selectedNode, setSelectedNode] = useState(); + const [editingNode, setEditingNode] = useState(); + const [fileData, setFileData] = useState(initialFileData ?? {}); + const [fileTabList, setFileTabList] = useState([]); + const [leftWidth, setLeftWidth] = useState(272); + const [isLoading, setIsLoading] = useState(false); + + const fileExplorerRef = useRef(null); + const tempCodeRef = useRef(''); + const codeEditorInstance = useRef(); + const monacoInstance = useRef(); + const pendingCreateOrFocusTabQueue = useRef([]); + const tempLeftWidth = useRef(null); + const [isFocusMode, setIsFocusMode] = useState(false); + + const fileKeyToViewState = useRef<{ + [key: string]: monaco.editor.ICodeEditorViewState | null; + }>({}); + + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + const selectedKeys = useMemo(() => { + return selectedNode ? [selectedNode.key] : []; + }, [selectedNode]); + + // subscribe CodeEdiot action(command + s), to save file + useSubscribe(Action.Save, () => saveEditorCode(), [fileData, editingNode]); + + return ( + +
+ {title} + + {!isReadOnly && ( + <> + + {!isAsyncMode && ( + + )} + + )} +
+ { + // If trigger onResize first time, save prev left width + if (!tempLeftWidth.current) { + tempLeftWidth.current = leftWidth; + } + let nextLeftWidth = tempLeftWidth.current + d.width; + + if (nextLeftWidth <= MIN_FILE_TREE_WIDTH) { + nextLeftWidth = MIN_FILE_TREE_WIDTH; + } else if (nextLeftWidth >= MAX_FILE_TREE_WIDTH) { + nextLeftWidth = MAX_FILE_TREE_WIDTH; + } + setLeftWidth(nextLeftWidth); + }} + onResizeStop={(e, direction, ref, d) => { + // Reset tempLeftWidth + tempLeftWidth.current = null; + // Only to refresh handleWrapperClass + forceUpdate(); + }} + > + + 文件列表 + {!isReadOnly && ( + + { + return isAsyncMode; + }} + data={(file) => { + const { name } = file; + + const folderKey = selectedNode + ? selectedNode.isFolder + ? selectedNode.key + : selectedNode.parentKey + : ''; + + return { + path: folderKey, + filename: name, + }; + }} + > + + + + + + + + + + + + )} + + + + + + + + + + {fileTabList.map((item) => { + return ( + { + // prevent select same tab + if (item.node.key === selectedNode?.key) { + return; + } + focusTab(item); + }} + onClose={() => { + onDeleteFinish([item.node.key], item.node.key, true); + }} + /> + ); + })} + + + +
+ ); + + async function createOrUpdateNodeInBackEnd( + filePath: string, + isFolder: boolean, + code: string = '', + ) { + if (!isAsyncMode || !id) return; + + const { parentPath, fileName } = getFileInfoByFilePath(filePath); + setIsLoading(true); + try { + const result = await createOrUpdateAlgorithmProjectFileContent(id, { + path: parentPath, + filename: fileName, + is_directory: isFolder, + file: code, + }); + setIsLoading(false); + return result; + } catch (error) { + Message.error(error.message); + setIsLoading(false); + return Promise.reject(error); + } + } + async function renameNodeInBackEnd(oldPath: string, newPath: string) { + if (!isAsyncMode || !id) return; + + setIsLoading(true); + try { + const result = await renameAlgorithmProjectFileContent(id, { + path: oldPath, + dest: newPath, + }); + setIsLoading(false); + return result; + } catch (error) { + Message.error(error.message); + setIsLoading(false); + return Promise.reject(error); + } + } + async function deleteNodeInBackEnd(filePath: string) { + if (!isAsyncMode || !id) return; + + setIsLoading(true); + try { + const result = await deleteAlgorithmProjectFileContent(id, { path: filePath }); + setIsLoading(false); + return result; + } catch (error) { + Message.error(error.message); + setIsLoading(false); + return Promise.reject(error); + } + } + + function resetState() { + tempCodeRef.current = ''; + fileKeyToViewState.current = {}; + // dispose model + fileTabList.forEach((item) => { + item.model?.dispose(); + }); + setFileTabList([]); + setSelectedNode(undefined); + setEditingNode(undefined); + } + + function getCodeEditorInstance(editor: monaco.editor.IStandaloneCodeEditor, monaco: Monaco) { + codeEditorInstance.current = editor; + monacoInstance.current = monaco; + + // dequeue pending queue + if (pendingCreateOrFocusTabQueue.current.length > 0) { + pendingCreateOrFocusTabQueue.current.forEach((node) => createOrFocusTab(node)); + + // reset pending queue + pendingCreateOrFocusTabQueue.current = []; + } + } + + function createOrFocusTab(node: FileDataNode) { + if (!monacoInstance.current || !codeEditorInstance.current) { + // enqueue pending queue + pendingCreateOrFocusTabQueue.current.push(node); + return; + } + const monaco = monacoInstance.current; + const tempUri = monaco.Uri.file(String(node.key)); + let model = monaco.editor.getModel(tempUri); + // if model not exist create, otherwise replace value (for editor resets). + if (model === null) { + model = monaco.editor.createModel( + node.code ?? '', + formatLanguage(node.fileExt ?? '') ?? undefined, + tempUri, + ); + + // add new tab + setFileTabList((prevState) => [ + ...prevState, + { + node, + model, + }, + ]); + } + + // focus editor + // codeEditorInstance.current?.focus(); + // save editor view state + if (editingNode) { + const tempState = codeEditorInstance.current.saveViewState(); + fileKeyToViewState.current[String(editingNode?.key)] = tempState; + } + fileKeyToViewState.current[node.key] = null; + + // replace editor model + codeEditorInstance.current?.setModel(model); + + if (fileKeyToViewState.current[node.key]) { + codeEditorInstance.current?.restoreViewState(fileKeyToViewState.current[node.key]!); + } + } + + async function saveEditorCode(node?: FileDataNode) { + // save prev tempCodeRef + if (editingNode) { + const tempCode = tempCodeRef.current; + try { + if (isAsyncMode) { + // Save code in Back-end + await createOrUpdateNodeInBackEnd( + String(editingNode.key), + Boolean(editingNode.isFolder), + tempCode, + ); + } + + setFileData((prevState) => { + return { + ...prevState, + [editingNode.key]: tempCode, + }; + }); + onFileDataChangeFromProps?.({ ...fileData, [editingNode.key]: tempCode }); + onContentChange?.(); + } catch (error) { + // Do nothing + } + } + if (node) { + tempCodeRef.current = fileData[String(node.key)] || node.code || ''; + } + } + + function focusTab(tab: FileTab, isSaveCode = true) { + if (!monacoInstance.current || !codeEditorInstance.current) { + return; + } + + if (isSaveCode) { + saveEditorCode(tab.node); + } else { + tempCodeRef.current = fileData[String(tab.node.key)] || tab.node.code || ''; + } + + // focus editor + // codeEditorInstance.current?.focus(); + const tempState = codeEditorInstance.current.saveViewState(); + fileKeyToViewState.current[String(editingNode?.key)] = tempState; + + setSelectedNode(tab.node); + setEditingNode(tab.node); + codeEditorInstance.current?.setModel(tab.model); + + if (fileKeyToViewState.current[tab.node.key]) { + codeEditorInstance.current?.restoreViewState(fileKeyToViewState.current[tab.node.key]!); + } + } + + function onDeleteFinish(deleteKeys: Key[], firstDeleteKey: Key, isTabClick = false) { + const newFileTabList: FileTab[] = []; + fileTabList.forEach((item) => { + if (deleteKeys.includes(item.node.key)) { + // dispose model + item.model?.dispose(); + + // clear fileKeyToViewState + delete fileKeyToViewState.current[item.node.key]; + } else { + newFileTabList.push(item); + } + }); + + // delete active node + if (selectedNode && deleteKeys.includes(selectedNode.key)) { + setSelectedNode(undefined); + } + if (editingNode && deleteKeys.includes(editingNode.key)) { + setEditingNode(undefined); + // default select first tab + if (newFileTabList.length > 0) { + // must clear node state, otherwise saveEditorCode will create extra same node + focusTab(newFileTabList[0], false); + } + } + + setFileTabList(newFileTabList); + + if (newFileTabList.length === 0) { + resetState(); + } + if (!isTabClick) { + onContentChange?.(); + } + } + async function onRenameFinish(node: FileDataNode, oldKey: Key, newKey: Key) { + onContentChange?.(); + + if (!node.isFolder) { + // rename file node + if (selectedNode?.key === oldKey) { + // must clear node state, otherwise saveEditorCode will create extra same node + setSelectedNode(undefined); + setEditingNode(undefined); + } + + const oldTabIndex = fileTabList.findIndex((item) => item.node.key === oldKey); + + if (oldTabIndex === -1) { + return; + } + const { model: oldModel } = fileTabList[oldTabIndex]; + + // rename fileKeyToViewState + const oldViewState = fileKeyToViewState.current[String(oldKey)]; + fileKeyToViewState.current[String(newKey)] = oldViewState; + delete fileKeyToViewState.current[String(oldKey)]; + + // dispose oldModel + oldModel?.dispose(); + + // TODO: Find a good way to replace oldTab to newTab, no just only delete oldTab + setFileTabList([...fileTabList.slice(0, oldTabIndex), ...fileTabList.slice(oldTabIndex + 1)]); + } else { + // rename folder node + const allOldFileKey: Key[] = []; + + const regx = new RegExp(`^${transformRegexSpecChar(String(oldKey))}`); // prefix originKey + // find all file node key under this folder + Object.keys(fileData).forEach((key) => { + if (!!key.match(regx)) { + allOldFileKey.push(key); + } + }); + + if (allOldFileKey.includes(String(selectedNode?.key))) { + // must clear node state, otherwise saveEditorCode will create extra same node + setSelectedNode(undefined); + setEditingNode(undefined); + } + + // rename fileKeyToViewState + const filteredFileTabList = fileTabList.filter((item) => { + const { node: oldNode, model: oldModel } = item; + if (allOldFileKey.includes(oldNode.key)) { + const oldViewState = fileKeyToViewState.current[String(oldNode.key)]; + const tnewKey = String(oldNode.key).replace(regx, String(newKey)); + + fileKeyToViewState.current[String(tnewKey)] = oldViewState; + delete fileKeyToViewState.current[String(oldNode.key)]; + + // dispose oldModel + oldModel?.dispose(); + + // delete this tab + return false; + } + // reserve this tab + return true; + }); + setFileTabList(filteredFileTabList); + } + } + function onCreateFinish(path: Key, isFolder: boolean) { + onContentChange?.(); + } + function onClickRename(node: FileDataNode) { + saveEditorCode(); + } + function onFocusModeChange(focusMode: boolean) { + setIsFocusMode(focusMode); + } + + function onCodeChange(val?: string) { + // store temp code + tempCodeRef.current = val ?? ''; + } + function onFileDataChange(fileData: FileData) { + setFileData(fileData); + onFileDataChangeFromProps?.(fileData); + } + function onFileNodeSelect(filePath: Key, fileContent: string, node: FileDataNode) { + // prevent select same node + if (node.key === selectedNode?.key) { + return; + } + setSelectedNode(node); + saveEditorCode(node); + setEditingNode(node); + createOrFocusTab(node); + } + function onSelect( + selectedKeys: Key[], + info: { + selected: boolean; + selectedNodes: FileDataNode[]; + node: FileDataNode; + e: Event; + }, + ) { + // folder or file + if (info.selected && info.selectedNodes && info.selectedNodes[0]) { + const currentNode = info.selectedNodes[0]; + // prevent select same node + if (currentNode.key === selectedNode?.key) { + return; + } + + setSelectedNode(currentNode); + } + } + + async function onUploadChange(fileList: UploadItem[], info: UploadItem) { + const { status, response, name } = info; + switch (status) { + case 'done': + const resData: Omit = (response as any).data; + const fileKey = buildRelativePath(resData); + + // Set 's inner filePathToIsReadMap, so it will fetch file content API when select this upload file + fileExplorerRef.current?.setFilePathToIsReadMap({ + [fileKey]: false, + }); + + // Display node in file explorer + setFileData((prevState) => { + return { + ...prevState, + [fileKey]: '', + }; + }); + onFileDataChangeFromProps?.({ + ...fileData, + [(response as any).data.path]: (response as any).data.content, + }); + onContentChange?.(); + setIsLoading(false); + break; + case 'error': + Message.error(t('upload.msg_upload_fail', { fileName: info.name })); + setIsLoading(false); + break; + case 'uploading': + setIsLoading(true); + break; + // When beforeUpload return false, status will be undefined. + // In this case, isAsyncMode = false, so read file content locally + case undefined: + // const template = await readAsJSONFromFile(info.file as any); + const code = await readAsTextFromFile(info.originFile as any); + + const folderKey = selectedNode + ? selectedNode.isFolder + ? selectedNode.key + : selectedNode.parentKey + : ''; + + const path = `${folderKey ? `${folderKey}/` : ''}${name}`; + setFileData((prevState) => { + return { + ...prevState, + [path]: code, + }; + }); + onFileDataChangeFromProps?.({ ...fileData, [path]: code }); + onContentChange?.(); + + break; + default: + break; + } + } + function onAddFolderOnRoot() { + if (isLoading || isFocusMode) return; + fileExplorerRef.current?.createFileOrFolder(undefined, false); + } + function onAddFileOnRoot() { + if (isLoading || isFocusMode) return; + + fileExplorerRef.current?.createFileOrFolder(undefined, true); + } + function onFoldClick() { + setLeftWidth(MIN_FILE_TREE_WIDTH); + } + + async function onSave() { + if (isAsyncMode) { + await saveEditorCode(); + Message.success(t('algorithm_management.form_code_changed')); + } else { + let finalFileData = fileData; + if (editingNode) { + const tempCode = tempCodeRef.current; + finalFileData = { + ...fileData, + [editingNode.key]: tempCode, + }; + setFileData(finalFileData); + onFileDataChangeFromProps?.(finalFileData); + } + Message.success(t('algorithm_management.form_code_changed')); + restProps.onSave?.(finalFileData); + } + } + function onReset() { + if (isAsyncMode) return; + setFileData(initialFileData); + onFileDataChangeFromProps?.(initialFileData); + + resetState(); + + restProps.onReset?.(); + } + async function onClose() { + if (restProps.onClose) { + // Clear all code editor model info in memory + // If no clear, it will effect create + resetState(); + restProps.onClose(); + } + } + + async function beforeCreate(node: FileDataNode, key: Key, isFolder: boolean) { + try { + if (isAsyncMode) { + // Create new file/folder when API response success + const result = await createOrUpdateNodeInBackEnd(String(key), isFolder); + const fileKey = result && result.data ? buildRelativePath(result.data) : key; + return String(fileKey); + } + } catch (error) { + return false; // return false to prevent create file/folder + } + return true; + } + async function beforeRename(node: FileDataNode, oldKey: Key, newKey: Key, isFolder: boolean) { + try { + if (isAsyncMode) { + // Save code in Back-end if this node is editingNode + if (editingNode?.key === node.key) { + // Save code in Back-end + await createOrUpdateNodeInBackEnd( + String(editingNode.key), + Boolean(editingNode.isFolder), + tempCodeRef.current || '', + ); + } + // Rename node in Back-end + await renameNodeInBackEnd(String(oldKey), String(newKey)); + } + } catch (error) { + return false; // return false to prevent rename file/folder + } + return true; + } + async function beforeDelete(key: Key, isFolder: boolean) { + try { + if (isAsyncMode) { + await deleteNodeInBackEnd(String(key)); + } + } catch (error) { + return false; // return false to prevent delete file/folder + } + return true; + } +}; + +const _BaseCodeEditorWithAlgorithmProjectAPI: FC = ({ + id, + ...restProps +}) => { + return ; +}; + +const _BaseCodeEditorWithAlgorithmAPI: FC = ({ id, ...restProps }) => { + return ; +}; + +export const CodeEditorModal: FC & { + AlgorithmProject: FC; + Algorithm: FC; + AlgorithmProjectFormButton: FC; +} = ({ + id, + isAsyncMode = false, + visible = false, + title = CONSTANTS.EMPTY_PLACEHOLDER, + initialFileData, + isReadOnly = false, + onReset = () => {}, + onSave = () => {}, + onClose = () => {}, + getFileTreeList, + getFile, + onFileDataChange, + onContentChange, + ...resetProps +}) => { + return ( + + + + ); +}; + +const _CodeEditorModalWithAlgorithmProjectAPI: FC = ({ id, ...restProps }) => { + return ; +}; + +const _CodeEditorModalWithAlgorithmAPI: FC = ({ id, ...restProps }) => { + return ; +}; + +const FormButtonContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 24px 0; + border: 1px dashed var(--lineColor); + border-radius: 2px; + background: rgb(var(--gray-2)); + cursor: pointer; + ${MixinCommonTransition('border-color')} + + &:hover { + border-color: var(--primaryColor); + } +`; +const FormButtonTitle = styled.div` + color: var(--textColorStrong); + font-weight: 500; + font-size: 16px; + margin: 14px 0 4px; +`; +const FormButtonTip = styled.span` + display: inline-block; + color: var(--textColorSecondary); + font-size: 12px; +`; +export const AlgorithmProjectFormButton: FC = ({ + id, + width = '100%', + height = 140, + ...restProps +}) => { + const [isShowCodeEditor, setIsShowCodeEditor] = useState(false); + const [isEdited, setIsEdited] = useState(false); + + return ( + <> + { + setIsShowCodeEditor(true); + }} + > + + 代码编辑器 +
+ + {isEdited ? '更改内容已在后台保存' : '点击进入代码编辑器'} +
+
+ { + setIsShowCodeEditor(false); + }} + onContentChange={() => { + if (!isEdited) { + setIsEdited(true); + } + }} + {...getAlgorithmProjectProps({ id: id! })} + {...restProps} + /> + + ); +}; + +BaseCodeEditor.AlgorithmProject = _BaseCodeEditorWithAlgorithmProjectAPI; +BaseCodeEditor.Algorithm = _BaseCodeEditorWithAlgorithmAPI; +CodeEditorModal.AlgorithmProject = _CodeEditorModalWithAlgorithmProjectAPI; +CodeEditorModal.Algorithm = _CodeEditorModalWithAlgorithmAPI; +CodeEditorModal.AlgorithmProjectFormButton = AlgorithmProjectFormButton; + +export default CodeEditorModal; diff --git a/web_console_v2/client/src/components/CodePreview/index.tsx b/web_console_v2/client/src/components/CodePreview/index.tsx new file mode 100644 index 000000000..35cf76634 --- /dev/null +++ b/web_console_v2/client/src/components/CodePreview/index.tsx @@ -0,0 +1,184 @@ +/* istanbul ignore file */ + +import React, { FC, useState } from 'react'; +import styled from 'styled-components'; +import classNames from 'classnames'; + +import { MixinCommonTransition } from 'styles/mixins'; +import { formatLanguage } from 'shared/helpers'; + +import { Resizable } from 're-resizable'; +import CodeEditor from 'components/CodeEditor'; +import FileExplorer, { FileDataNode } from 'components/FileExplorer'; +import { + getAlgorithmProjectProps, + getAlgorithmProps, + getPeerAlgorithmProps, + getPendingAlgorithmProps, +} from 'components/shared'; + +const Container = styled.div` + display: flex; + flex: 1; + border: 1px solid var(--lineColor); + + .resize-bar-wrapper { + &.resizing > div, + > div:hover { + ${MixinCommonTransition('background-color')} + padding:0 3px; + background-clip: content-box; + background-color: var(--primaryColor); + } + } +`; + +const StyledResizable = styled(Resizable)` + position: relative; + padding: 12px 0 0 0; + overflow: hidden; + border-right: 1px solid var(--lineColor); + &::after { + position: absolute; + right: 0px; + content: ''; + width: 1px; + background-color: var(--lineColor); + } +`; + +const Right = styled.div` + flex: 1; + // when resizing, max-width + overflow will trigger automaticLayout + overflow: hidden; +`; + +export type Props = { + /** Algorithm project id / Algorithm id */ + id?: ID; + isAsyncMode?: boolean; + getFileTreeList?: () => Promise; + getFile?: (filePath: string) => Promise; + /** Container height */ + height?: number | string; + /** Default display value. Only work on sync mode, it isn't work on async mode */ + fileData?: { [filePath: string]: string }; + isLoading?: boolean; +}; + +export type AsyncProps = Omit & Required>; + +type Key = string | number; + +export const MIN_FILE_TREE_WIDTH = 270; +export const MAX_FILE_TREE_WIDTH = 600; + +const resizeableDefaultSize = { + width: MIN_FILE_TREE_WIDTH, + height: 'auto', +}; +const resizeableEnable = { + right: true, +}; + +const CodePreview: FC & { + Algorithm: FC; + AlgorithmProject: FC; + PendingAlgorithm: FC; + PeerAlgorithm: FC; +} = ({ id, fileData, height = 480, isLoading, isAsyncMode = false, getFileTreeList, getFile }) => { + const [currentCode, setCurrentCode] = useState(''); + const [currentLanguage, setCurrentLanguage] = useState< + 'json' | 'python' | 'javascript' | 'java' | 'go' + >('python'); + + const [isResizing, setIsResizing] = useState(false); + + return ( + + { + if (isResizing) return; + setIsResizing(true); + }} + onResizeStop={(e, direction, ref, d) => { + setIsResizing(false); + }} + > + + + + + + + ); + + function onFileNodeSelect(filePath: Key, fileContent: string, node: FileDataNode) { + setCurrentCode(fileContent ?? ''); + setCurrentLanguage(formatLanguage(node.fileExt ?? '') as any); + } +}; + +const _WithAlgorithmProjectAPI: FC = ({ id, ...restProps }) => { + return ; +}; + +const _WithAlgorithmAPI: FC = ({ id, ...restProps }) => { + return ; +}; + +const _WithPendingAlgorithmAPI: FC = ({ + projId, + id, + ...restProps +}) => { + return ; +}; + +const _WithPeerAlgorithmAPI: FC = ({ + id, + participantId, + projId, + uuid, + ...restProps +}) => { + return ( + + ); +}; + +CodePreview.AlgorithmProject = _WithAlgorithmProjectAPI; +CodePreview.Algorithm = _WithAlgorithmAPI; +CodePreview.PendingAlgorithm = _WithPendingAlgorithmAPI; +CodePreview.PeerAlgorithm = _WithPeerAlgorithmAPI; + +export default CodePreview; diff --git a/web_console_v2/client/src/components/CodePreviewCollapse/index.tsx b/web_console_v2/client/src/components/CodePreviewCollapse/index.tsx new file mode 100644 index 000000000..2966d7944 --- /dev/null +++ b/web_console_v2/client/src/components/CodePreviewCollapse/index.tsx @@ -0,0 +1,153 @@ +/* istanbul ignore file */ + +import React, { FC, useState } from 'react'; +import styled from 'styled-components'; +import { Collapse } from '@arco-design/web-react'; + +import { formatLanguage } from 'shared/helpers'; + +import { Resizable } from 're-resizable'; +import CodeEditor from 'components/CodeEditor'; +import FileExplorer, { FileDataNode } from 'components/FileExplorer'; + +const Content = styled.div` + display: flex; + flex: 1; + height: 443px; +`; + +const StyledCollapse = styled(Collapse)` + // when resizing, max-width + overflow will trigger automaticLayout + // I dont know why calc(100%) or calc(100% - 0px) not work,so I set calc(100% - 1px) + max-width: calc(100% - 1px); + + .ant-collapse-content > .ant-collapse-content-box { + padding: 0; + } + .ant-collapse-header { + background-color: #fff; + } +`; + +const StyledResizable = styled(Resizable)` + position: relative; + padding: 12px 0; + border-right: 1px solid var(--lineColor); + &::after { + position: absolute; + right: 0px; + content: ''; + width: 1px; + background-color: var(--lineColor); + } +`; + +const Right = styled.div` + flex: 1; + // when resizing, max-width + overflow will trigger automaticLayout + overflow: hidden; +`; + +const Title = styled.span` + display: inline-block; + margin-right: 12px; + color: #1d252f; + font-size: 13px; + font-weight: 500; +`; +const Label = styled.span` + display: inline-block; + padding: 0 6px; + border-radius: 2px; + background: #e8f4ff; + font-size: 12px; + color: var(--primaryColor); +`; + +type Props = { + title?: string; + label?: string; + style?: React.CSSProperties; + fileData: { [filePath: string]: string }; + isLoading?: boolean; +}; + +type Key = string | number; + +export const MIN_FILE_TREE_WIDTH = 270; +export const MAX_FILE_TREE_WIDTH = 600; + +const resizeableDefaultSize = { + width: MIN_FILE_TREE_WIDTH, + height: 'auto', +}; +const resizeableEnable = { + right: true, +}; + +const CodePreviewCollapse: FC = ({ title, label, fileData, style, isLoading }) => { + const [currentCode, setCurrentCode] = useState(''); + const [currentLanguage, setCurrentLanguage] = useState< + 'json' | 'python' | 'javascript' | 'java' | 'go' + >('python'); + + return ( + + + {title} + {label && } + + } + name="1" + > + + + + + + + + + + + ); + + function onSelect( + selectedKeys: Key[], + info: { + selected: boolean; + node: FileDataNode; + selectedNodes: FileDataNode[]; + e: Event; + }, + ) { + // folder or file + if ( + info.selected && + info.selectedNodes && + info.selectedNodes[0] && + !info.selectedNodes[0].isFolder + ) { + setCurrentCode(info.selectedNodes[0]?.code ?? ''); + setCurrentLanguage(formatLanguage(info.selectedNodes[0].fileExt ?? '') as any); + } + } +}; + +export default CodePreviewCollapse; diff --git a/web_console_v2/client/src/components/ConfigForm/index.less b/web_console_v2/client/src/components/ConfigForm/index.less new file mode 100644 index 000000000..7a683d952 --- /dev/null +++ b/web_console_v2/client/src/components/ConfigForm/index.less @@ -0,0 +1,42 @@ +.config-form{ + position: relative; + .config-form-extra{ + position: absolute; + top: 0; + right: 0; + } +} +.config-form-collapse{ + width: 100%; + overflow: initial; + .arco-collapse-item-header { + padding-left: 12px; + padding-right: 0; + border-width: 0; + &-title { + font-weight: 400 !important; + font-size: 12px; + } + .arco-btn-size-mini{ + padding: 0; + } + .arco-icon-hover{ + left: -3px + } + .arco-collapse-item-icon-hover{ + left: -3px + } + } + .arco-collapse-item-content { + background-color: transparent; + } + .arco-collapse-item-content-box { + padding: 0; + } +} + +.config-form-variable-label{ + font-size: 12px; + padding: 0 10px; + color: var(--color-text-2); +} diff --git a/web_console_v2/client/src/components/ConfigForm/index.tsx b/web_console_v2/client/src/components/ConfigForm/index.tsx new file mode 100644 index 000000000..3f8c1a25f --- /dev/null +++ b/web_console_v2/client/src/components/ConfigForm/index.tsx @@ -0,0 +1,306 @@ +/* istanbul ignore file */ +import React, { + useEffect, + useMemo, + useImperativeHandle, + ForwardRefRenderFunction, + forwardRef, + ReactNode, +} from 'react'; +import i18n from 'i18n'; + +import { Grid, Form, Input, Collapse, InputNumber, Select, Switch } from '@arco-design/web-react'; +import { IconQuestionCircle } from '@arco-design/web-react/icon'; +import ModelCodesEditorButton from 'components/ModelCodesEditorButton'; +import YAMLTemplateEditorButton from 'components/YAMLTemplateEditorButton'; +import EnvsInputForm from 'views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/EnvsInputForm'; +import { AlgorithmSelect } from 'components/DoubleSelect'; +import { CpuInput, MemInput } from 'components/InputGroup/NumberTextInput'; +import TitleWithIcon from 'components/TitleWithIcon'; + +import { FormProps, FormItemProps, FormInstance } from '@arco-design/web-react/es/Form'; +import { VariableComponent } from 'typings/variable'; +import { NO_CATEGORY } from 'views/Datasets/shared'; +import './index.less'; + +const { Row, Col } = Grid; + +export type ItemProps = { + componentType?: `${VariableComponent}`; + componentProps?: object; + tip?: string; + render?: (props: object) => React.ReactNode; + tag?: string; +} & FormItemProps; + +export type ExposedRef = { + formInstance: FormInstance; +}; + +type Props = { + value?: { [key: string]: any }; + onChange?: (val: any) => void; + /** Extra form props */ + formProps?: FormProps; + /** Extra for action*/ + configFormExtra?: ReactNode; + /** Form item list */ + formItemList?: ItemProps[]; + /** Collapse form item list */ + collapseFormItemList?: ItemProps[]; + /** Collapse title */ + collapseTitle?: string; + /** Collapse title extra node */ + collapseTitleExtra?: ReactNode; + /** Is default open collapse */ + isDefaultOpenCollapse?: boolean; + /** How many cols in one row */ + cols?: 1 | 2 | 3 | 4 | 6 | 8 | 12 | 24; + /** Reset initialValues when changing formItemList or collapseFormItemList */ + isResetOnFormItemListChange?: boolean; + /** variable will be grouped by this field */ + groupBy?: string; + /** Is group tag Hidden */ + hiddenGroupTag?: boolean; + /** Is Collapse Hidden*/ + hiddenCollapse?: boolean; + /** filter */ + filter?: (item: ItemProps) => boolean; +}; + +const emptyList: ItemProps[] = []; +interface ItemMapper { + [tagKey: string]: { + list: ItemProps[]; + rows: number; + }; +} + +const ConfigForm: ForwardRefRenderFunction = ( + { + value, + onChange, + formProps, + configFormExtra, + formItemList = emptyList, + collapseFormItemList = emptyList, + collapseTitle = i18n.t('model_center.title_advanced_config'), + collapseTitleExtra, + isDefaultOpenCollapse = false, + isResetOnFormItemListChange = false, + cols = 2, + groupBy = '', + hiddenGroupTag = true, + hiddenCollapse = false, + filter, + }, + parentRef, +) => { + const isControlled = typeof value === 'object' && value !== null; + const [form] = Form.useForm(); + + const initialFormValue = useMemo(() => { + const list = [...formItemList, ...collapseFormItemList]; + + return list.reduce((acc, cur) => { + const { field, initialValue } = cur; + + if (field) { + acc[field] = initialValue; + } + + return acc; + }, {} as any); + }, [formItemList, collapseFormItemList]); + + const span = useMemo(() => { + return Math.floor(24 / cols); + }, [cols]); + + const getGroupedItemMapper = ( + itemList: ItemProps[], + groupBy: string, + cols: number, + filter?: (item: ItemProps) => boolean, + ) => { + return itemList?.reduce((acc: ItemMapper, cur: any) => { + // Executive filter + if (filter && !filter(cur)) { + return acc; + } + const tag = cur[groupBy] || NO_CATEGORY; + if (!acc[tag]) { + acc[tag] = { + list: [], + rows: 0, + }; + } + acc[tag]?.list?.push({ ...cur }); + acc[tag].rows = Math.ceil(acc[tag]?.list?.length / cols); + return acc; + }, {} as ItemMapper); + }; + + const groupedFormItemMapper = useMemo(() => { + return getGroupedItemMapper(formItemList, groupBy, cols, filter); + }, [formItemList, groupBy, cols, filter]); + const groupedCollapseFormItemMapper = useMemo(() => { + return getGroupedItemMapper(collapseFormItemList, groupBy, cols, filter); + }, [collapseFormItemList, groupBy, cols, filter]); + + useEffect(() => { + if (isControlled) { + form.setFieldsValue({ ...value }); + } + }, [value, isControlled, form]); + + useEffect(() => { + if (isResetOnFormItemListChange) { + onChange?.(initialFormValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialFormValue, isResetOnFormItemListChange]); + + useImperativeHandle(parentRef, () => { + return { + formInstance: form, + }; + }); + + const renderFormItemList = ( + groupedFormItemMapper: ItemMapper, + cols: number, + span: number, + hiddenGroupTag: boolean, + ) => { + return Object.keys(groupedFormItemMapper).reduce((acc: any, cur: string) => { + const { list = [] as ItemProps[], rows } = groupedFormItemMapper[cur]; + !hiddenGroupTag && + !!groupBy && + acc.push( + + {cur} + , + ); + for (let i = 0; i < rows; i++) { + acc.push( + + {list.slice(i * cols, (i + 1) * cols).map((item: ItemProps, index: number) => { + const { componentType, componentProps, render, label, tip, ...restProps } = item; + return ( +
+ + ) : ( + label + ) + } + > + {renderFormItemContent(item)} + + + ); + })} + , + ); + } + return acc; + }, [] as any); + }; + + return ( + { + onChange?.(values); + }} + scrollToFirstError + {...formProps} + > +
{configFormExtra}
+ {renderFormItemList(groupedFormItemMapper, cols, span, hiddenGroupTag)} + {!hiddenCollapse && ( + + + {renderFormItemList(groupedCollapseFormItemMapper, cols, span, hiddenGroupTag)} + + + )} + + ); +}; + +function renderFormItemContent(props: ItemProps) { + const { componentType, componentProps = {}, render } = props; + + if (render) { + return render(componentProps); + } + + const Component = getRenderComponent(componentType) || Input; + + return ; +} + +export function getRenderComponent(componentType?: VariableComponent | `${VariableComponent}`) { + let Component: React.Component | React.FC = Input; + switch (componentType) { + case VariableComponent.Input: + Component = Input; + break; + case VariableComponent.TextArea: + Component = Input.TextArea; + break; + case VariableComponent.NumberPicker: + Component = InputNumber; + break; + case VariableComponent.Select: + Component = Select; + break; + case VariableComponent.Switch: + Component = Switch; + break; + case VariableComponent.Code: + Component = ModelCodesEditorButton; + break; + case VariableComponent.JSON: + Component = YAMLTemplateEditorButton; + break; + case VariableComponent.EnvsInput: + Component = EnvsInputForm; + break; + case VariableComponent.AlgorithmSelect: + Component = AlgorithmSelect; + break; + case VariableComponent.CPU: + Component = CpuInput; + break; + case VariableComponent.MEM: + Component = MemInput; + break; + default: + Component = Input; + break; + } + return Component; +} + +export default forwardRef(ConfigForm); diff --git a/web_console_v2/client/src/components/ConfusionMatrix/index.tsx b/web_console_v2/client/src/components/ConfusionMatrix/index.tsx new file mode 100644 index 000000000..9424bff86 --- /dev/null +++ b/web_console_v2/client/src/components/ConfusionMatrix/index.tsx @@ -0,0 +1,279 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import i18n from 'i18n'; +import { Switch } from '@arco-design/web-react'; +import NoResult from 'components/NoResult'; +import TitleWithIcon from 'components/TitleWithIcon'; +import { QuestionCircle } from 'components/IconPark'; +import { useModelMetriesResult } from 'hooks/modelCenter'; + +const Card = styled.div<{ height?: number }>` + display: flex; + align-items: center; + justify-content: center; + position: relative; + ${(props) => props.height && `height: ${props.height}px`}; + border: 1px solid var(--lineColor); + border-radius: 2px; +`; +const Content = styled.div` + position: relative; + width: 160px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0 auto; +`; + +const Item = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex: 0 0 80px; + width: 80px; + height: 50px; + font-size: 12px; + &:nth-of-type(1) { + background-color: #468dff; + color: #fff; + } + &:nth-of-type(2) { + background-color: #f6f9fe; + color: var(--textColor); + } + &:nth-of-type(3) { + background-color: #d6e4fd; + color: var(--textColor); + } + &:nth-of-type(4) { + background-color: #7da9f8; + color: #fff; + } +`; + +const Label = styled.span` + font-size: 12px; + color: var(--textColorStrong); +`; +const TopTitle = styled(Label)` + display: block; + width: 100%; + margin-bottom: 10px; + text-align: center; +`; +const LeftTitle = styled(Label)` + position: absolute; + top: 65px; + left: -45px; + transform: rotate(-90deg); +`; +const RightTopTitle = styled(Label)` + position: absolute; + top: 45px; + right: -30px; +`; +const RightBottomTitle = styled(Label)` + position: absolute; + top: 95px; + right: -30px; +`; +const BottomLeftTitle = styled(Label)` + position: absolute; + bottom: 15px; + left: 30px; +`; +const BottomRightTitle = styled(Label)` + position: absolute; + bottom: 15px; + right: 30px; +`; + +const Bar = styled.div` + position: relative; + width: 160px; + height: 8px; + margin-top: 30px; + background: linear-gradient(90deg, #fff 0%, #468dff 100%); + visibility: hidden; + &::before { + position: absolute; + left: -30px; + top: -5px; + display: inline-block; + content: '0'; + font-size: 12px; + color: var(--textColorStrong); + } + &::after { + position: absolute; + top: -5px; + right: -30px; + display: inline-block; + content: '1'; + font-size: 12px; + color: var(--textColorStrong); + } +`; + +const CenterLayout = styled.div` + margin: 0 auto; +`; + +const Title = styled(TitleWithIcon)` + position: absolute; + left: 16px; + top: 12px; + color: var(--textColor); + font-size: 12px; +`; + +const PercentContainer = styled.div` + position: absolute; + top: -3px; + right: -80px; + + button { + margin-left: 10px; + } +`; + +export type Props = { + valueList: any[]; + percentValueList?: any[]; + height?: number; + title?: string; + tip?: string; + isEmpty?: boolean; + formatPercentValueList?: (valueList: number[]) => string[]; +}; + +export type ModelEvaluationVariantProps = { + id: ID; + participantId?: ID; +}; + +export const defaultFormatPercentValueList = (valueList: number[]) => { + const total = valueList.reduce((acc: number, cur: number) => acc + cur, 0); + return valueList.map((num) => ((num / total) * 100).toFixed(2) + '%'); +}; + +type VariantComponents = { + ModelEvaluationVariant: React.FC; +}; + +export const ConfusionMatrix: FC & VariantComponents = ({ + valueList, + percentValueList: percentValueListFromProps, + height = 260, + title = 'Confusion matrix', + tip = '', + isEmpty = false, + formatPercentValueList = defaultFormatPercentValueList, +}) => { + const [isShowPercent, setIsShowPercent] = useState(false); + + const percentValueList = useMemo(() => { + if (percentValueListFromProps) { + return percentValueListFromProps; + } + + if (!valueList) { + return []; + } + + return formatPercentValueList(valueList); + }, [valueList, percentValueListFromProps, formatPercentValueList]); + + const displayValueList = isShowPercent ? percentValueList : valueList; + + // tp = true positive,答案是1、预测是1 + // tn = true negative,答案是0、预测是0 + // fp = false positive,预测是1,答案是0 + // fn = false negative,预测是0,答案是1 + return ( + + + {isEmpty ? ( + <CenterLayout style={{ margin: '0 auto' }}> + <NoResult.NoData /> + </CenterLayout> + ) : ( + <Content> + <TopTitle>Actual class</TopTitle> + <LeftTitle>Predicted</LeftTitle> + <RightTopTitle>0</RightTopTitle> + <RightBottomTitle>1</RightBottomTitle> + <BottomLeftTitle>0</BottomLeftTitle> + <BottomRightTitle>1</BottomRightTitle> + <LeftTitle>Predicted</LeftTitle> + <Item>{displayValueList?.[3] ?? ''}</Item> + <Item>{displayValueList?.[1] ?? ''}</Item> + <Item>{displayValueList?.[2] ?? ''}</Item> + <Item>{displayValueList?.[0] ?? ''}</Item> + <PercentContainer> + <TitleWithIcon + title={i18n.t('model_center.title_confusion_matrix_normalization')} + isShowIcon={true} + isBlock={false} + tip={i18n.t('model_center.tip_confusion_matrix_normalization')} + icon={QuestionCircle} + /> + <Switch checked={isShowPercent} onChange={onSwitchChange} /> + </PercentContainer> + <Bar /> + </Content> + )} + </Card> + ); + + function onSwitchChange(val: boolean) { + setIsShowPercent(val); + } +}; + +export const ModelEvaluationVariant: React.FC<ModelEvaluationVariantProps> = ({ + id, + participantId, +}) => { + const { data } = useModelMetriesResult(id, participantId); + + const valueList = useMemo(() => { + if (!data) { + return []; + } + const { confusion_matrix = {} } = data; + return [confusion_matrix.tp, confusion_matrix.fn, confusion_matrix.fp, confusion_matrix.tn].map( + (item) => { + switch (typeof item) { + case 'string': + return parseInt(item); + case 'number': + return item; + case 'undefined': + default: + return 0; + } + }, + ); + }, [data]); + + return ( + <ConfusionMatrix + valueList={valueList} + isEmpty={valueList.length === 0 || valueList.every((v) => v === 0)} + /> + ); +}; + +ConfusionMatrix.ModelEvaluationVariant = ModelEvaluationVariant; + +export default ConfusionMatrix; diff --git a/web_console_v2/client/src/components/CountTime/index.test.tsx b/web_console_v2/client/src/components/CountTime/index.test.tsx new file mode 100644 index 000000000..f0a06b772 --- /dev/null +++ b/web_console_v2/client/src/components/CountTime/index.test.tsx @@ -0,0 +1,299 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import CountTime from './index'; + +describe('<CountTime />', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + describe('count down', () => { + it('should render correctly and trigger onCountDownFinish one time when countdown to zero', () => { + const onCountDownFinish = jest.fn(); + const wrapper = render( + <CountTime time={10} isCountDown={true} onCountDownFinish={onCountDownFinish} />, + ); + expect(wrapper.queryByText('00:00:10')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(wrapper.queryByText('00:00:09')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(wrapper.queryByText('00:00:08')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(999); + }); + expect(wrapper.queryByText('00:00:08')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(wrapper.queryByText('00:00:07')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(7000); + }); + expect(wrapper.queryByText('00:00:00')).toBeInTheDocument(); + expect(onCountDownFinish).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(wrapper.queryByText('00:00:00')).toBeInTheDocument(); + expect(onCountDownFinish).toHaveBeenCalledTimes(1); + }); + + it('should only render second', () => { + const onCountDownFinish = jest.fn(); + const wrapper = render( + <CountTime + time={70} + isCountDown={true} + onCountDownFinish={onCountDownFinish} + isOnlyShowSecond={true} + />, + ); + expect(wrapper.queryByText('70')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(wrapper.queryByText('69')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(wrapper.queryByText('68')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(999); + }); + expect(wrapper.queryByText('68')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(wrapper.queryByText('67')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(7000); + }); + expect(wrapper.queryByText('60')).toBeInTheDocument(); + expect(onCountDownFinish).toHaveBeenCalledTimes(0); + + act(() => { + jest.advanceTimersByTime(60000); + }); + expect(wrapper.queryByText('0')).toBeInTheDocument(); + expect(onCountDownFinish).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(wrapper.queryByText('0')).toBeInTheDocument(); + expect(onCountDownFinish).toHaveBeenCalledTimes(1); + }); + }); + + describe('count up', () => { + it('should render correctly', () => { + const wrapper = render(<CountTime time={10} isCountDown={false} />); + expect(wrapper.queryByText('00:00:10')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(wrapper.queryByText('00:00:11')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(wrapper.queryByText('00:00:12')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(999); + }); + expect(wrapper.queryByText('00:00:12')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(wrapper.queryByText('00:00:13')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(7000); + }); + + expect(wrapper.queryByText('00:00:20')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(41000); + }); + + expect(wrapper.queryByText('00:01:01')).toBeInTheDocument(); + }); + + it('should only render second', () => { + const wrapper = render(<CountTime time={70} isCountDown={false} isOnlyShowSecond={true} />); + expect(wrapper.queryByText('70')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(wrapper.queryByText('71')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(wrapper.queryByText('72')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(999); + }); + expect(wrapper.queryByText('72')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(wrapper.queryByText('73')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(7000); + }); + expect(wrapper.queryByText('80')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(60000); + }); + expect(wrapper.queryByText('140')).toBeInTheDocument(); + }); + }); + + it('should render static time', () => { + const wrapper = render(<CountTime time={10} isCountDown={true} isStatic={true} />); + expect(wrapper.queryByText('00:00:10')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(wrapper.queryByText('00:00:10')).toBeInTheDocument(); + }); + + describe('change isStatic prop', () => { + it('should reset time on change isStatic prop', () => { + const wrapper = render( + <CountTime time={10} isCountDown={true} isStatic={false} isResetOnChange={true} />, + ); + expect(wrapper.queryByText('00:00:10')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(wrapper.queryByText('00:00:08')).toBeInTheDocument(); + + wrapper.rerender( + <CountTime time={10} isCountDown={true} isStatic={true} isResetOnChange={true} />, + ); + expect(wrapper.queryByText('00:00:10')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(wrapper.queryByText('00:00:10')).toBeInTheDocument(); + }); + + it('should not reset time on change prop', () => { + const wrapper = render( + <CountTime time={10} isCountDown={true} isStatic={false} isResetOnChange={false} />, + ); + expect(wrapper.queryByText('00:00:10')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(wrapper.queryByText('00:00:08')).toBeInTheDocument(); + + wrapper.rerender( + <CountTime time={10} isCountDown={true} isStatic={true} isResetOnChange={false} />, + ); + expect(wrapper.queryByText('00:00:08')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(wrapper.queryByText('00:00:08')).toBeInTheDocument(); + }); + }); + + it('should support render props mode', () => { + const onCountDownFinish = jest.fn(); + + const wrapper = render( + <CountTime + time={10} + isCountDown={true} + isRenderPropsMode={true} + onCountDownFinish={onCountDownFinish} + > + {(formattedTime: string, noFormattedTime: number) => { + return ( + <> + <div>formattedTime: {formattedTime}</div> + <div>noFormattedTime: {noFormattedTime}</div> + </> + ); + }} + </CountTime>, + ); + + expect(wrapper.queryByText('formattedTime: 00:00:10')).toBeInTheDocument(); + expect(wrapper.queryByText('noFormattedTime: 10')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(wrapper.queryByText('formattedTime: 00:00:05')).toBeInTheDocument(); + expect(wrapper.queryByText('noFormattedTime: 5')).toBeInTheDocument(); + expect(onCountDownFinish).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(wrapper.queryByText('formattedTime: 00:00:00')).toBeInTheDocument(); + expect(wrapper.queryByText('noFormattedTime: 0')).toBeInTheDocument(); + expect(onCountDownFinish).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(wrapper.queryByText('formattedTime: 00:00:00')).toBeInTheDocument(); + expect(wrapper.queryByText('noFormattedTime: 0')).toBeInTheDocument(); + expect(onCountDownFinish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web_console_v2/client/src/components/CronTimePicker/index.module.less b/web_console_v2/client/src/components/CronTimePicker/index.module.less new file mode 100644 index 000000000..6baa287f8 --- /dev/null +++ b/web_console_v2/client/src/components/CronTimePicker/index.module.less @@ -0,0 +1,4 @@ +.time_picker_container{ + display: grid; + grid-template-columns: 1fr 10px 1fr 10px 1fr; +} diff --git a/web_console_v2/client/src/components/CronTimePicker/index.tsx b/web_console_v2/client/src/components/CronTimePicker/index.tsx new file mode 100644 index 000000000..bdd6ab942 --- /dev/null +++ b/web_console_v2/client/src/components/CronTimePicker/index.tsx @@ -0,0 +1,153 @@ +import React, { FC, useState } from 'react'; +import { TimePicker, Select } from '@arco-design/web-react'; +import dayjs, { Dayjs } from 'dayjs'; +import objectSupport from 'dayjs/plugin/objectSupport'; + +import styles from './index.module.less'; + +dayjs.extend(objectSupport); + +const { Option } = Select; + +export interface PickerValue { + method: string; + weekday?: number; + time: Dayjs | null; +} + +type Props = { + value?: PickerValue; + onChange?: (value: PickerValue) => void; +}; + +const CronTimePicker: FC<Props> = ({ value, onChange }) => { + const [method, setMethod] = useState(value?.method!); + const [weekday, setWeekday] = useState(value?.weekday || 0); + const [time, setTime] = useState<Dayjs | null>(value?.time || null); + + return ( + <div className={styles.time_picker_container}> + <Select + onChange={(val) => { + setMethod(val); + onChange && onChange({ method: val, weekday, time }); + }} + value={method} + > + <Option value="hour">每时</Option> + <Option value="day">每天</Option> + <Option value="week">每周</Option> + </Select> + <div /> + {method === 'week' && ( + <> + <Select + onChange={(val) => { + setWeekday(val); + onChange && onChange({ method, weekday: val, time }); + }} + value={weekday} + > + <Option value={0}>星期天</Option> + <Option value={1}>星期一</Option> + <Option value={2}>星期二</Option> + <Option value={3}>星期三</Option> + <Option value={4}>星期四</Option> + <Option value={5}>星期五</Option> + <Option value={6}>星期六</Option> + </Select> + <div /> + </> + )} + + <TimePicker + value={time as any} + onChange={(_, val: any) => { + setTime(val); + onChange && onChange({ method, weekday: val, time: val }); + }} + format={method === 'hour' ? 'mm 分' : 'HH 时 : mm 分'} + showNowBtn={false} + placeholder={method === 'hour' ? '- 分' : '- 时 - 分'} + /> + </div> + ); +}; + +/** + * PickerValue in local format -> Cron in UTC format + * @param value + * @returns + */ +export function toCron(value: PickerValue) { + const { method, weekday, time } = formatWithUtc(value, true); + let cron = 'null'; + if (time) { + if (method === 'week') { + cron = `${time.minute()} ${time.hour()} * * ${weekday}`; + } else if (method === 'day') { + cron = `${time.minute()} ${time.hour()} * * *`; + } else if (method === 'hour') { + cron = `${time.minute()} * * * *`; + } + } + return cron; +} + +/** + * Cron in UTC format -> PickerValue in local format + * @param cron + * @returns + */ +export function parseCron(cron: string) { + const parsed: PickerValue = { + method: 'day', + time: null, + }; + if (cron && cron !== 'null') { + const cronArray = cron.split(' '); + const cronLen = cronArray.length; + if (cronArray[cronLen - 1] !== '*') { + // This means that the time is based on the day of the week + parsed.weekday = Number(cronArray[cronLen - 1]); + parsed.method = 'week'; + } + if (cronLen === 5) { + if (cronArray[1] === '*') { + parsed.method = 'hour'; + parsed.time = dayjs().set({ + minute: Number(cronArray[0]), + second: 0, + }); + } else { + parsed.time = dayjs().set({ + hour: Number(cronArray[1]), + minute: Number(cronArray[0]), + }); + } + } + } + return formatWithUtc(parsed, false); +} + +export function formatWithUtc({ method, weekday, time }: PickerValue, isToUtc: boolean) { + if (time) { + let offsetHour = dayjs().utcOffset() / 60; + !isToUtc && (offsetHour = 0 - offsetHour); + const newHour = time.hour() - offsetHour; + if (method === 'week' && weekday !== undefined) { + let utcWeekday = weekday; + if (newHour < 0) { + utcWeekday -= 1; + } + if (newHour > 23) { + utcWeekday += 1; + } + weekday = (utcWeekday + 7) % 7; + } + time = dayjs().set({ second: time.second(), minute: time.minute(), hour: (newHour + 24) % 24 }); + } + return { method, weekday, time }; +} + +export default CronTimePicker; diff --git a/web_console_v2/client/src/components/DataPreview/PictureDataTable/PictureList.tsx b/web_console_v2/client/src/components/DataPreview/PictureDataTable/PictureList.tsx new file mode 100644 index 000000000..944c01cac --- /dev/null +++ b/web_console_v2/client/src/components/DataPreview/PictureDataTable/PictureList.tsx @@ -0,0 +1,92 @@ +/* istanbul ignore file */ + +import React, { FC } from 'react'; +import styled from 'styled-components'; +import GridRow from 'components/_base/GridRow'; +import { ImageDetail } from 'typings/dataset'; + +const Container = styled.div` + --cols: 6; + + display: grid; + grid-template-columns: repeat(var(--cols), 1fr); + align-items: start; + justify-content: space-between; + grid-gap: 15px; + width: 100%; + padding: 15px 20px; + min-width: 550px; + + @media screen and (max-width: 1600px) { + --cols: 5; + } + + @media screen and (max-width: 1500px) { + --cols: 4; + } +`; + +const CardContainer = styled.div` + text-align: center; +`; +const Name = styled.div` + color: var(--textColorStrongSecondary); +`; + +const Size = styled.div` + color: var(--textColorSecondary); +`; +const PictureContainer = styled(GridRow)` + min-width: 88px; + min-height: 88px; + background-color: #f6f7fb; + border-radius: 4px; + cursor: pointer; +`; +const StyledImg = styled.img` + height: 66px; + width: 66px; + border-radius: 5px; + /* Crop the picture */ + object-fit: cover; +`; + +const PictureCard: FC<{ data: ImageDetail; onClick?: (data: ImageDetail) => void }> = ({ + data, + onClick, +}) => { + return ( + <CardContainer> + <PictureContainer + justify="center" + align="center" + onClick={() => { + onClick?.(data); + }} + > + <StyledImg + alt={data?.annotation?.caption || '照片显示错误'} + src={data?.uri} + title={data?.annotation?.caption} + /> + </PictureContainer> + <Name>{data.name}</Name> + <Size>{`${data.width} × ${data.height}`}</Size> + </CardContainer> + ); +}; + +const PictureList: FC<{ data: ImageDetail[]; onClick?: (data: ImageDetail) => void }> = ({ + data, + onClick, +}) => { + return ( + <Container> + {data.map((item, index) => ( + <PictureCard data={item} key={`pic-${item.file_name}`} onClick={onClick} /> + ))} + </Container> + ); +}; + +export default PictureList; diff --git a/web_console_v2/client/src/components/DataPreview/PictureDataTable/index.tsx b/web_console_v2/client/src/components/DataPreview/PictureDataTable/index.tsx new file mode 100644 index 000000000..223289581 --- /dev/null +++ b/web_console_v2/client/src/components/DataPreview/PictureDataTable/index.tsx @@ -0,0 +1,326 @@ +/* istanbul ignore file */ + +import React, { FC, useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import { isEmpty, isNil } from 'lodash-es'; + +import { transformRegexSpecChar } from 'shared/helpers'; +import { CONSTANTS } from 'shared/constants'; + +import { Input, Message, Select, Spin, Tooltip } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; + +import NoResult from 'components/NoResult'; +import PictureList from './PictureList'; + +import { ImageDetail, PreviewData } from 'typings/dataset'; +import { MixinEllipsis } from 'styles/mixins'; + +const { Option } = Select; + +const Container = styled.div` + display: grid; + grid-template-columns: 180px 0.6fr 0.4fr; + border: 1px solid var(--lineColor); + width: 100%; +`; + +const ColumnContainer = styled.div` + display: grid; + grid-template-rows: 36px 1fr; + height: 100%; +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 20px; + border-bottom: 1px solid var(--lineColor); + border-right: 1px solid var(--lineColor); + color: #000000; + font-size: 12px; +`; + +const Body = styled.div` + height: 557px; + overflow-y: auto; + border-right: 1px solid var(--lineColor); +`; + +const StyledGridRow = styled(GridRow)` + padding: 6px 20px; + cursor: pointer; + + .itemNum { + background-color: #f6f7fb; + border-radius: 32px; + padding: 0 6px; + } + + &:hover, + &[data-active='true'] { + background-color: #f6f7fb; + + .itemValue { + color: var(--primaryColor); + } + + .itemNum { + background-color: #ffffff; + } + } +`; + +const StyledFileName = styled.div` + ${MixinEllipsis()}; + width: 90%; + color: var(--textColorStrong); +`; + +const StyledSize = styled.span` + background-color: #f6f7fb; + border-radius: 8px; + padding: 0 4px; + color: var(--textColorStrongSecondary); +`; + +type Props = { + data?: PreviewData; + loading?: boolean; + isError?: boolean; + noResultText?: string; +}; +const PictureDataPreviewTable: FC<Props> = ({ data, loading, isError, noResultText }) => { + const { t } = useTranslation(); + /** active label */ + const [active, setActive] = useState(''); + const [labelOrder, setLabelOrder] = useState('ascending'); + const [filterText, setFilterText] = useState(''); + const [pictureOnFocus, setPictureOnFocus] = useState<ImageDetail>(); + + const formatData = useMemo(() => { + if (!data) { + return undefined; + } + + const map: { [key: string]: ImageDetail[] } = {}; + + data?.images?.forEach((item) => { + const labelName = item?.annotation?.label ?? t('no_label'); + if (map[labelName] === undefined) { + map[labelName] = []; + } + map[labelName].push({ + ...item, + uri: `/api/v2/image?name=${item.path}`, + }); + }); + + return { ...data, formatImages: map }; + }, [data, t]); + + const labelList = useMemo(() => { + const list: Array<{ count: number; value: string }> = []; + const map: { [key: string]: number } = {}; + + data?.images?.forEach((item) => { + const labelName = item?.annotation?.label ?? t('no_label'); + + if (map[labelName] === undefined) { + map[labelName] = 0; + } + map[labelName]++; + }); + + Object.keys(map).forEach((key) => { + const value = map[key]; + list.push({ + value: key, + count: value, + }); + }); + + return list; + }, [data, t]); + + useEffect(() => { + if (labelList.length && !active) { + setActive(labelList[0].value); + } + }, [active, labelList]); + + const filterTagList = useMemo(() => { + const regx = new RegExp(`^.*${transformRegexSpecChar(filterText)}.*$`); + return labelList.filter((item: any) => { + return regx.test(item.value); + }); + }, [filterText, labelList]); + + const tagShowList = useMemo(() => { + if (filterTagList) { + const list = [...filterTagList]; + switch (labelOrder) { + case 'ascending': + return list.sort((a: any, b: any) => { + return a.count - b.count; + }); + + case 'descending': + return list.sort((a: any, b: any) => { + return b.count - a.count; + }); + case 'beginA': + return list.sort((a: any, b: any) => { + return a.value.localeCompare(b.value); + }); + case 'endA': + return list.sort((a: any, b: any) => { + return b.value.localeCompare(a.value); + }); + default: + Message.error('未知选项'); + return list; + } + } + return []; + }, [filterTagList, labelOrder]); + + const imageList = useMemo(() => { + if (active && formatData && formatData.formatImages) { + const list = [...formatData.formatImages[active]]; + + return list; + } + return []; + }, [active, formatData]); + + useEffect(() => { + if (imageList && !pictureOnFocus) { + setPictureOnFocus(imageList[0]); + } + }, [imageList, pictureOnFocus]); + + if (isError) { + return <NoResult text={t('dataset.tip_state_error')} />; + } + + if (loading) { + return ( + <GridRow style={{ height: '100%' }} justify="center"> + <Spin loading={true} /> + </GridRow> + ); + } + + if (isNil(data)) { + return <NoResult text={t('dataset.tip_state_error')} />; + } + + if (noResultText) { + return <NoResult text={noResultText} />; + } + + if (!labelList || isEmpty(labelList)) { + return <NoResult text={t('dataset.tip_state_error')} />; + } + return ( + <Container> + <ColumnContainer> + <Header style={{ paddingLeft: '12px' }}> + <Select + defaultValue="ascending" + bordered={false} + size="small" + style={{ color: '#000000', fontSize: 12 }} + onChange={(value) => { + setLabelOrder(value); + }} + value={labelOrder} + dropdownMenuStyle={{ width: 'auto' }} + > + <Option value="ascending">数据量升序</Option> + <Option value="descending">数据量降序</Option> + <Option value="beginA">按字母 A-Z</Option> + <Option value="endA">按字母 Z-A</Option> + </Select> + </Header> + <Body> + <div style={{ padding: '6px 13px' }}> + <Input.Search + placeholder="搜索..." + allowClear + size="small" + onChange={(value) => { + setFilterText(value); + }} + /> + </div> + {tagShowList.map((item, index) => { + if (!active && !index) { + setActive(item.value); + } + return ( + <StyledGridRow + justify="space-between" + data-active={active === item.value} + onClick={() => { + setActive(item.value); + setPictureOnFocus(undefined); + }} + key={`tag-${index}`} + > + <span className="itemValue">{item.value}</span> + <span className="itemNum">{item.count}</span> + </StyledGridRow> + ); + })} + </Body> + </ColumnContainer> + <ColumnContainer> + <Header> + <span style={{ color: 'var(--textColorStrongSecondary)' }}> + 以下展示该标签下的20张样例 + </span> + </Header> + <Body> + <PictureList + data={imageList} + onClick={(item) => { + setPictureOnFocus(item); + }} + /> + </Body> + </ColumnContainer> + <ColumnContainer> + <Header style={{ borderRight: 0 }}> + <GridRow gap={10}> + <Tooltip content={pictureOnFocus?.name ?? ''}> + <StyledFileName>{pictureOnFocus?.name ?? CONSTANTS.EMPTY_PLACEHOLDER}</StyledFileName> + </Tooltip> + <StyledSize>{`${pictureOnFocus?.width || 0} × ${ + pictureOnFocus?.height || 0 + } pixels`}</StyledSize> + </GridRow> + <span + style={{ color: 'var(--textColorStrongSecondary)' }} + >{`${pictureOnFocus?.file_name?.split('.').pop()}`}</span> + </Header> + <Body style={{ borderRight: 0 }}> + <div style={{ height: '100%', minHeight: '100%', margin: '0 10%' }}> + <GridRow justify="center" align="center" style={{ height: '100%' }}> + <img + alt={pictureOnFocus?.annotation?.caption || '照片显示错误'} + src={pictureOnFocus?.uri} + title={pictureOnFocus?.annotation?.caption} + /> + </GridRow> + </div> + </Body> + </ColumnContainer> + </Container> + ); +}; + +export default PictureDataPreviewTable; diff --git a/web_console_v2/client/src/components/DataPreview/StructDataTable/FeatureInfoDrawer.module.less b/web_console_v2/client/src/components/DataPreview/StructDataTable/FeatureInfoDrawer.module.less new file mode 100644 index 000000000..13292ed32 --- /dev/null +++ b/web_console_v2/client/src/components/DataPreview/StructDataTable/FeatureInfoDrawer.module.less @@ -0,0 +1,33 @@ +.drawer_container{ + :global{ + .arco-drawer-content{ + padding-top: 0; + padding-bottom: 200px; + } + } +} +.drawer_header{ + position: sticky; + z-index: 2; + top: 0; + margin: 0 -24px 0; + padding: 10px 16px 10px 24px; + background-color: white; + border-bottom: 1px solid var(--lineColor); +} +.feature_key{ + position: relative; + margin-bottom: 0; + margin-right: 10px; +} +.info_table{ + margin-top: 30px; + border: 1px solid var(--lineColor); + border-radius: 4px; +} +.chart_container{ + padding: 20px 16px; + margin-top: 20px; + border: 1px solid var(--lineColor); + border-radius: 4px; +} diff --git a/web_console_v2/client/src/components/DataPreview/StructDataTable/FeatureInfoDrawer.tsx b/web_console_v2/client/src/components/DataPreview/StructDataTable/FeatureInfoDrawer.tsx new file mode 100644 index 000000000..ed41b7b82 --- /dev/null +++ b/web_console_v2/client/src/components/DataPreview/StructDataTable/FeatureInfoDrawer.tsx @@ -0,0 +1,190 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo } from 'react'; +import { Drawer, DrawerProps, Grid, Button, Table } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import { CaretDown, CaretUp, Close } from 'components/IconPark'; +import { Bar } from 'react-chartjs-2'; +import { floor } from 'lodash-es'; +import { CONSTANTS } from 'shared/constants'; + +import styles from './FeatureInfoDrawer.module.less'; + +const { Row } = Grid; + +type HistDataset = { + data: number[]; + label?: string; + backgroundColor?: string; +}; + +export const formatChartData = (labels: number[], datasets: HistDataset[]) => ({ + labels: labels + .map((v) => floor(v, 1)) + .reduce((acc, curr, index, arr) => { + if (arr[index + 1]) { + acc.push(`[${curr}, ${arr[index + 1]}]`); + } + return acc; + }, [] as string[]), + + datasets: datasets.map((data) => { + return Object.assign({ label: '数据集', backgroundColor: '#468DFF' }, data); + }), +}); + +interface Props extends DrawerProps { + data?: any[]; + histData?: ReturnType<typeof formatChartData>; + compareWithBase?: boolean; + loading?: boolean; + featureKey?: string; + toggleVisible: (val: boolean) => void; + onClose?: () => void; +} + +const barChartOptions: Chart.ChartOptions = { + scales: { + xAxes: [ + { + ticks: { + beginAtZero: false, + fontSize: 8, + }, + }, + ], + }, +}; + +export const METRIC_KEY_TRANSLATE_MAP: { [key: string]: string } = { + count: '样本数', + mean: '平均值', + stddev: '标准差', + min: '最小值', + max: '最大值', + missing_count: '缺失数', + missing_rate: '缺失率', +}; + +export const FEATURE_DRAWER_ID = 'feature_drawer'; + +const FeatureInfoDrawer: FC<Props> = ({ + featureKey, + data, + histData, + loading, + onClose, + toggleVisible, + compareWithBase, + ...props +}) => { + const columns = useMemo(() => { + return !compareWithBase + ? [ + { + title: '参数', + dataIndex: 'key', + width: '100px', + }, + { + title: '求交数据集', + dataIndex: 'value', + }, + ] + : [ + { + title: '参数', + dataIndex: 'key', + width: '100px', + }, + { + title: '原始数据集', + dataIndex: 'baseValue', + }, + + { + title: '求交数据集', + dataIndex: 'value', + }, + { + title: '对比', + dataIndex: 'diff', + render: (val: any, record: { baseValue: number; diff: number; isPercent: boolean }) => { + let isShowUpIcon = false; + + // If isPercent = true, diff is string, like '99%','-88%' + if (record.isPercent) { + const strDiff = String(record.diff); + isShowUpIcon = + strDiff.length > 0 ? strDiff[0] !== CONSTANTS.EMPTY_PLACEHOLDER : false; + } else { + isShowUpIcon = val >= 0; + } + + return ( + <GridRow gap={5}> + {isShowUpIcon ? ( + <CaretUp style={{ color: 'var(--successColor)' }} /> + ) : ( + <CaretDown style={{ color: 'var(--errorColor)' }} /> + )} + {record.isPercent + ? record.diff + : floor((record.diff / (record.baseValue || 1)) * 100, 2) + '%'} + </GridRow> + ); + }, + }, + ]; + }, [compareWithBase]); + return ( + <Drawer + maskStyle={{ backdropFilter: 'blur(3px)' }} + width="520px" + onCancel={closeDrawer} + headerStyle={{ display: 'none' }} + footer={null} + focusLock={true} + maskClosable={true} + {...props} + > + <div id={FEATURE_DRAWER_ID} style={{ height: '100%' }}> + <Row + id={FEATURE_DRAWER_ID} + className={styles.drawer_header} + align="center" + justify="space-between" + > + <Row align="center"> + <h3 className={styles.feature_key}>{featureKey}</h3> + </Row> + <GridRow gap="10"> + <Button size="small" icon={<Close />} onClick={closeDrawer} /> + </GridRow> + </Row> + + <Table + className={`${styles.info_table} custom-table`} + rowKey={'key'} + size="small" + columns={columns} + pagination={false} + data={data ?? []} + loading={loading} + /> + {histData && ( + <div className={styles.chart_container}> + <Bar data={histData} options={barChartOptions} /> + </div> + )} + </div> + </Drawer> + ); + + function closeDrawer() { + toggleVisible && toggleVisible(false); + onClose && onClose(); + } +}; + +export default FeatureInfoDrawer; diff --git a/web_console_v2/client/src/components/DataPreview/StructDataTable/hooks.tsx b/web_console_v2/client/src/components/DataPreview/StructDataTable/hooks.tsx new file mode 100644 index 000000000..bbf409e2f --- /dev/null +++ b/web_console_v2/client/src/components/DataPreview/StructDataTable/hooks.tsx @@ -0,0 +1,31 @@ +/* istanbul ignore file */ + +import { useEffect } from 'react'; +import { STRUCT_DATA_TABLE_ID } from '.'; +import { FEATURE_DRAWER_ID } from './FeatureInfoDrawer'; + +export function useFeatureDrawerClickOutside(params: { + setActiveFeatKey: (key?: string) => void; + toggleDrawerVisible: (val: boolean) => void; + allowlistElementIds?: string[]; +}) { + useEffect(() => { + document.addEventListener('click', handler); + + function handler(evt: MouseEvent) { + const target = evt.target as HTMLElement; + if ( + !document.getElementById(STRUCT_DATA_TABLE_ID)?.contains(target) && + !document.getElementById(FEATURE_DRAWER_ID)?.contains(target) + ) { + params.setActiveFeatKey(undefined); + params.toggleDrawerVisible(false); + } + } + + return () => { + document.removeEventListener('click', handler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params.setActiveFeatKey, params.toggleDrawerVisible]); +} diff --git a/web_console_v2/client/src/components/DataPreview/StructDataTable/index.tsx b/web_console_v2/client/src/components/DataPreview/StructDataTable/index.tsx new file mode 100644 index 000000000..631889669 --- /dev/null +++ b/web_console_v2/client/src/components/DataPreview/StructDataTable/index.tsx @@ -0,0 +1,419 @@ +/* istanbul ignore file */ + +import React, { FC, memo, useRef, useMemo } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import { isEmpty, isNil, floor } from 'lodash-es'; + +import { Table, Spin, Checkbox } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import NoResult from 'components/NoResult'; + +import { PreviewData, ValueType } from 'typings/dataset'; +import { TABLE_COL_WIDTH } from 'shared/constants'; + +const Container = styled.div` + ${(props: any) => + props.activeIndex === 1 + ? '' + : `.arco-table-th:nth-child(${props.activeIndex}), .arco-table-td:nth-child(${props.activeIndex}) { + --activeBackground: rgba(22, 100, 255, 0.06); + --summaryBackground: transparent; + }`} +`; + +const PreviewTable = styled(Table)` + border: 1px solid var(--lineColor); + border-radius: 4px; + width: auto; + + .arco-table-th-item, + .arco-table-td { + padding: 0; + } + + .arco-table-col-fixed-left:first-of-type { + text-align: center; + background: var(--color-fill-2); + } + + .arco-table-cell { + word-break: break-word; + } +`; +const SummaryCol = styled.div` + padding: 8px; + font-size: 12px; + line-height: 22px; + white-space: nowrap; + background-color: var(--summaryBackground, #ffffff) !important; +`; +const SummaryLabelCol = styled(SummaryCol)` + margin-left: -40px; + padding-left: 30px; +`; +const SummaryColCell = styled.div``; +const DataTypeCell = styled(SummaryColCell)` + color: var(--textColorSecondary); +`; +const ClickableHeader = styled.label` + display: flex; + padding: 8px; + font-size: 12px; + text-decoration: underline; + white-space: nowrap; + cursor: pointer; + border-right: 1px solid var(--lineColor); + border-bottom: 2px solid var(--lineColor); + background-color: #ffffff; + + &[data-is-avtive='true'] { + background-color: var(--activeBackground) !important; + box-shadow: 0 2.5px 0 0 var(--primaryColor) inset; + } + + &:hover { + color: var(--primaryColor); + } +`; + +const TableCell = styled.label` + display: flex; + padding: 12px 8px; + font-size: 12px; + &[data-is-avtive='true'] { + background-color: var(--activeBackground); + } +`; + +const SummaryTd = styled.td` + background-color: #fafafa; + text-align: center; +`; +const CheckboxConatiner = styled.div` + margin-left: 5px; + transform: scale(0.875); +`; + +export const STRUCT_DATA_TABLE_ID = 'struct_data_table'; + +/** + * Format origin value to display value + */ +export function formatOriginValue(originValue: string | number, type?: ValueType) { + if (type === 'string') { + return originValue; + } + /** + * origin value / display value + * missing value => "null" + * string "null" => "null" + * string "nan" => "NaN" + */ + let displayValue: string | number; + if (originValue == null || originValue === 'null') { + displayValue = 'null'; + } else if (originValue === 'nan') { + displayValue = 'NaN'; + } else { + displayValue = parseFloat(Number(originValue).toFixed(3)); + } + return displayValue; +} + +const FeatureMetric: FC<{ + type?: string; + missingCount?: string | number; + baseMissingCount?: string | number; + count?: string | number; + baseCount?: string | number; +}> = memo(({ type, missingCount, baseMissingCount, count, baseCount }) => { + let tempMissingCount = 0; + let tempAllCount = 0; + let missingRate = 'N/A'; + + let tempBaseMissingCount = 0; + let tempBaseAllCount = 0; + let baseMissingRate = 'N/A'; + + if (missingCount !== 'N/A') { + tempMissingCount = Number(missingCount) || 0; + tempAllCount = tempMissingCount + (Number(count) || 0); + missingRate = floor((tempMissingCount / tempAllCount) * 100, 2) + '%'; + } + if (baseMissingCount !== 'N/A') { + tempBaseMissingCount = Number(baseMissingCount) || 0; + tempBaseAllCount = tempBaseMissingCount + (Number(baseCount) || 0); + baseMissingRate = floor((tempBaseMissingCount / tempBaseAllCount) * 100, 2) + '%'; + } + + return ( + <SummaryCol> + <DataTypeCell>{type}</DataTypeCell> + {baseMissingCount && <SummaryColCell>{baseMissingRate}</SummaryColCell>} + <SummaryColCell>{missingRate}</SummaryColCell> + </SummaryCol> + ); +}); + +const CustomRow: React.FC<{ index: number; maxCount: number; featuresKeysCount: number }> = ( + props, +) => { + const { index, maxCount, featuresKeysCount, children, ...restProps } = props; + + // Render summary row + if (index >= maxCount) { + return ( + <tr {...restProps}> + <SummaryTd colSpan={featuresKeysCount + 1}> + <span>以上为取 {maxCount.toLocaleString('en')} 条样本数据</span> + </SummaryTd> + </tr> + ); + } + + return <tr children={children} {...restProps} />; +}; + +type Props = { + data?: PreviewData; + loading?: boolean; + compareWithBase?: boolean; + baseData?: PreviewData; + datasetName?: string; + activeKey?: string; + checkable?: boolean; + checkedKeys?: string[]; + noResultText?: string; + isError?: boolean; + onCheckedChange?: (keys: string[]) => void; + onActiveFeatChange?: (k: string) => void; +}; + +const StructDataPreviewTable: FC<Props> = ({ + datasetName = '', + loading, + data, + baseData, + activeKey, + onActiveFeatChange, + compareWithBase, + checkable, + checkedKeys = [], + onCheckedChange, + noResultText, + isError, +}) => { + const dom = useRef<HTMLDivElement>(); + const { t } = useTranslation(); + + const [featuresKeys, featuresTypes] = useMemo(() => { + const featuresKeys: string[] = []; + const featuresTypes: ValueType[] = []; + + data?.dtypes?.forEach((item) => { + featuresKeys.push(item.key); + featuresTypes.push(item.value); + }); + + return [featuresKeys, featuresTypes]; + }, [data]); + + if (isError) { + return <NoResult text={t('dataset.tip_state_error')} />; + } + + if (loading) { + return ( + <GridRow style={{ height: '100%' }} justify="center"> + <Spin loading={true} /> + </GridRow> + ); + } + if (isNil(data)) { + return <NoResult text={t('dataset.tip_state_error')} />; + } + + const previewData = data; + + const metrics = previewData.metrics ?? {}; + const count = previewData.count ?? 0; + const sampleCount = previewData.sample?.length ?? 0; + + if (noResultText) { + return <NoResult text={noResultText} />; + } + + if (!previewData.sample || isEmpty(previewData.sample)) { + return <NoResult text={t('dataset.tip_state_error')} />; + } + + if (!previewData.metrics || isEmpty(previewData.metrics)) { + return <NoResult text={t('dataset.tip_state_error')} />; + } + + const list = previewData.sample.map((item) => { + return item.reduce((ret, curr, index) => { + ret[featuresKeys[index] as any] = formatOriginValue(curr, featuresTypes[index]); + return ret; + }, {} as { [key: string]: string | number }); + }); + + const isShowSummaryRow = list.length > 0 && list.length < count; + + // Add summary row + if (isShowSummaryRow) { + list.push({ + id: '__summary__', + }); + } + + const activeKeyIndex = featuresKeys.indexOf(activeKey!); + const headerCellStyle = { + padding: 0, + }; + const columns: any[] = [ + { + fixed: 'left', + children: [ + { + title: ( + <SummaryLabelCol className="head-col"> + <DataTypeCell>类型</DataTypeCell> + {compareWithBase && <SummaryColCell>原始数据集缺失率%</SummaryColCell>} + <SummaryColCell>{compareWithBase && datasetName}缺失率%</SummaryColCell> + </SummaryLabelCol> + ), + fixed: 'left', + width: TABLE_COL_WIDTH.THIN, + dataIndex: 'order', + key: 'order', + headerCellStyle: { + ...headerCellStyle, + backgroundColor: '#fff', + }, + render: (_: any, record: any, index: number) => { + return <span>{index + 1}</span>; + }, + }, + ], + headerCellStyle: { + ...headerCellStyle, + backgroundColor: '#fff', + backgroundImage: `url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAANklEQVR4AZXKBQ3AQBTA0Lke8ybimz2sgjZp8A2m6Y9T4eWLrHBbYBaYBWaBWWAWmAVmgVlgLvftw6nDSDtTAAAAAElFTkSuQmCC')`, + backgroundSize: '8px 8px', + backgroundPosition: 'right bottom', + backgroundRepeat: 'no-repeat', + }, + }, + ...featuresKeys.map((featKey, index) => ({ + key: featKey, + headerCellStyle, + title: ( + <ClickableHeader + data-is-avtive={activeKey === featKey} + onClick={() => onFeatColHeaderClick(featKey)} + > + {featKey} + {checkable && ( + <CheckboxConatiner> + <Checkbox + checked={checkedKeys.includes(featKey)} + onChange={(checked) => onFeatChecked(featKey, checked)} + /> + </CheckboxConatiner> + )} + </ClickableHeader> + ), + children: [ + { + width: Math.max(featKey.length, 7) * 10 + (checkable ? 20 : 0), + headerCellStyle, + title: ( + <FeatureMetric + type={featuresTypes[index]} + missingCount={metrics[featKey]?.missing_count ?? 0} + baseMissingCount={ + compareWithBase + ? (baseData?.metrics && baseData.metrics[featKey]?.missing_count) ?? 'N/A' + : undefined + } + baseCount={ + compareWithBase + ? (baseData?.metrics && baseData.metrics[featKey]?.count) ?? 'N/A' + : undefined + } + count={metrics[featKey]?.count ?? 0} + /> + ), + key: featKey, + dataIndex: featKey, + render: (value: any, record: any, index: number) => { + return renderColumn(value, index, activeKey === featKey); + }, + }, + ], + })), + ]; + + return ( + <Container + id={STRUCT_DATA_TABLE_ID} + ref={(dom as unknown) as any} + {...{ activeIndex: activeKeyIndex + 2 }} + > + <PreviewTable + data={list} + columns={columns} + size="small" + pagination={false} + scroll={{ + x: 'max-content', + y: window.innerHeight - 490, + }} + components={{ + body: { + row: CustomRow, + }, + }} + onRow={(record, index) => { + return { + index, + // Set 999999, in order to don't show summary row + maxCount: isShowSummaryRow ? sampleCount : 999999, + featuresKeysCount: featuresKeys.length, + } as any; + }} + /> + </Container> + ); + + function renderColumn(value: string, index: number, isActive: boolean) { + const obj = { + children: <TableCell data-is-avtive={isActive}>{value}</TableCell>, + props: {} as any, + }; + // Last column of each row consist the summary row on the bottom + if (isShowSummaryRow && index === sampleCount) { + obj.props.colSpan = 0; + } + return obj; + } + + function onFeatColHeaderClick(featKey: string) { + onActiveFeatChange?.(featKey); + } + function onFeatChecked(key: string, checked: boolean) { + if (!checkable) return; + + const nextCheckedKeys = [...checkedKeys]; + if (checked) { + nextCheckedKeys.push(key); + } else { + nextCheckedKeys.splice(nextCheckedKeys.indexOf(key), 1); + } + onCheckedChange?.(nextCheckedKeys); + } +}; + +export default StructDataPreviewTable; diff --git a/web_console_v2/client/src/components/DataSourceSelect/index.module.less b/web_console_v2/client/src/components/DataSourceSelect/index.module.less new file mode 100644 index 000000000..f65c9a84b --- /dev/null +++ b/web_console_v2/client/src/components/DataSourceSelect/index.module.less @@ -0,0 +1,9 @@ +.data_source_select{ + width: 100%; +} +.data_source_select_option_text{ + height: 20px; + line-height: 20px; + color: #86909C; + font-size: 12px; +} diff --git a/web_console_v2/client/src/components/DataSourceSelect/index.tsx b/web_console_v2/client/src/components/DataSourceSelect/index.tsx new file mode 100644 index 000000000..290d2ba6f --- /dev/null +++ b/web_console_v2/client/src/components/DataSourceSelect/index.tsx @@ -0,0 +1,121 @@ +/* istanbul ignore file */ +import React, { FC, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useTranslation } from 'react-i18next'; +import { fetchDataSourceList } from 'services/dataset'; +import { Select, Grid, Tag, Space } from '@arco-design/web-react'; +import { useGetCurrentProjectId } from 'hooks'; + +import { SelectProps } from '@arco-design/web-react/es/Select'; +import { OptionInfo } from '@arco-design/web-react/es/Select/interface'; +import { DataSource, DatasetType } from 'typings/dataset'; +import TitleWithIcon from 'components/TitleWithIcon'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import styled from './index.module.less'; + +const Row = Grid.Row; +const Col = Grid.Col; + +type Props = { + /** extra API query params */ + queryParams?: object; + valueKey?: 'id' | 'uuid'; +} & SelectProps; + +function renderOption(data: DataSource) { + const isStream = data.dataset_type === DatasetType.STREAMING; + let dataDescText = ''; + switch (data.dataset_format) { + case 'TABULAR': + dataDescText = `结构化数据${data.store_format ? '/' + data.store_format : ''}`; + break; + case 'NONE_STRUCTURED': + dataDescText = '非结构化数据'; + break; + case 'IMAGE': + dataDescText = '图片'; + break; + default: + dataDescText = '未知'; + break; + } + return ( + <div> + <Row> + <Col span={18}> + <span>{data.name}</span> + </Col> + <Col span={6}>{isStream ? <Tag color="blue">增量</Tag> : <></>}</Col> + </Row> + <div className={styled.data_source_select_option_text}>{dataDescText}</div> + </div> + ); +} + +export const DataSourceSelect: FC<Props> = ({ + value, + valueKey = 'id', + onChange, + queryParams, + ...props +}) => { + const { t } = useTranslation(); + const projectId = useGetCurrentProjectId(); + const [isShowTip, setShowTip] = useState(false); + const query = useQuery( + ['fetchDataSourceList', projectId], + () => fetchDataSourceList({ projectId: projectId, ...queryParams }), + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const optionList = useMemo(() => { + if (!query.data) { + return []; + } + + return query.data.data.map((item) => ({ + label: renderOption(item), + value: item[valueKey], + extra: item, + })); + }, [query.data, valueKey]); + + const isControlled = typeof value !== 'undefined'; + const valueProps = isControlled ? { value } : {}; + + return ( + <Space direction="vertical" className={styled.data_source_select}> + <Select + placeholder={t('placeholder_select')} + onChange={onSelectChange} + loading={query.isFetching} + showSearch + allowClear + filterOption={(inputValue, option) => { + return option.props.extra.name.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0; + }} + options={optionList} + {...valueProps} + {...props} + /> + {isShowTip && ( + <TitleWithIcon + title="增量数据将检查目录结构,并批量导入。导入后该数据集只能用于求交任务" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + )} + </Space> + ); + + function onSelectChange(id: string, options: OptionInfo | OptionInfo[]) { + setShowTip(options && (options as any).extra.dataset_type === DatasetType.STREAMING); + onChange?.(id, options); + } +}; + +export default DataSourceSelect; diff --git a/web_console_v2/client/src/components/DatasetExportModal/index.module.less b/web_console_v2/client/src/components/DatasetExportModal/index.module.less new file mode 100644 index 000000000..3dd37d7f2 --- /dev/null +++ b/web_console_v2/client/src/components/DatasetExportModal/index.module.less @@ -0,0 +1,4 @@ +.footer_grid_row{ + padding-top: 15px; + border-top: 1px solid var(--backgroundColorGray); +} diff --git a/web_console_v2/client/src/components/DatasetExportModal/index.tsx b/web_console_v2/client/src/components/DatasetExportModal/index.tsx new file mode 100644 index 000000000..2c3474f1d --- /dev/null +++ b/web_console_v2/client/src/components/DatasetExportModal/index.tsx @@ -0,0 +1,84 @@ +import React, { FC, useState } from 'react'; +import { to } from 'shared/helpers'; +import { exportDataset } from 'services/dataset'; +import { Modal, Form, Input, Button, Message } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import ButtonWithPopconfirm from 'components/ButtonWithPopconfirm'; +import { ExportDataset } from 'typings/dataset'; +import { removeFalsy } from 'shared/object'; +import styled from './index.module.less'; +export interface Props { + visible: boolean; + id?: ID; + batchId?: ID; + onSuccess?: (datasetId: ID, datasetJobId: ID) => void; + onFail?: () => void; + onCancel?: () => void; +} + +interface FormData { + export_path: string; +} + +const ExportModal: FC<Props> = ({ id, batchId, visible, onSuccess, onFail, onCancel }) => { + const [isLoading, setIsLoading] = useState(false); + + const [formInstance] = Form.useForm<any>(); + + return ( + <Modal + title="导出数据集" + visible={visible} + maskClosable={false} + afterClose={afterClose} + onCancel={onCancel} + footer={null} + > + <Form layout="vertical" form={formInstance} onSubmit={onSubmit}> + <Form.Item field="export_path" label="导出路径" rules={[{ required: true }]}> + <Input /> + </Form.Item> + + <Form.Item wrapperCol={{ span: 23 }} style={{ marginBottom: 0 }}> + <GridRow className={styled.footer_grid_row} justify="end" gap="12"> + <ButtonWithPopconfirm buttonText="取消" onConfirm={onCancel} /> + <Button type="primary" htmlType="submit" loading={isLoading}> + 确认 + </Button> + </GridRow> + </Form.Item> + </Form> + </Modal> + ); + + async function onSubmit(values: FormData) { + setIsLoading(true); + const [data, err] = await to( + exportDataset( + id!, + removeFalsy({ + batch_id: batchId, + export_path: values.export_path, + }), + ), + ); + if (err) { + setIsLoading(false); + onFail?.(); + Message.error(err.message || '导出失败'); + return; + } + + Message.success('导出成功'); + setIsLoading(false); + const { dataset_job_id, export_dataset_id } = data?.data || ({} as ExportDataset); + onSuccess?.(export_dataset_id, dataset_job_id); + } + + function afterClose() { + // Clear all fields + formInstance.resetFields(); + } +}; + +export default ExportModal; diff --git a/web_console_v2/client/src/components/DatasetJobsType/index.tsx b/web_console_v2/client/src/components/DatasetJobsType/index.tsx new file mode 100644 index 000000000..9de38cbff --- /dev/null +++ b/web_console_v2/client/src/components/DatasetJobsType/index.tsx @@ -0,0 +1,65 @@ +import React, { FC, useMemo } from 'react'; +import styled from 'styled-components'; +import { Tag, TagProps } from '@arco-design/web-react'; +import { useTranslation } from 'react-i18next'; +import { DataJobBackEndType } from 'typings/dataset'; + +type Props = { + style?: React.CSSProperties; + type: DataJobBackEndType; + tagProps?: Partial<TagProps>; +}; + +const Container = styled.div` + display: inline-block; +`; +const StyledModalTag = styled(Tag)` + margin-right: 4px; + font-size: 12px; + vertical-align: top; +`; + +const DatasetJobsType: FC<Props> = ({ style = {}, type, tagProps = {} }) => { + const { t } = useTranslation(); + const [taskType, tagColor]: string[] = useMemo(() => { + if (!type) { + return ['jobType error: type empty']; + } + switch (type) { + case DataJobBackEndType.DATA_JOIN: + case DataJobBackEndType.RSA_PSI_DATA_JOIN: + case DataJobBackEndType.OT_PSI_DATA_JOIN: + case DataJobBackEndType.LIGHT_CLIENT_RSA_PSI_DATA_JOIN: + case DataJobBackEndType.LIGHT_CLIENT_OT_PSI_DATA_JOIN: + case DataJobBackEndType.HASH_DATA_JOIN: + return [t('dataset.label_data_job_type_create'), 'arcoblue']; + case DataJobBackEndType.DATA_ALIGNMENT: + return [t('dataset.label_data_job_type_alignment'), 'arcoblue']; + case DataJobBackEndType.IMPORT_SOURCE: + return [t('dataset.label_data_job_type_import')]; + case DataJobBackEndType.EXPORT: + return [t('dataset.label_data_job_type_export')]; + case DataJobBackEndType.ANALYZER: + return ['数据探查']; + default: + return ['jobType error: unknown type']; + } + }, [t, type]); + + const mergedTagStyle = useMemo<React.CSSProperties>(() => { + return { + fontWeight: 'normal', + ...(tagProps.style ?? {}), + }; + }, [tagProps.style]); + + return ( + <Container style={style}> + <StyledModalTag {...{ ...tagProps, color: tagColor }} style={mergedTagStyle}> + {taskType} + </StyledModalTag> + </Container> + ); +}; + +export default DatasetJobsType; diff --git a/web_console_v2/client/src/components/DatasetPublishAndRevokeModal/index.tsx b/web_console_v2/client/src/components/DatasetPublishAndRevokeModal/index.tsx new file mode 100644 index 000000000..264713b03 --- /dev/null +++ b/web_console_v2/client/src/components/DatasetPublishAndRevokeModal/index.tsx @@ -0,0 +1,211 @@ +import React, { FC, useMemo } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; + +import { postPublishDataset, unpublishDataset } from 'services/dataset'; + +import { Modal, Form, Message, InputNumber, Space } from '@arco-design/web-react'; +import datasetPublishBg from 'assets/images/dataset-publish-bg.png'; +import { Dataset } from 'typings/dataset'; +import creditsIcon from 'assets/icons/credits-icon.svg'; +import i18n from 'i18n'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import { CREDITS_LIMITS } from 'views/Datasets/shared'; +import { useGetAppFlagValue } from 'hooks'; +import { FlagKey } from 'typings/flag'; + +const ContainerModal = styled(Modal)<{ $isPublish: boolean }>` + ${(prop) => + prop.$isPublish + ? ` + .arco-modal-content{ + padding: 0; + } + ` + : ''} + .arco-modal-header { + border-bottom: 0; + } + .arco-modal-footer { + border-top: 0; + text-align: center; + } +`; + +const StyledPublishContent = styled.div` + height: 88px; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + background-image: url(${datasetPublishBg}); + background-size: cover; +`; + +const StyledForm = styled(Form)` + width: 140px; + margin: 16px auto 0; +` as typeof Form; + +const StyledInputNumber = styled(InputNumber)` + width: 140px; +`; + +const StyledCreditIcon = styled.img` + display: inline-block; +`; + +const StyledSpace = styled(Space)` + width: 100%; + height: 32px; + background: #e8f4ff; + justify-content: center; +`; + +const StyleTitleSpace = styled(Space)` + width: 100%; + justify-content: center; +`; + +enum OPERATION_TYPE { + PUBLISH = 'publish', + REVOKE = 'revoke', +} + +export interface Props { + visible: boolean; + dataset?: Dataset; + onSuccess?: () => void; + onFail?: () => void; + onCancel?: () => void; +} + +interface FormData { + value: number; +} + +const DatasetPublishAndRevokeModal: FC<Props> = ({ + dataset, + visible, + onSuccess, + onFail, + onCancel, +}) => { + const bcs_support_enabled = useGetAppFlagValue(FlagKey.BCS_SUPPORT_ENABLED); + const { t } = useTranslation(); + const formData: FormData = { + value: !dataset?.value ? 100 : dataset?.value, + }; + const [formInstance] = Form.useForm<FormData>(); + const willUnpublished = useMemo(() => { + if (!dataset?.is_published) { + return false; + } + return dataset.is_published === true; + }, [dataset]); + + return ( + <ContainerModal + $isPublish={!willUnpublished} + closable={false} + visible={visible} + onConfirm={handleOnConfirm} + onCancel={handleOnCancel} + maskClosable={false} + okText={t(willUnpublished ? OPERATION_TYPE.REVOKE : OPERATION_TYPE.PUBLISH)} + cancelText={t('cancel')} + okButtonProps={{ + status: willUnpublished ? 'danger' : 'default', + }} + title={willUnpublished ? renderTitle() : null} + > + {willUnpublished ? renderRevoke() : renderPublish()} + </ContainerModal> + ); + + async function handleOnConfirm() { + if (!dataset) { + return; + } + try { + if (willUnpublished) { + await unpublishDataset(dataset.id); + } else { + const value = formInstance.getFieldValue('value'); + await postPublishDataset(dataset.id, { + value, + }); + } + Message.success(t(willUnpublished ? 'message_revoke_success' : 'message_publish_success')); + formInstance.resetFields('value'); + onSuccess?.(); + } catch (e) { + Message.error(t(willUnpublished ? 'message_revoke_failed' : 'message_publish_failed')); + formInstance.resetFields('value'); + onFail?.(); + } + } + + function handleOnCancel() { + formInstance.resetFields('value'); + onCancel?.(); + } + + function renderPublish() { + return ( + <> + <StyledPublishContent> + <span> + {t(`dataset.msg_publish_confirm`, { + name: dataset?.name, + })} + </span> + <span>{t('dataset.tips_publish')}</span> + </StyledPublishContent> + {/*temporary solution for no metadata*/} + {!!bcs_support_enabled && renderFirstPublishTips(dataset?.value || 0)} + {!!bcs_support_enabled && ( + <StyledForm initialValues={formData} layout="vertical" form={formInstance}> + <Form.Item field="value" label={t('dataset.label_use_price')}> + <StyledInputNumber + min={CREDITS_LIMITS.MIN} + max={CREDITS_LIMITS.MAX} + suffix={t('dataset.label_publish_credits')} + step={1} + /> + </Form.Item> + </StyledForm> + )} + </> + ); + } + + function renderRevoke() { + return <StyleTitleSpace>{t(`dataset.msg_unpublish_tip`)}</StyleTitleSpace>; + } + + function renderTitle() { + return ( + <StyleTitleSpace> + <IconInfoCircle style={{ color: '#FA9600' }} /> + {t(`dataset.msg_unpublish_confirm`, { + name: dataset?.name, + })} + </StyleTitleSpace> + ); + } + + function renderFirstPublishTips(price: number = 0) { + if (price > 0) { + return null; + } + return ( + <StyledSpace align="center"> + <StyledCreditIcon src={creditsIcon} /> + {i18n.t('dataset.tips_first_publish')} + </StyledSpace> + ); + } +}; + +export default DatasetPublishAndRevokeModal; diff --git a/web_console_v2/client/src/components/DoubleSelect/index.less b/web_console_v2/client/src/components/DoubleSelect/index.less new file mode 100644 index 000000000..b3c54ea13 --- /dev/null +++ b/web_console_v2/client/src/components/DoubleSelect/index.less @@ -0,0 +1,13 @@ +.algorithm-container{ + position: relative; + .algorithm-select{ + width: 100%; + } + .delete-icon{ + position: absolute; + top: 7px; + right: -30px; + font-size: 18px; + cursor: pointer; + } +} diff --git a/web_console_v2/client/src/components/DoubleSelect/index.tsx b/web_console_v2/client/src/components/DoubleSelect/index.tsx new file mode 100644 index 000000000..b70a2ed83 --- /dev/null +++ b/web_console_v2/client/src/components/DoubleSelect/index.tsx @@ -0,0 +1,780 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo, useState, useRef, useEffect } from 'react'; + +import { useInfiniteQuery, useQuery } from 'react-query'; +import { useTranslation } from 'react-i18next'; + +import { + fetchModelSetList, + fetchModelJobList, + fetchModelJobGroupList, + fetchModelJobList_new, +} from 'services/modelCenter'; +import { fetchProjectList, fetchProjectDetail } from 'services/algorithm'; + +import { Input, Grid, Select } from '@arco-design/web-react'; +import { Delete } from 'components/IconPark'; +import { formatListWithExtra } from 'shared/modelCenter'; +import { + AlgorithmParameter, + AlgorithmProject, + EnumAlgorithmProjectSource, + EnumAlgorithmProjectType, +} from 'typings/algorithm'; +import { useGetCurrentProjectId, usePrevious } from 'hooks'; +import { WorkflowState } from 'typings/workflow'; +import { ModelJobStatus } from 'typings/modelCenter'; + +import './index.less'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { + FILTER_MODEL_JOB_OPERATOR_MAPPER, + FILTER_MODEL_TRAIN_OPERATOR_MAPPER, +} from 'views/ModelCenter/shared'; +import { debounce } from 'lodash-es'; + +const { Row, Col } = Grid; + +export type OptionItem = { + /** Display label */ + label: string | number; + /** Form value */ + value: any; + disabled?: boolean; + /** Extra data */ + extra?: any; +}; + +type Props = { + /** + * Form value + * + * { [leftField]: any,[rightField]: any } + */ + value?: { [key: string]: any }; + onChange?: (val: any) => void; + onLeftSelectChange?: (val: any) => void; + onRightSelectChange?: (val: any) => void; + onDeleteClick?: () => void; + leftOnPopupScroll?: (element: any) => void; + rightOnPopupScroll?: (element: any) => void; + onLeftSearch?: (val: string) => void; + onRightSearch?: (val: string) => void; + /** Reset right selector when left selector change */ + isClearRightValueAfterLeftSelectChange?: boolean; + /** Reset both side's value when left side options changed */ + isClearBothAfterLeftOptionsChange?: boolean; + /** Left selector datasource */ + leftOptionList?: OptionItem[]; + /** Right selector datasource */ + rightOptionList?: OptionItem[]; + /** Left selector value field */ + leftField?: string; + /** Right selector value field */ + rightField?: string; + /** Left selector label */ + leftLabel?: string; + /** Right selector label */ + rightLabel?: string; + className?: any; + /** Is show delete icon */ + isShowDelete?: boolean; + disabled?: boolean; + leftDisabled?: boolean; + rightDisabled?: boolean; + /** Container style */ + containerStyle?: React.CSSProperties; + /** Indicate left side data fetching */ + leftLoading?: boolean; + /** Indicate right side data fetching */ + rightLoading?: boolean; +}; + +export type ModelSelectProps = Props & { + /** Cahce map, it's helpful to calc disabled per item */ + modelIdToIsSelectedMap?: { + [key: number]: boolean; + }; + /** Disable linkage */ + isDisabledLinkage?: boolean; +}; +export type ModelGroupSelectProps = Props & { + /** The algorithm type of the job groups */ + type: 'NN_VERTICAL' | 'NN_HORIZONTAL'; + onLeftOptionsEmpty?: () => void; +}; + +export type AlgorithmSelectValue = { + algorithmProjectId?: ID; + algorithmId?: ID; + algorithmUuid?: ID; + config?: AlgorithmParameter[]; + path?: string; +}; +export type AlgorithmSelectProps = Omit<Props, 'leftField' | 'rightField'> & { + value?: AlgorithmSelectValue; + onChange?: (val: AlgorithmSelectValue) => void; + algorithmProjectTypeList?: EnumAlgorithmProjectType[]; + disableFirstOnChange?: boolean; + isParticipant?: boolean; + disableHyperparameters?: boolean; +}; +const DoubleSelect: FC<Props> & { + ModelSelect: FC<ModelSelectProps>; + AlgorithmSelect: FC<AlgorithmSelectProps>; + ModelJobGroupSelect: FC<ModelGroupSelectProps>; +} = ({ + value, + onChange, + onLeftSelectChange: onLeftSelectChangeFromProps, + onRightSelectChange: onRightSelectChangeFromProps, + onDeleteClick, + leftField = 'leftValue', + rightField = 'rightValue', + leftLabel = '', + rightLabel = '', + leftOptionList = [], + rightOptionList = [], + isClearRightValueAfterLeftSelectChange = false, + isClearBothAfterLeftOptionsChange = false, + className, + isShowDelete = false, + disabled = false, + leftDisabled = false, + rightDisabled = false, + containerStyle, + leftLoading = false, + rightLoading = false, + leftOnPopupScroll, + rightOnPopupScroll, + onLeftSearch, + onRightSearch, +}) => { + const isControlled = typeof value === 'object'; + const { t } = useTranslation(); + const [innerLeftValue, setInnerLeftValue] = useState(); + const [innerRightValue, setInnerRightValue] = useState(); + + const leftValue = useMemo(() => { + if (!isControlled) { + return innerLeftValue; + } + + if (value?.[leftField] || value?.[leftField] === 0) { + return value[leftField]; + } + + return undefined; + }, [value, leftField, isControlled, innerLeftValue]); + + const rightValue = useMemo(() => { + if (!isControlled) { + return innerRightValue; + } + + if (value?.[rightField] || value?.[rightField] === 0) { + return value[rightField]; + } + + return undefined; + }, [value, rightField, isControlled, innerRightValue]); + + // clear both selectors' value when left side options changed + const prevLeftOptionList = usePrevious(leftOptionList); + useEffect(() => { + // if previous option list is empty, indicating that options have just been initialized, hence ignore it. + if ( + !isClearBothAfterLeftOptionsChange || + !prevLeftOptionList || + prevLeftOptionList.length === 0 || + leftOptionList.length === 0 + ) { + return; + } + + if (!isControlled) { + setInnerLeftValue(undefined); + setInnerRightValue(undefined); + } + onLeftSelectChangeFromProps?.(undefined); + onChange?.({ + [leftField]: undefined, + [rightField]: undefined, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prevLeftOptionList, leftOptionList]); + + return ( + <div className={`${className} algorithm-container`} style={containerStyle}> + {(leftLabel || rightLabel) && ( + <Row gutter={12}> + <Col span={12}>{leftLabel}</Col> + <Col span={12}>{rightLabel}</Col> + </Row> + )} + + <Row gutter={12}> + <Col span={12}> + <Select + className="algorithm-select" + value={leftValue} + placeholder={t('model_center.placeholder_select')} + onChange={onLeftSelectChange} + allowClear + disabled={disabled || leftDisabled} + showSearch + loading={leftLoading} + onPopupScroll={leftOnPopupScroll} + filterOption={(input, option) => { + if (option.props.children) { + return option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0; + } else if (option?.props?.label) { + return ( + (option.props.label as string).toLowerCase().indexOf(input.toLowerCase()) >= 0 + ); + } + return true; + }} + onSearch={onLeftSearch} + > + {leftOptionList.map((item) => { + return ( + <Select.Option + key={item.value} + value={item.value} + disabled={item.disabled || disabled || leftDisabled} + extra={item.extra} + > + {item.label} + </Select.Option> + ); + })} + </Select> + </Col> + <Col span={12}> + <Select + className="algorithm-select" + value={rightValue} + placeholder={t('model_center.placeholder_select')} + onChange={onRightSelectChange} + onPopupScroll={rightOnPopupScroll} + allowClear + disabled={disabled || rightDisabled} + showSearch + loading={rightLoading} + filterOption={(input, option) => { + if (option?.props?.children) { + return option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0; + } else if (option?.props?.label) { + return ( + (option.props.label as string).toLowerCase().indexOf(input.toLowerCase()) >= 0 + ); + } + return true; + }} + onSearch={onRightSearch} + > + {rightOptionList.map((item) => { + return ( + <Select.Option + key={item.value} + value={item.value} + disabled={item.disabled || disabled || rightDisabled} + extra={item.extra} + > + {item.label} + </Select.Option> + ); + })} + </Select> + </Col> + </Row> + {isShowDelete && <Delete className="delete-icon" onClick={onDeleteClick} />} + </div> + ); + + function onLeftSelectChange(val: any) { + if (!isControlled) { + setInnerLeftValue(val); + if (isClearRightValueAfterLeftSelectChange) { + setInnerRightValue(undefined); + } + } + onLeftSelectChangeFromProps?.(val); + onChange?.({ + ...value, + [leftField]: val, + [rightField]: isClearRightValueAfterLeftSelectChange + ? undefined + : isControlled + ? value?.[rightField] + : innerRightValue, + }); + } + function onRightSelectChange(val: any) { + if (!isControlled) { + setInnerRightValue(val); + } + onRightSelectChangeFromProps?.(val); + onChange?.({ + ...value, + [leftField]: isControlled ? value?.[leftField] : innerLeftValue, + [rightField]: val, + }); + } +}; + +export const ModelSelect: FC<ModelSelectProps> = ({ + leftField = 'model_set_id', + rightField = 'model_id', + modelIdToIsSelectedMap = {}, + value, + isDisabledLinkage = false, + ...props +}) => { + const projectId = useGetCurrentProjectId(); + + const listQuery = useQuery(['double-select-fetch-model-set-list'], () => fetchModelSetList(), { + retry: 2, + refetchOnWindowFocus: false, + }); + + // TODO: filter by group_id + const modelListQuery = useQuery( + ['double-select-fetch-model-jobs'], + () => fetchModelJobList({ types: ['TREE_TRAINING', 'NN_TRAINING', 'TRAINING'] }), + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const leftOptionList = useMemo(() => { + if (!listQuery.data) { + return []; + } + + let list = listQuery.data.data || []; + + list = formatListWithExtra(list, true); + + // filter by project_id and isCompareReport + list = list.filter((item) => { + return !item.isCompareReport && (!projectId || String(item.project_id) === String(projectId)); + }); + + // desc sort + list.sort((a, b) => b.created_at - a.created_at); + + return list.map((item) => ({ + label: item.name, + value: item.id, + })); + }, [listQuery.data, projectId]); + + const rightOptionList = useMemo(() => { + if (isDisabledLinkage) { + return modelListQuery.data?.data.map((item) => ({ + label: item.name, + value: item.id, + disabled: modelIdToIsSelectedMap[item.id as any], + })); + } + const leftValue = value?.[leftField]; + + if (!modelListQuery.data || !leftValue) { + return []; + } + + return modelListQuery.data?.data + .filter((item) => { + return item.group_id === leftValue && item.state === WorkflowState.COMPLETED; + }) + .map((item) => ({ + label: item.name, + value: item.id, + disabled: modelIdToIsSelectedMap[item.id as any], + })); + }, [modelListQuery.data, modelIdToIsSelectedMap, value, leftField, isDisabledLinkage]); + + return ( + <DoubleSelect + value={value} + leftField={leftField} + rightField={rightField} + leftOptionList={leftOptionList} + rightOptionList={rightOptionList} + isClearRightValueAfterLeftSelectChange={true} + {...props} + /> + ); +}; + +export const ModelJobGroupSelect: FC<ModelGroupSelectProps> = ({ + type, + onChange, + onLeftOptionsEmpty, + ...props +}) => { + const projectId = useGetCurrentProjectId(); + const [selectedGroup, setSelectedGroup] = useState<number>(); + const [leftKeyWord, setLeftKeyWord] = useState<string>(); + const [rightKeyWord, setRightKeyWord] = useState<string>(); + + const { + data: pageModelJobGroupList, + fetchNextPage: fetchModelGroupNextPage, + isFetchingNextPage: isFetchingModelGroupNextPage, + hasNextPage: hasModelGroupNextPage, + } = useInfiniteQuery( + ['fetchModelJboGroupList', projectId, type, leftKeyWord], + ({ pageParam = 1 }) => + fetchModelJobGroupList(projectId!, { + page: pageParam, + pageSize: 10, + filter: filterExpressionGenerator( + { + configured: true, + algorithm_type: [type], + name: leftKeyWord, + }, + FILTER_MODEL_TRAIN_OPERATOR_MAPPER, + ), + }), + { + enabled: Boolean(projectId && type), + keepPreviousData: true, + getNextPageParam: (lastPage) => (lastPage.page_meta?.current_page ?? 0) + 1, + }, + ); + + const { + data: pageModelJobList, + fetchNextPage: fetchModelJobListNextPage, + isFetchingNextPage: isFetchingModelJobListNextPage, + hasNextPage: hasModelJobListNextPage, + } = useInfiniteQuery( + ['fetchModelJobList', selectedGroup, projectId, rightKeyWord], + ({ pageParam = 1 }) => + fetchModelJobList_new(projectId!, { + group_id: selectedGroup?.toString()!, + page: pageParam, + page_size: 10, + filter: filterExpressionGenerator( + { status: [ModelJobStatus.SUCCEEDED], name: rightKeyWord }, + FILTER_MODEL_JOB_OPERATOR_MAPPER, + ), + }), + { + enabled: Boolean(projectId && selectedGroup), + keepPreviousData: true, + getNextPageParam: (lastPage) => (lastPage.page_meta?.current_page ?? 0) + 1, + }, + ); + + const leftOptions = useMemo(() => { + const resultOptions: { label: string; value: ID }[] = []; + pageModelJobGroupList?.pages.forEach((group) => { + resultOptions.push(...group.data.map((item) => ({ label: item.name, value: item.id }))); + }); + return resultOptions; + }, [pageModelJobGroupList]); + + const rightOptions = useMemo(() => { + const resultOptions: { label: string; value: ID }[] = []; + pageModelJobList?.pages.forEach((group) => { + resultOptions.push(...group.data.map((item) => ({ label: item.name, value: item.id }))); + }); + return resultOptions; + }, [pageModelJobList]); + + const leftPopupScrollHandler = (element: any) => { + const { scrollTop, scrollHeight, clientHeight } = element; + const scrollBottom = scrollHeight - (scrollTop + clientHeight); + const pagesNumber = pageModelJobGroupList?.pages.length || 0; + const { current_page, total_pages } = pageModelJobGroupList?.pages?.[pagesNumber - 1] + .page_meta || { current_page: 0, total_pages: 0 }; + if (scrollBottom < 10 && !isFetchingModelGroupNextPage && current_page < total_pages) { + hasModelGroupNextPage && fetchModelGroupNextPage(); + } + }; + const rightPopupScrollHandler = (element: any) => { + const { scrollTop, scrollHeight, clientHeight } = element; + const scrollBottom = scrollHeight - (scrollTop + clientHeight); + const pagesNumber = pageModelJobGroupList?.pages.length || 0; + const { current_page, total_pages } = pageModelJobGroupList?.pages?.[pagesNumber - 1] + .page_meta || { current_page: 0, total_pages: 0 }; + if (scrollBottom < 10 && !isFetchingModelJobListNextPage && current_page < total_pages) { + hasModelJobListNextPage && fetchModelJobListNextPage(); + } + }; + + useEffect(() => { + if ( + props.leftField && + props.value?.[props.leftField] != null && + (!leftOptions || leftOptions.length === 0) + ) { + onLeftOptionsEmpty?.(); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [leftOptions]); + + useEffect(() => { + setSelectedGroup(props.leftField ? props.value?.[props.leftField] : undefined); + }, [props.value, props.leftField]); + + return ( + <DoubleSelect + onChange={onChange} + leftLoading={isFetchingModelGroupNextPage} + rightLoading={isFetchingModelJobListNextPage} + leftOptionList={leftOptions ?? []} + rightOptionList={rightOptions ?? []} + onLeftSelectChange={setSelectedGroup} + isClearBothAfterLeftOptionsChange={false} + leftOnPopupScroll={leftPopupScrollHandler} + rightOnPopupScroll={rightPopupScrollHandler} + onLeftSearch={debounce((value: string) => { + setLeftKeyWord(value); + }, 300)} + onRightSearch={debounce((value: string) => { + setRightKeyWord(value); + }, 300)} + {...props} + /> + ); +}; + +export const AlgorithmSelect: FC<AlgorithmSelectProps> = ({ + value, + onChange: onChangeFromProps, + algorithmProjectTypeList, + disableFirstOnChange = false, + containerStyle, + isParticipant = false, + disableHyperparameters = false, + ...props +}) => { + const isControlled = typeof value === 'object'; + + const [innerLeftValue, setInnerLeftValue] = useState<ID>(); + const [innerConfigValueList, setInnerConfigValueList] = useState<AlgorithmParameter[]>([]); + const isAlreadyCallOnChange = useRef(false); + const projectId = useGetCurrentProjectId(); + + const { t } = useTranslation(); + + const leftValue = useMemo(() => { + if (value?.algorithmProjectId || value?.algorithmProjectId === 0) { + return value.algorithmProjectId; + } + + return undefined; + }, [value]); + + const rightValue = useMemo(() => { + if (value?.algorithmId || value?.algorithmId === 0) { + return value?.algorithmId; + } + + return undefined; + }, [value]); + + const configValueList = useMemo(() => { + if (value?.config) { + return value.config; + } + + return innerConfigValueList; + }, [value, innerConfigValueList]); + + const algorithmProjectQuery = useQuery( + ['getAllAlgorithmProjectList', projectId, ...(algorithmProjectTypeList ?? [])], + async () => { + let data: AlgorithmProject[] = []; + try { + if (projectId) { + const resp = await fetchProjectList(projectId ?? 0, { + type: algorithmProjectTypeList ? algorithmProjectTypeList : undefined, + }); + data = data.concat(resp.data || []); + } + + // preset algorithm + const resp = await fetchProjectList(0, { + type: algorithmProjectTypeList ? algorithmProjectTypeList : undefined, + sources: EnumAlgorithmProjectSource.PRESET, + }); + data = data.concat(resp.data || []); + } catch (error) {} + + return { data }; + }, + + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const algorithmProjectDetailQuery = useQuery( + ['getAlgorithmProjectDetail', leftValue, innerLeftValue], + () => fetchProjectDetail(isControlled ? leftValue! : innerLeftValue!), + { + enabled: + (isControlled + ? leftValue !== null && leftValue !== undefined + : innerLeftValue !== null && innerLeftValue !== undefined) && !isParticipant, + retry: 2, + refetchOnWindowFocus: false, + onSuccess(res) { + if ( + isAlreadyCallOnChange.current || + (!leftValue && leftValue !== 0) || + (!rightValue && rightValue !== 0) || + disableFirstOnChange + ) { + return; + } + + const rightItem = (res.data?.algorithms ?? []).find((item) => item.id === rightValue); + + // Because it is necessary to get extra info in edit mode, so call onChangeFromProps manually + onChangeFromProps?.({ + algorithmProjectId: leftValue, + algorithmId: rightValue, + config: value?.config ?? rightItem?.parameter?.variables ?? [], + path: value?.path ?? rightItem?.path ?? '', + algorithmUuid: rightItem?.uuid ?? '', + }); + }, + }, + ); + + const leftOptionList = useMemo(() => { + if (!algorithmProjectQuery.data) { + return []; + } + + const list = algorithmProjectQuery.data.data || []; + + return list.map((item) => ({ + label: item.name, + value: item.id, + extra: item, + })); + }, [algorithmProjectQuery.data]); + + const rightOptionList = useMemo(() => { + if ( + !algorithmProjectDetailQuery.data || + (isControlled && !leftValue && leftValue !== 0) || + (!isControlled && !innerLeftValue && innerLeftValue !== 0) + ) { + return []; + } + + return (algorithmProjectDetailQuery.data?.data?.algorithms ?? []).map((item) => ({ + label: `V${item.version}`, + value: item.id, + extra: item, + })); + }, [algorithmProjectDetailQuery.data, isControlled, leftValue, innerLeftValue]); + + return ( + <div style={containerStyle}> + {!isParticipant && ( + <DoubleSelect + value={value} + leftField="algorithmProjectId" + rightField="algorithmId" + leftOptionList={leftOptionList} + rightOptionList={rightOptionList} + isClearRightValueAfterLeftSelectChange={true} + onChange={onChange} + containerStyle={containerStyle} + {...props} + /> + )} + {configValueList.length > 0 && ( + <> + {!isParticipant && ( + <Row gutter={[12, 12]}> + <Col span={12}>{t('hyper_parameters')}</Col> + </Row> + )} + <Row gutter={[12, 12]}> + {configValueList.map((item, index) => ( + <React.Fragment key={`${item.name}_${index}`}> + <Col span={12}> + <Input + defaultValue={item.name} + onChange={(_, event) => onConfigValueChange(event.target.value, 'name', index)} + disabled={true} + /> + </Col> + <Col span={12}> + <Input + defaultValue={item.value} + onChange={(_, event) => onConfigValueChange(event.target.value, 'value', index)} + disabled={disableHyperparameters} + /> + </Col> + </React.Fragment> + ))} + </Row> + </> + )} + {isParticipant && configValueList.length <= 0 && ( + <Input disabled placeholder="对侧无算法超参数,无需配置" /> + )} + </div> + ); + + function onChange(val: { algorithmProjectId: ID; algorithmId: ID }) { + isAlreadyCallOnChange.current = true; + const rightItem = rightOptionList.find((item) => item.value === val.algorithmId); + + const config = rightItem?.extra?.parameter?.variables ?? []; + const path = rightItem?.extra?.path ?? []; + const algorithmUuid = rightItem?.extra?.uuid; + + if (!isControlled) { + if (leftValue !== val.algorithmProjectId) { + setInnerLeftValue(val.algorithmProjectId); + } + + setInnerConfigValueList(config); + } + + onChangeFromProps?.({ + ...val, + config, + path, + algorithmUuid, + }); + } + + function onConfigValueChange(val: string, key: string, index: number) { + const newConfigValueList = [...configValueList]; + newConfigValueList[index] = { ...newConfigValueList[index], [key]: val }; + + if (!isControlled) { + setInnerConfigValueList(newConfigValueList); + } + + onChangeFromProps?.({ + ...value, + config: newConfigValueList, + }); + } +}; + +DoubleSelect.ModelSelect = ModelSelect; +DoubleSelect.ModelJobGroupSelect = ModelJobGroupSelect; +DoubleSelect.AlgorithmSelect = AlgorithmSelect; + +export default DoubleSelect; diff --git a/web_console_v2/client/src/components/DrawerConfirm/index.test.tsx b/web_console_v2/client/src/components/DrawerConfirm/index.test.tsx new file mode 100644 index 000000000..610655b63 --- /dev/null +++ b/web_console_v2/client/src/components/DrawerConfirm/index.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { act, screen, fireEvent, waitFor } from '@testing-library/react'; +import confirm from './index'; + +describe('<DrawerConfirm />', () => { + it('should render in specific container', () => { + const container = document.createElement('div'); + + act(() => { + confirm({ + container, + renderContent: () => <div>content</div>, + }); + }); + + const drawer = document.querySelector('.arco-drawer'); + expect(drawer).toBeInTheDocument(); + }); + + it('should called onOk and onClose', () => { + let onOk = jest.fn(() => Promise.resolve('')); + let onClose = jest.fn(); + const okText = 'okText' + Date.now(); + const cancelText = 'cancelText' + Date.now(); + + act(() => { + confirm({ + onOk, + onClose, + okText, + cancelText, + renderContent: () => <div>content</div>, + }); + }); + + const okBtn = screen.getByText(okText); + + fireEvent.click(okBtn); + expect(onOk).toBeCalledTimes(1); + expect(onClose).toBeCalledTimes(0); + + onOk = jest.fn(() => Promise.resolve('')); + onClose = jest.fn(); + act(() => { + confirm({ + onOk, + onClose, + okText, + cancelText, + renderContent: () => <div>content</div>, + }); + }); + + // 当前页面有两个 drawer + const [, cancelBtn] = screen.getAllByText(cancelText); + + fireEvent.click(cancelBtn); + + // Click <ButtonWithPopconfirm/>'s submit button to close drawer + fireEvent.click(screen.getAllByText('submit')[0]); + + expect(onOk).toBeCalledTimes(0); + expect(onClose).toBeCalledTimes(1); + }); + + it('should remove container after close', () => { + const container = document.createElement('div'); + const cancelText = 'cancelText' + Date.now(); + + act(() => { + confirm({ + container, + cancelText, + renderContent: () => <div>content</div>, + }); + }); + + const cancelBtn = screen.getByText(cancelText); + fireEvent.click(cancelBtn); + waitFor(() => { + expect(container.querySelector('.arco-drawer')).toBeNull(); + }); + }); +}); diff --git a/web_console_v2/client/src/components/DrawerConfirm/index.tsx b/web_console_v2/client/src/components/DrawerConfirm/index.tsx new file mode 100644 index 000000000..d60e6fc60 --- /dev/null +++ b/web_console_v2/client/src/components/DrawerConfirm/index.tsx @@ -0,0 +1,136 @@ +// 给 Drawer 组件添加类似于 Modal.confirm 一样的功能 + +import React, { FC, useState } from 'react'; +import ReactDOM from 'react-dom'; +import styled from 'styled-components'; + +import { Drawer, DrawerProps, Button, Space } from '@arco-design/web-react'; +import ButtonWithPopconfirm from 'components/ButtonWithPopconfirm'; + +type TSetParams = (params: any) => void; + +export type TProps = { + visible: boolean; + container?: HTMLElement; + renderContent: (setOkParams: TSetParams, setCloseParams: TSetParams) => React.ReactNode; + okText?: string; + cancelText?: string; + onOk?: (params: any) => Promise<any>; + onClose?: (params?: any) => void; +} & Pick<DrawerProps, 'title' | 'afterClose'>; + +const StyledContainer = styled.div` + font-size: 12px; + color: rgb(var(--gray-8)); +`; +const StyledButtonSpace = styled(Space)` + margin-top: 28px; +`; + +const ConfirmDrawer: FC<TProps> = ({ + title, + okText, + cancelText, + visible, + container, + renderContent, + afterClose, + onOk, + onClose, +}) => { + const [confirming, setConfirming] = useState<boolean>(false); + const [okParams, setOkParams] = useState<any>({}); + const [closeParams, setCloseParams] = useState<any>({}); + + return ( + <Drawer + width={400} + visible={visible} + maskClosable={false} + title={title} + closable={true} + onCancel={onClose} + unmountOnExit={true} + afterClose={afterClose} + getPopupContainer={() => container || window.document.body} + > + <StyledContainer>{renderContent(setOkParams, setCloseParams)}</StyledContainer> + <StyledButtonSpace> + <Button loading={confirming} onClick={onConfirmWrap} type="primary"> + {okText} + </Button> + <ButtonWithPopconfirm + buttonProps={{ + disabled: confirming, + }} + buttonText={cancelText} + onConfirm={() => { + onClose?.(closeParams); + }} + /> + </StyledButtonSpace> + </Drawer> + ); + + async function onConfirmWrap() { + if (typeof onOk === 'function') { + setConfirming(true); + await onOk(okParams); + setConfirming(false); + } + onClose?.(); + } +}; + +type TConfirmProps = Omit<TProps, 'visible'>; +function confirm(props: TConfirmProps) { + const key = `__scale_drawer_${Date.now()}__`; + const container = window.document.createElement('div'); + container.style.zIndex = '1000'; + window.document.body.appendChild(container); + + hide(props); // 先渲染组件 + show(props); // 再显示 + + function renderComp(props: TProps) { + ReactDOM.render( + React.createElement(ConfirmDrawer, { + ...props, + key, + container, + async onOk(params) { + if (props.onOk) { + await props.onOk(params); + hide(props); + } + }, + onClose() { + if (props.onClose) { + props.onClose(); + } + hide(props); + }, + }), + container, + ); + } + + function hide(props: TConfirmProps) { + renderComp({ + ...props, + visible: false, + afterClose() { + window.document.body.removeChild(container); + }, + }); + } + + function show(props: TConfirmProps) { + renderComp({ + ...props, + visible: true, + }); + } +} + +export default confirm; diff --git a/web_console_v2/client/src/components/ErrorBoundary/index.tsx b/web_console_v2/client/src/components/ErrorBoundary/index.tsx new file mode 100644 index 000000000..0ca86f0e4 --- /dev/null +++ b/web_console_v2/client/src/components/ErrorBoundary/index.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Alert } from '@arco-design/web-react'; + +interface ErrorBoundaryProps { + title?: React.ReactNode; + description?: React.ReactNode; + children?: React.ReactNode; +} + +export default class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + { + error?: Error | null; + info: { + componentStack?: string; + }; + } +> { + state = { + error: undefined, + info: { + componentStack: '', + }, + }; + + componentDidCatch(error: Error | null, info: object) { + this.setState({ error, info }); + } + + render() { + const { title, description, children } = this.props; + const { + error, + info: { componentStack }, + } = this.state; + const errorTitle = typeof title === 'undefined' ? (error || '').toString() : title; + const errorDescription = typeof description === 'undefined' ? componentStack : description; + if (error) { + return ( + <Alert + type="error" + style={{ overflow: 'auto' }} + title={errorTitle} + content={<pre>{errorDescription}</pre>} + /> + ); + } + return children; + } +} diff --git a/web_console_v2/client/src/components/FeatureImportance/index.tsx b/web_console_v2/client/src/components/FeatureImportance/index.tsx new file mode 100644 index 000000000..c005a6ac4 --- /dev/null +++ b/web_console_v2/client/src/components/FeatureImportance/index.tsx @@ -0,0 +1,165 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo } from 'react'; +import styled from 'styled-components'; + +import HorizontalBarChart from 'components/HorizontalBarChart'; +import NoResult from 'components/NoResult'; +import TitleWithIcon from 'components/TitleWithIcon'; +import { QuestionCircle } from 'components/IconPark'; +import { useModelMetriesResult } from 'hooks/modelCenter'; + +const Card = styled.div<{ height?: number }>` + display: flex; + align-items: center; + justify-content: center; + position: relative; + ${(props) => props.height && `height: ${props.height}px`}; + border: 1px solid var(--lineColor); + border-radius: 2px; + padding: 30px 0; +`; +const Title = styled(TitleWithIcon)` + position: absolute; + left: 16px; + top: 12px; + color: var(--textColor); + font-size: 12px; +`; +const Content = styled.div` + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0 auto; +`; + +type TXTickFormatter = (val: number) => string | number; + +function defaultXTickFormatter(val: any) { + return val; +} + +const getBarOption = (xTickFormatter?: TXTickFormatter) => { + return { + maintainAspectRatio: false, + indexAxis: 'y', + // Elements options apply to all of the options unless overridden in a dataset + // In this case, we are setting the border of each horizontal bar to be 2px wide + elements: { + bar: { + borderWidth: 0, + }, + }, + responsive: true, + plugins: { + legend: { + display: false, + }, + title: { + display: false, + }, + }, + scales: { + y: { + grid: { + color: 'transparent', + tickColor: '#cecece', + }, + }, + x: { + grid: { + drawBorder: false, + }, + min: 0, + ticks: { + callback: + typeof xTickFormatter === 'function' + ? xTickFormatter + : function (value: any) { + return value; + }, + }, + }, + }, + }; +}; + +type Item = { + label: string; + value: any; +}; + +export type Props = { + valueList: Item[]; + height?: number; + title?: string; + tip?: string; + xTipFormatter?: TXTickFormatter; +}; + +export type ModelEvaluationVariantProps = { + id: ID; + participantId?: ID; + tip?: string; +}; + +type VariantComponent = { + ModelEvaluationVariant: FC<ModelEvaluationVariantProps>; +}; + +export const FeatureImportance: FC<Props> & VariantComponent = ({ + valueList = [], + height = 260, + title = 'Feature importance(Top 15)', + tip = 'Feature importance', + xTipFormatter = defaultXTickFormatter, +}) => { + return ( + <Card height={height}> + <Title + title={title || ''} + isShowIcon={Boolean(tip)} + isLeftIcon={false} + isBlock={false} + tip={tip} + icon={QuestionCircle} + /> + {valueList.length > 0 ? ( + <Content> + <HorizontalBarChart valueList={valueList} options={getBarOption(xTipFormatter)} /> + </Content> + ) : ( + <NoResult.NoData /> + )} + </Card> + ); +}; + +const ModelValuationVariant: FC<ModelEvaluationVariantProps> = ({ id, participantId, tip }) => { + const { data } = useModelMetriesResult(id, participantId); + + const valueList = useMemo(() => { + if (!data) { + return []; + } + + const list = []; + const { feature_importance = {} } = data; + + for (const k in feature_importance) { + list.push({ + label: k, + value: feature_importance[k], + }); + } + return list.sort((a, b) => b.value - a.value); + }, [data]); + + return <FeatureImportance valueList={valueList} xTipFormatter={(val: any) => val} tip={tip} />; +}; + +FeatureImportance.ModelEvaluationVariant = ModelValuationVariant; +export default FeatureImportance; diff --git a/web_console_v2/client/src/components/FeatureSelect/index.tsx b/web_console_v2/client/src/components/FeatureSelect/index.tsx new file mode 100644 index 000000000..f39818a12 --- /dev/null +++ b/web_console_v2/client/src/components/FeatureSelect/index.tsx @@ -0,0 +1,330 @@ +/* istanbul ignore file */ + +import { Button, Input, Select, Tooltip, Message, Alert } from '@arco-design/web-react'; +import ErrorBoundary from 'components/ErrorBoundary'; +import StructDataPreviewTable from 'components/DataPreview/StructDataTable'; +import { Delete } from 'components/IconPark'; +import NoResult from 'components/NoResult'; +import GridRow from 'components/_base/GridRow'; +import React, { FC } from 'react'; +import { useQuery } from 'react-query'; +import { useRecoilState } from 'recoil'; +import { fetchDatasetPreviewData } from 'services/dataset'; +import { datasetState } from 'stores/dataset'; +import styled from 'styled-components'; +import { MixinCommonTransition } from 'styles/mixins'; + +const Container = styled.div` + position: relative; + left: 250px; + width: calc(100vw - 2 * var(--contentOuterPadding)); + min-height: 400px; + display: flex; + transform: translateX(-50%); + border-top: 1px solid var(--lineColor); +`; +const NoResultContainer = styled.div` + flex-shrink: 0; + width: 100%; + margin: 20px 0; +`; +const TableConatienr = styled.div` + width: 50%; + padding: 24px; + padding-right: 0; + padding-bottom: 0; + border-right: 1px solid var(--lineColor); + overflow: hidden; +`; +const SelectedConatienr = styled.div` + display: flex; + flex-wrap: wrap; + align-content: flex-start; + width: 50%; + padding: 24px; + + > div + div { + margin-left: 20px; + } +`; +const Heading = styled.h4` + margin-bottom: 20px; + line-height: 20px; + font-size: 13px; + font-weight: 500; +`; +const SelectedFeatCol = styled.div` + flex: 1; + min-width: 150px; + max-width: 250px; +`; +const MissingCountCol = styled.div` + flex: 2; + max-width: 400px; +`; +const FeatureDisplay = styled.div` + ${MixinCommonTransition()} + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 20px; + margin-bottom: 20px; + padding-right: 10px; + line-height: 22px; + border-radius: 2px; + background-color: var(--backgroundColor); + cursor: pointer; + + &:hover { + color: var(--primaryColor); + background-color: white; + box-shadow: 0 0 0 1px var(--primaryColor) inset; + } +`; +const FeatureInput = styled(Input)` + margin-bottom: 20px; +`; +const ButtonGroup = styled(GridRow)` + width: 100%; + flex: 0 0 auto; +`; +const InvalidAlert = styled(Alert)` + width: 100%; + margin-bottom: 10px; +`; + +const CUSTOM_VALUE_TYPE = '_'; +const VALUE_TYPE_OPTIONS = [ + { + value: CUSTOM_VALUE_TYPE, + label: '指定值', + disabled: false, + }, + { + value: 'max', + label: '最大值', + disabled: true, + }, + { + value: 'min', + label: '最小值', + disabled: true, + }, + { + value: 'mean', + label: '中位数', + disabled: true, + }, + { + value: 'avg', + label: '平均值', + disabled: true, + }, +] as const; + +type Props = { + value?: string; + onChange?: (val: string) => void; +}; +type ParsedFeatures = { key: string; value: string }[]; + +let inputTimer: TimeoutID = undefined; + +const FeatureSelect: FC<Props> = ({ value, onChange }) => { + const checkedFeats = _parseValue(value); + + const [dataset] = useRecoilState(datasetState); + const datasetId = dataset.current?.id; + + const previewDataQuery = useQuery( + ['fetchStructPreviewData', datasetId], + () => fetchDatasetPreviewData(datasetId!), + { + refetchOnWindowFocus: false, + retry: 0, + enabled: Boolean(datasetId), + }, + ); + + if (!datasetId) { + return ( + <Container style={{ flexDirection: 'column' }}> + <NoResult text="数据集ID不存在,请检查" /> + <GridRow justify="center"> + <Button onClick={onPreviousClick}>上一步</Button> + </GridRow> + </Container> + ); + } + + const checkedKeys = checkedFeats.map((item) => item.key); + const nothingChecked = checkedKeys.length === 0; + + return ( + <ErrorBoundary> + <Container> + {/* Preview table */} + <TableConatienr> + <Heading>选择特征</Heading> + {/* For hiding preview table's border-right */} + <div style={{ marginRight: -2 }}> + <StructDataPreviewTable + data={previewDataQuery.data?.data} + loading={previewDataQuery.isFetching} + checkable + checkedKeys={checkedKeys} + onCheckedChange={onCheckedChange} + /> + </div> + </TableConatienr> + {/* Selected */} + <SelectedConatienr> + {!_validate(checkedFeats) && <InvalidAlert content="请检查特征缺失默认填充情况" banner />} + <SelectedFeatCol> + <Heading>已选 {0} 项特征</Heading> + {checkedFeats.map((item) => { + return ( + <FeatureDisplay key={item.key}> + {item.key} + <Tooltip content="取消选择该特征"> + <Delete onClick={() => onFeatDeselect(item.key)} /> + </Tooltip> + </FeatureDisplay> + ); + })} + </SelectedFeatCol> + <MissingCountCol> + <Heading>缺失值填充</Heading> + {checkedFeats.map((item) => { + const isCustom = _isCustomType(item.value); + return ( + <FeatureInput + type="number" + addBefore={ + <Select + style={{ minWidth: 90 }} + defaultValue={isCustom ? CUSTOM_VALUE_TYPE : item.value} + onChange={(type) => onValueTypeChange({ type, key: item.key })} + > + {VALUE_TYPE_OPTIONS.map((item) => ( + <Select.Option key={item.value} disabled={item.disabled} value={item.value}> + {item.label} + </Select.Option> + ))} + </Select> + } + defaultValue={item.value} + disabled={!isCustom} + placeholder="请输入" + onChange={(value: string, evt) => + onFeatValueChange({ value: evt.target.value, key: item.key }) + } + key={item.key} + /> + ); + })} + </MissingCountCol> + + {/* Nothing selected */} + {nothingChecked && ( + <NoResultContainer> + <NoResult width="200px" text="没有已选的特征" /> + </NoResultContainer> + )} + + <ButtonGroup justify="center" gap={12}> + <Button + style={{ width: '156px' }} + disabled={nothingChecked} + onClick={onSubmit} + type="primary" + > + 下一步 + </Button> + <Button onClick={onPreviousClick}>上一步</Button> + </ButtonGroup> + </SelectedConatienr> + </Container> + </ErrorBoundary> + ); + + function onSubmit(evt: Event) { + if (checkedFeats.length === 0) { + Message.error('请选择至少一个特征'); + evt.preventDefault(); + return; + } + if (!_validate(checkedFeats)) { + Message.error('请检查特征缺失默认填充情况'); + evt.preventDefault(); + return; + } + } + function onFeatDeselect(featKey: string) { + const nextCheckedKeys = [...checkedKeys]; + + nextCheckedKeys.splice(nextCheckedKeys.indexOf(featKey), 1); + onCheckedChange(nextCheckedKeys); + } + function onValueTypeChange(payload: { type: string; key: string }) { + if (payload.type !== CUSTOM_VALUE_TYPE) { + updateValueByFeatKey(payload.key, payload.type); + } + } + function onFeatValueChange(payload: { value: string; key: string }) { + clearTimeout((inputTimer as unknown) as number); + + inputTimer = setTimeout(() => { + updateValueByFeatKey(payload.key, payload.value); + }, 200); + } + function updateValueByFeatKey(featKey: string, value: string) { + const targetFeat = checkedFeats.find((item) => item.key === featKey); + if (!targetFeat) return; + + targetFeat.value = value; + + onChange?.(_assembleValue(checkedFeats)); + } + function onCheckedChange(keys: string[]) { + const values = keys.map((key) => { + return { + key, + value: checkedFeats.find((item) => item.key === key)?.value ?? '', + }; + }); + onChange?.(_assembleValue(values)); + } + function onPreviousClick() {} +}; + +/** Every featrure need a non-empty value */ +function _validate(feats: ParsedFeatures) { + return feats.every(({ value }) => Boolean(value.trim())); +} +/** + * ! @NOTE: value is a twice-JSON-stringified string + */ +function _parseValue(value?: string): ParsedFeatures { + if (!value) return []; + + try { + const unwrapValue = JSON.parse(value); + const featuresMap = unwrapValue?.replace ? JSON.parse(unwrapValue.replace(/\\/g, '')) : {}; + return Object.entries(featuresMap).map(([key, value]) => ({ key, value: value as string })); + } catch (error) { + console.error('[Feature Select]:', error); + return []; + } +} +/** ! @NOTE: Stringify twice warning */ +function _assembleValue(feats: ParsedFeatures) { + const data = JSON.stringify(Object.fromEntries(feats.map((item) => [item.key, item.value]))); + // NOTE: escape json string to avoid invalid params in backend + return JSON.stringify(data); +} +/** Custom value is always number format */ +function _isCustomType(val: string) { + return /[\d]+/.test(val) || !val; +} +export default FeatureSelect; diff --git a/web_console_v2/client/src/components/FileExplorer/index.less b/web_console_v2/client/src/components/FileExplorer/index.less new file mode 100644 index 000000000..13b2130ca --- /dev/null +++ b/web_console_v2/client/src/components/FileExplorer/index.less @@ -0,0 +1,131 @@ +.file-export-wrapper { + height: 100%; + display: block; + .arco-spin-children { + height: 100%; + overflow: auto; + } +} +.file-node-content-container { + display: flex; + .arco-form-item{ + flex: 1; + } + .arco-form-item-control { + min-height: 26px; + } +} + +.folder-selected{ + background-color: var(--color-fill-2); +} +.file-export-node-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + //todo: checker auto is valid or not; + //max-width: auto; + flex: 1; + display: inline-block; + font-size: 14px; + color: var(--font-color); + font-weight: var(--font-weight-normal); + height: 28px; + line-height: 28px; +} +.file-action-list-container { + visibility: hidden; + + .anticon { + color: var(--action-button-color); + } + .anticon:hover { + color: var(--action-button-color-hover); + } + /* transition: background-color 0s !important; */ + .arco-btn { + transition-duration: 0s !important; + } +} +.file-icon-container { + display: inline-block; + margin-right: 5px; + display: flex; + justify-content: center; + align-items: center; + height: 28px; + .anticon{ + height: 16px; + line-height: 16px; + } +} +.file-directory-tree { + // TODO: theme + --bg-hover: #f6f7fb; + --bg-selected: #f2f3f8; + --font-weight-normal: 400; + --font-weight-selected: 500; + --font-color: #1d2129; + --switcher-color: #86909c; + --action-button-color: #86909c; + --action-button-color-hover: #4e5969; + + height: 100%; + overflow: scroll; + .arco-tree-node-switcher { + display: none; + color: var(--switcher-color) !important; + } + + .arco-tree-node:hover { + background-color: var(--color-fill-2); + ::before { + bottom: 0px; + background-color: var(--bg-hover); + } + .file-action-list-container { + visibility: visible; + } + } + .arco-tree-node{ + padding-left: 16px; + } + .arco-tree-node-title{ + padding: 0 !important; + overflow: hidden; + } + .arco-tree-node-title:hover{ + background-color: transparent; + } + .arco-tree-node-selected { + ::before { + background-color: var(--bg-selected) !important; + } + + .file-export-node-name { + font-weight: var(--font-weight-selected); + } + } + .arco-tree-node-indent-block{ + margin: 0; + } + + .arco-form-item { + width: auto; + .arco-col-19{ + width: 100%; + flex: 1; + } + } + + .arco-tree-node.is-focus-mode:not(.is-focus-node) { + opacity: 0.3; + pointer-events: none; + } + &-isFocusMode { + .is-not-focus-node { + opacity: 0.3; + pointer-events: none; + } + } +} diff --git a/web_console_v2/client/src/components/FileExplorer/index.tsx b/web_console_v2/client/src/components/FileExplorer/index.tsx new file mode 100644 index 000000000..77e42448c --- /dev/null +++ b/web_console_v2/client/src/components/FileExplorer/index.tsx @@ -0,0 +1,935 @@ +import React, { + ForwardRefRenderFunction, + useMemo, + useState, + useEffect, + useRef, + useImperativeHandle, + forwardRef, +} from 'react'; +import styled from 'styled-components'; +import i18n from 'i18n'; +import { useMount } from 'react-use'; +import classNames from 'classnames'; +import { record } from 'shared/object'; +import { Spin, Message, Tooltip, Form, Input, Tree } from '@arco-design/web-react'; +import NoResult from 'components/NoResult'; +import { + EditNoUnderline, + Check, + Close, + FolderAddFill, + FileAddFill, + ArrowFillDown, + ArrowFillRight, +} from 'components/IconPark'; +import MoreActions from 'components/MoreActions'; + +import { TreeProps, TreeNodeProps } from '@arco-design/web-react/es/Tree'; +import { FormItemProps } from '@arco-design/web-react/es/Form/interface'; +import { + transformRegexSpecChar, + giveWeakRandomKey, + dfs, + formatTreeData, + getFirstFileNode, + fileExtToIconMap as fileExtToIconMapOrigin, + formatFileTreeNodeListToFileData, +} from 'shared/helpers'; +import './index.less'; +const TreeNode = Tree.Node; + +export const fileExtToIconMap = fileExtToIconMapOrigin; + +export type Key = string; + +export type FileDataNode = TreeNodeProps & { + key: string; + code?: string | null; + fileExt?: string; + parentKey?: Key; + label?: string; + children?: FileDataNode[]; + isFolder?: boolean; +}; + +export type FileData = { [filePath: string]: string | null }; +export type FilePathToIsReadMap = { [filePath: string]: boolean }; + +export type Props = { + fileData?: FileData; + getFileTreeList?: () => Promise<any[]>; + getFile?: (filePath: string) => Promise<any>; + formatFileTreeListToFileData?: (data: any[]) => FileData; + isAsyncMode?: boolean; + isLoading?: boolean; + isAutoSelectFirstFile?: boolean; + isReadOnly?: boolean; + isShowNodeTooltip?: boolean; + isExpandAll?: boolean; + onFileDataChange?: (fileData: FileData) => void; + onDeleteFinish?: (keys: Key[], firstKey: Key) => void; + onRenameFinish?: (node: FileDataNode, oldKey: Key, newKey: Key) => void; + onCreateFinish?: (key: Key, isFolder: boolean) => void; + onSelectFile?: (filePath: Key, fileContent: string, node: FileDataNode) => void; + onClickRename?: (node: FileDataNode) => void; + /** + * When beforeCreate return false or Promise that is resolved false, don't create node + */ + beforeCreate?: ( + node: FileDataNode, + key: Key, + isFolder: boolean, + ) => boolean | Promise<boolean | string>; + /** + * When beforeRename return false or Promise that is resolved false, don't rename node + */ + beforeRename?: ( + node: FileDataNode, + oldKey: Key, + newKey: Key, + isFolder: boolean, + ) => boolean | Promise<boolean | string>; + /** + * When beforeDelete return false or Promise that is resolved false, don't delete node + */ + beforeDelete?: (key: Key, isFolder: boolean) => boolean | Promise<boolean>; + onFocusModeChange?: (isFocusMode: boolean) => void; +} & Partial<TreeProps>; + +export type FileExplorerExposedRef = { + getFileData: () => FileData; + createFileOrFolder: (node?: FileDataNode, isFile?: boolean) => void; + setFilePathToIsReadMap: (filePathToIsReadMap: FilePathToIsReadMap) => void; +}; + +// TODO:There are some dependencies on properties defined by this component that will be removed later +const StyledTreeNode = styled(TreeNode)<{ + code?: string | null; + fileExt?: string; + parentKey?: Key; + label?: string; + isFolder?: boolean; +}>``; + +const FileExplorer: ForwardRefRenderFunction<FileExplorerExposedRef, Props> = ( + { + fileData, + getFileTreeList, + getFile, + formatFileTreeListToFileData = formatFileTreeNodeListToFileData, + isAsyncMode = false, + isLoading: isLoadingFromProps = false, + isReadOnly = false, + isShowNodeTooltip = true, + isAutoSelectFirstFile = true, + isExpandAll = true, + onFileDataChange, + onSelect: onSelectFromProps, + onSelectFile, + onDeleteFinish, + onRenameFinish, + onCreateFinish, + onClickRename, + beforeCreate, + beforeRename, + beforeDelete, + selectedKeys: selectedKeysFromProps, + expandedKeys: expandedKeysFromProps, + onFocusModeChange, + ...restProps + }, + parentRef, +) => { + const [tempFileData, setTempFileData] = useState<FileData>(fileData || {}); + const [focusKey, setFocusKey] = useState<Key | null>(); + const [isCreating, setIsCreating] = useState(false); + const [selectedKeys, setSelectedKeys] = useState<Array<Key>>([]); + const [expandedKeys, setExpandedKeys] = useState<Array<Key>>([]); + const [validateObj, setValidateObj] = useState<{ + validateStatus: FormItemProps['validateStatus']; + help?: string; + }>({ + validateStatus: undefined, + help: '', + }); + const [isLoading, setIsLoading] = useState(false); + + const isDeleteButtonClick = useRef(false); + const isExpandedAll = useRef(false); + const isAlreadySelectFirstNode = useRef(false); + const isAlreadyFetchedFileTreeList = useRef(false); + const inputRef = useRef<any>(null); + // TODO: when to clear cache? + const filePathToIsReadMap = useRef<FilePathToIsReadMap>({}); + + // sync fileData + useEffect(() => { + if (fileData) { + setTempFileData((prevState) => fileData); + } + }, [fileData]); + useEffect(() => { + if (selectedKeysFromProps) { + setSelectedKeys((prevState) => selectedKeysFromProps); + } + }, [selectedKeysFromProps]); + useEffect(() => { + if (expandedKeysFromProps) { + setExpandedKeys((prevState) => expandedKeysFromProps); + } + }, [expandedKeysFromProps]); + useEffect(() => { + onFocusModeChange?.(Boolean(focusKey)); + }, [focusKey, onFocusModeChange]); + + // Fetch file tree data + useMount(() => { + if (!isAsyncMode || !getFileTreeList) return; + + filePathToIsReadMap.current = {}; + + setIsLoading(true); + getFileTreeList() + .then((data) => { + const tempFileData = formatFileTreeListToFileData(data || []) || {}; + // If there is no file data, isAutoSelectFirstFile will be invalid + if (data.length === 0) { + isAlreadySelectFirstNode.current = true; + } + + // Store filePathToIsReadMap cache + filePathToIsReadMap.current = record(tempFileData, false); + setTempFileData((prevState) => tempFileData); + setIsLoading(false); + + isAlreadyFetchedFileTreeList.current = true; + }) + .catch((error) => { + setIsLoading(false); + Message.error(error.message); + // If there is no file data, isAutoSelectFirstFile will be invalid + isAlreadySelectFirstNode.current = true; + isAlreadyFetchedFileTreeList.current = true; + }); + }); + + const formattedTreeData = useMemo(() => formatTreeData(tempFileData), [tempFileData]); + + // Auto select first node in synchronous mode + useMount(() => { + if (isAsyncMode || !isAutoSelectFirstFile) return; + + selectFirstFileNode(formattedTreeData); + }); + // Auto select first node in asynchronous mode (after async tree data loaded) + useEffect(() => { + if (!isAsyncMode || !isAutoSelectFirstFile || isAlreadySelectFirstNode.current) return; + + selectFirstFileNode(formattedTreeData); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAsyncMode, isAutoSelectFirstFile, formattedTreeData]); + + // default expand all folder + useEffect(() => { + if (isExpandedAll.current || !formattedTreeData || formattedTreeData.length === 0) { + return; + } + // search all folder node + const allFolderNode = dfs<FileDataNode>( + { key: '', parentKey: '', children: formattedTreeData }, + (node) => { + if (node.isFolder && node.key) { + return true; + } + return false; + }, + ); + + // get all folder key + if (isExpandAll) { + setExpandedKeys(allFolderNode.map((item) => item.key)); + } + isExpandedAll.current = true; + }, [isExpandAll, isExpandedAll, formattedTreeData]); + + useImperativeHandle(parentRef, () => { + return { + getFileData: () => tempFileData, + createFileOrFolder: onCreateFile, + setFilePathToIsReadMap, + }; + }); + + if (!formattedTreeData || formattedTreeData.length === 0) { + return ( + <Spin className={'file-export-wrapper'} loading={isLoadingFromProps || isLoading}> + <NoResult.NoData /> + </Spin> + ); + } + + return ( + <Spin className={'file-export-wrapper'} loading={isLoadingFromProps || isLoading}> + <Tree + className={`file-directory-tree ${!!focusKey ? 'file-directory-tree-isFocusMode' : ''}`} + showLine={false} + autoExpandParent={true} + blockNode={true} + size="small" + selectedKeys={selectedKeys} + expandedKeys={expandedKeys} + onSelect={onSelect as any} + onExpand={onExpand} + {...restProps} + > + {renderTreeNode(formattedTreeData)} + </Tree> + </Spin> + ); + + function renderFileTreeNode(node: FileDataNode) { + const Icon = typeof node.icon === 'function' ? node.icon({ ...restProps }) : node.icon; + + const isFocus = focusKey === node.key; + return ( + <div className="file-node-content-container"> + {Icon && <span className="file-icon-container">{Icon}</span>} + {isFocus && !isReadOnly ? ( + <Form.Item {...validateObj} hasFeedback> + <Input + style={{ + padding: 0, + }} + ref={inputRef} + autoFocus + defaultValue={isCreating ? '' : (node.title as string) || ''} + onChange={(value: string) => { + isValidateName(value, node); + }} + onBlur={(event) => { + // actual: onBlur => onClick + // expect: onCick => onBlur + // in isFocus mode, when click create or delete button, input blur event will trigger first + // so use setTimeout + local ref flag to change trigger order + setTimeout(() => { + if (!isDeleteButtonClick.current) { + onInputBlur(event, node); + } else { + resetState(); + } + }, 100); + }} + onKeyDown={(event: any) => { + onInputKeyPress(event, node); + }} + data-testid={`input-${node.key}`} + /> + </Form.Item> + ) : isShowNodeTooltip ? ( + <Tooltip content={node.title || ''} position="lb"> + <span className="file-export-node-name" data-testid={node.key}> + {node.title} + </span> + </Tooltip> + ) : ( + <span className="file-export-node-name" data-testid={node.key}> + {node.title} + </span> + )} + {!isReadOnly && ( + <span + className="file-action-list-container" + style={isFocus ? { visibility: 'visible' } : undefined} + data-testid={`action-list-container-${node.key}`} + > + {isFocus ? ( + <> + <Tooltip content={i18n.t('create')}> + <Check + style={{ margin: '0 8px' }} + onClick={(event) => { + event.stopPropagation(); + // don't do sth, only trigger input blur event + }} + data-testid={`btn-ok-${node.key}`} + /> + </Tooltip> + <Tooltip content={'删除'}> + <Close + onClick={(event) => { + event.stopPropagation(); + // set local ref flag + isDeleteButtonClick.current = true; + onDeleteFile(node, true); + }} + data-testid={`btn-delete-${node.key}`} + /> + </Tooltip> + </> + ) : ( + <> + <Tooltip content={'编辑'}> + <EditNoUnderline + onClick={(event) => { + event.stopPropagation(); + onEditFile(node); + }} + data-testid={`btn-edit-${node.key}`} + /> + </Tooltip> + <MoreActions + zIndex={9999} + actionList={[ + { + label: '删除', + onClick: () => onDeleteFile(node), + testId: `btn-more-acitons-delete-${node.key}`, + }, + ]} + /> + </> + )} + </span> + )} + </div> + ); + } + function renderFolderTreeNode(node: FileDataNode) { + const isFocus = focusKey === node.key; + const isExpanded = expandedKeys.includes(node.key); + return ( + <div className="file-node-content-container"> + <span className="file-icon-container"> + {isExpanded ? <ArrowFillDown /> : <ArrowFillRight />} + </span> + {isFocus && !isReadOnly ? ( + <Form.Item {...validateObj} hasFeedback> + <Input + style={{ + padding: 0, + }} + ref={inputRef} + autoFocus + defaultValue={isCreating ? '' : (node.title as string) || ''} + onChange={(value: string) => { + isValidateName(value, node); + }} + onBlur={(event) => { + onInputBlur(event, node); + }} + onKeyDown={(event: any) => { + onInputKeyPress(event, node); + }} + data-testid={`input-${node.key}`} + /> + </Form.Item> + ) : isShowNodeTooltip ? ( + <Tooltip content={node.title || ''} position="tl"> + <span className="file-export-node-name" data-testid={node.key}> + {node.title} + </span> + </Tooltip> + ) : ( + <span className="file-export-node-name" data-testid={node.key}> + {node.title} + </span> + )} + + {!isReadOnly && ( + <span + className="file-action-list-container" + style={isFocus ? { visibility: 'visible' } : undefined} + data-testid={`action-list-container-${node.key}`} + > + {isFocus ? ( + <> + <Tooltip content={i18n.t('create')}> + <Check + style={{ margin: '0 8px' }} + onClick={(event) => { + event.stopPropagation(); + // don't do sth, only trigger input blur event + }} + data-testid={`btn-ok-${node.key}`} + /> + </Tooltip> + <Tooltip content={'删除'}> + <Close + onClick={(event) => { + event.stopPropagation(); + // set local ref flag + isDeleteButtonClick.current = true; + onDeleteFile(node, true); + }} + data-testid={`btn-delete-${node.key}`} + /> + </Tooltip> + </> + ) : ( + <> + <Tooltip content={'编辑'}> + <EditNoUnderline + onClick={(event) => { + event.stopPropagation(); + onEditFile(node); + }} + data-testid={`btn-edit-${node.key}`} + /> + </Tooltip> + <Tooltip content={i18n.t('create_folder')}> + <FolderAddFill + style={{ marginLeft: 4 }} + onClick={(event) => { + event.stopPropagation(); + onCreateFile(node, false); + }} + data-testid={`btn-create-folder-${node.key}`} + /> + </Tooltip> + <Tooltip content={i18n.t('create_file')}> + <FileAddFill + style={{ marginLeft: 4 }} + onClick={(event) => { + event.stopPropagation(); + onCreateFile(node, true); + }} + data-testid={`btn-create-file-${node.key}`} + /> + </Tooltip> + <MoreActions + zIndex={9999} + actionList={[ + { + label: '删除', + onClick: () => onDeleteFile(node), + testId: `btn-more-acitons-delete-${node.key}`, + }, + ]} + /> + </> + )} + </span> + )} + </div> + ); + } + function renderTreeNode(treeList: FileDataNode[]) { + return treeList.map((item) => { + const isFocus = focusKey === item.key; + const isSelected = selectedKeys.includes(item.key) && item.isLeaf; + return ( + <StyledTreeNode + key={item.key} + parentKey={item.parentKey} + title={!item.isFolder ? renderFileTreeNode(item) : renderFolderTreeNode(item)} + isLeaf={item.isLeaf} + isFolder={item.isFolder} + code={item.code} + fileExt={item.fileExt} + label={String(item.title)} + data-key={item.key} + data-testid={item.key} + icons={{ + switcherIcon: null, + }} + className={classNames({ + 'is-focus-mode': !!focusKey, + 'is-focus-node': isFocus, + 'is-not-focus-node': !isFocus, + 'folder-selected': isSelected, + })} + > + {renderTreeNode(item.children || [])} + </StyledTreeNode> + ); + }); + } + + function setFileData(fileData: FileData) { + setTempFileData(fileData); + onFileDataChange?.(fileData); + } + function setFilePathToIsReadMap(finalFilePathToIsReadMap: FilePathToIsReadMap) { + filePathToIsReadMap.current = { ...filePathToIsReadMap.current, ...finalFilePathToIsReadMap }; + } + async function renameFileOrFolder(node: FileDataNode, renameKey: string) { + const isFile = !node.isFolder; + const originKey = String(node.key); + + if ( + (isFile && !Object.prototype.hasOwnProperty.call(tempFileData, originKey)) || + originKey === renameKey + ) { + return; + } + + let finalKey = renameKey; + + try { + if (isCreating) { + if (beforeCreate) { + const result = await beforeCreate(node, renameKey, Boolean(node.isFolder)); + + if (result === false) { + deleteFileOrFolder(originKey, !node.isFolder, true); + return; + } + if (result && typeof result === 'string') { + finalKey = result; + } + } + } else { + if (beforeRename) { + const result = await beforeRename(node, originKey, renameKey, Boolean(node.isFolder)); + if (result === false) { + return; + } + if (result && typeof result === 'string') { + finalKey = result; + } + } + } + } catch (error) { + if (isCreating) { + // If beforeCreate throw error(Promise.reject), delete the file/folder + deleteFileOrFolder(originKey, !node.isFolder, true); + } + return; + } + + let tempFileDataCopy = { ...tempFileData }; + + if (isFile) { + tempFileDataCopy[finalKey] = tempFileDataCopy[originKey]; + delete tempFileDataCopy[originKey]; + } else { + const regx = new RegExp(`^${transformRegexSpecChar(originKey)}`); // prefix originKey + // change folder, in other word, change all file under this folder + tempFileDataCopy = Object.keys(tempFileDataCopy).reduce((sum, current) => { + if (!!current.match(regx)) { + const newKey = current.replace(regx, finalKey); + sum[newKey] = tempFileDataCopy[current]; + } else { + sum[current] = tempFileDataCopy[current]; + } + + return sum; + }, {} as FileData); + } + + setFileData(tempFileDataCopy); + + if (isCreating) { + onCreateFinish?.(finalKey, !isFile); + } else { + onRenameFinish?.(node, originKey, finalKey); + } + + // Auto selected new file node + if (isCreating && isFile) { + const extList = finalKey.split('.'); + const nameList = finalKey.split('/'); + const fileExt = extList && extList.length > 0 ? extList[extList.length - 1] : 'default'; + + // trigger mock select event + onSelect( + [finalKey], + { + selected: true, + node: { + props: { + dataRef: { + ...node, + title: nameList[nameList.length - 1], + label: nameList[nameList.length - 1], + key: finalKey, + code: '', + isLeaf: true, + fileExt: fileExt, + isFolder: false, + }, + }, + }, + selectedNodes: [ + { + ...node, + title: nameList[nameList.length - 1], + label: nameList[nameList.length - 1], + key: finalKey, + code: '', + isLeaf: true, + fileExt: fileExt, + isFolder: false, + }, + ], + e: null as any, + }, + true, + ); + } + } + async function deleteFileOrFolder(key: string, isFile = true, isForceDelete = false) { + if (!isForceDelete && beforeDelete) { + try { + const result = await beforeDelete(key, !isFile); + if (result === false) { + return; + } + } catch (error) { + // beforeDelete return Promise.reject, do nothing + return; + } + } + + const toBeDeleteKeys = []; + let tempFileDataCopy = { ...tempFileData }; + if (isFile) { + if (!Object.prototype.hasOwnProperty.call(tempFileData, key)) { + return; + } + // delete node by key + delete tempFileDataCopy[key]; + toBeDeleteKeys.push(key); + } else { + const regx = new RegExp(`^${transformRegexSpecChar(key)}`); // prefix originKey + // delete folder, delete all file under this folder + tempFileDataCopy = Object.keys(tempFileDataCopy).reduce((sum, current) => { + if (!!current.match(regx)) { + // delete + toBeDeleteKeys.push(current); + return sum; + } + + sum[current] = tempFileDataCopy[current]; + + return sum; + }, {} as FileData); + } + + if (!isCreating && onDeleteFinish) { + onDeleteFinish(toBeDeleteKeys, key); + } + + // Cancel focus mode + if (focusKey === key) { + setFocusKey(null); + } + setFileData(tempFileDataCopy); + } + + function resetState() { + setFocusKey(null); + setIsCreating(false); + setValidateObj({ + validateStatus: undefined, + help: '', + }); + isDeleteButtonClick.current = false; + } + function isValidateName(name: string, node: FileDataNode) { + // validate empty + if (!name) { + setValidateObj({ + validateStatus: 'error', + help: i18n.t('valid_error.empty_node_name_invalid'), + }); + return false; + } + if (name === node.title) { + return true; + } + // validate same file path + let tempkey = ''; + if (node.parentKey) { + tempkey = `${node.parentKey}/${name}`; + } else { + tempkey = `${name}`; + } + if ( + Object.prototype.hasOwnProperty.call(tempFileData, tempkey) || + Object.keys(tempFileData).some((innerPath) => { + // Case: tempFileData = { 'main/test.py': '1' }, tempkey = "main" + // There is a folder named "main", so it is not validate name + return innerPath.startsWith(`${tempkey}/`); + }) + ) { + setValidateObj({ + validateStatus: 'error', + help: i18n.t('valid_error.same_node_name_invalid'), + }); + return false; + } + + if (validateObj.validateStatus !== 'success') { + setValidateObj({ + validateStatus: 'success', + help: '', + }); + } + + return true; + } + + function onSelect( + selectedKeys: Key[], + info: { + selected: boolean; + selectedNodes: FileDataNode[]; + node: any; + e: Event; + }, + isForceSelect = false, // 扩展参数,表示是否强制选择 + ) { + // Disable select handler in focus mode + if (!isForceSelect && Boolean(focusKey)) return; + + const node = info?.node?.props.dataRef ?? {}; + const { key, isFolder, code } = node; + + if (info.selected && !isFolder) { + if (isAsyncMode && Object.prototype.hasOwnProperty.call(filePathToIsReadMap.current, key)) { + if (getFile) { + if (filePathToIsReadMap.current[key]) { + onSelectFile?.(key, code!, node); + } else { + setIsLoading(true); + getFile(String(key)) + .then((fileContent) => { + filePathToIsReadMap.current[key] = true; + setFileData({ + ...tempFileData, + [key]: fileContent, + }); + node.code = fileContent; + onSelectFile?.(key, fileContent, node); + setIsLoading(false); + }) + .catch((error) => { + Message.error(error.message); + setIsLoading(false); + }); + } + } else { + onSelectFile?.(key, code!, node); + } + } else { + onSelectFile?.(key, code!, node); + } + } + if (isFolder) { + if (expandedKeys.includes(key)) { + setExpandedKeys([...expandedKeys.filter((item) => item !== key)]); + } else { + setExpandedKeys([...expandedKeys, key]); + } + } + setSelectedKeys(selectedKeys); + onSelectFromProps?.(selectedKeys, info as any); + } + function onExpand(expandedKeys: Array<Key>) { + setExpandedKeys(expandedKeys); + } + async function onInputBlur(event: React.FocusEvent<HTMLInputElement>, node: FileDataNode) { + event.stopPropagation(); + const inputValue = event.target.value; + + if (!isValidateName(inputValue, node)) { + inputRef.current?.focus(); + return; + } + + const finalKey = String(node.key).replace(String(node.title), inputValue); + + await renameFileOrFolder(node, finalKey); + + resetState(); + } + async function onInputKeyPress(event: any, node: FileDataNode) { + if (event.key === 'Escape') { + if (isCreating) { + // remove temp node + await deleteFileOrFolder(String(focusKey), !node.isFolder, true); + } + resetState(); + return; + } + if (event.key === 'Enter') { + const inputValue = event.target.value; + + if (!isValidateName(inputValue, node)) return; + + const finalKey = String(node.key).replace(String(node.title), inputValue); + await renameFileOrFolder(node, finalKey); + resetState(); + return; + } + } + function onCreateFile(node?: FileDataNode, isFile?: boolean) { + let tempKey = ''; + const folderKey = node ? (node.isFolder ? node.key : node.parentKey) : ''; + if (folderKey) { + // set folder expand key + setExpandedKeys((prevState) => [...prevState, folderKey]); + // insert new temp node on folder + tempKey = `${folderKey}/${giveWeakRandomKey()}`; + } else { + // insert new temp node on root + tempKey = `${giveWeakRandomKey()}`; + } + + setIsCreating(true); + setFocusKey(tempKey); + setFileData({ ...tempFileData, [tempKey]: isFile ? '' : null }); // null will be treated as folder + } + function onEditFile(node: FileDataNode) { + onClickRename?.(node); + setFocusKey(node.key); + } + function onDeleteFile(node: FileDataNode, isForceDelete = false) { + deleteFileOrFolder(String(node.key), !node.isFolder, isForceDelete); + } + + function selectFirstFileNode(fileTreeList: FileDataNode[] = formattedTreeData) { + if (isAlreadySelectFirstNode.current) return; + + const node = getFirstFileNode(fileTreeList); + + // If there is no file node, isAutoSelectFirstFile will be invalid + if (isAsyncMode && isAlreadyFetchedFileTreeList.current && !node) { + isAlreadySelectFirstNode.current = true; + return; + } + + if (node) { + isAlreadySelectFirstNode.current = true; + // Trigger mock select event + onSelect( + [node.key], + { + selected: true, + node: { + props: { + dataRef: { + ...node, + title: node.title, + label: node.title as string, + }, + }, + }, + selectedNodes: [ + { + ...node, + title: node.title, + label: node.title as string, + }, + ], + e: null as any, + }, + true, + ); + } + } +}; + +export default forwardRef(FileExplorer); diff --git a/web_console_v2/client/src/components/FileUpload/index.module.less b/web_console_v2/client/src/components/FileUpload/index.module.less new file mode 100644 index 000000000..0142bb639 --- /dev/null +++ b/web_console_v2/client/src/components/FileUpload/index.module.less @@ -0,0 +1,24 @@ +.file_upload_inner{ + padding: 20px 0; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + background-color: var(--color-fill-2); +} + +.file_upload_placeholder{ + line-height: 20px; + font-size: 12px; +} + +.file_upload_tip{ + font-size: 12px; + line-height: 18px; + color: var(--textColorSecondary); +} + +.plus_icon{ + margin-bottom: 10px; + font-size: 20px; +} diff --git a/web_console_v2/client/src/components/FileUpload/index.tsx b/web_console_v2/client/src/components/FileUpload/index.tsx new file mode 100644 index 000000000..436c5962f --- /dev/null +++ b/web_console_v2/client/src/components/FileUpload/index.tsx @@ -0,0 +1,160 @@ +/* istanbul ignore file */ + +import { Message, Upload, UploadProps } from '@arco-design/web-react'; +import { PlusCircle } from 'components/IconPark'; +import React, { FC, useState } from 'react'; +import { isNil, omit } from 'lodash-es'; +import { humanFileSize } from 'shared/file'; +import { getJWTHeaders } from 'shared/helpers'; +import { UploadItem } from '@arco-design/web-react/es/Upload'; +import styles from './index.module.less'; + +export enum UploadFileType { + Dataset = 'dataset', +} + +export type UploadFile = { + /** + * File display name with folder displayed in code editor. + * + * Examples: "test/test.py","syslib.bin" + */ + display_file_name: string; + /** Internal store location for uploaded file */ + internal_path: string; + /** Internal store parent directory for upload file */ + internal_directory: string; + /** + * File content that will be visible and editable for users + * + * Applicable only to human-readable text files + */ + content?: string; +}; + +type Props = Omit<UploadProps, 'action' | 'headers' | 'onChange'> & { + /** Path string */ + value?: string[]; + /** Path string list */ + onChange?: (val: UploadFile[]) => void; + /** Will be as http post body data, i.e. { kind } */ + kind?: UploadFileType; + action?: UploadProps['action']; + headers?: UploadProps['headers']; + /** Upload success */ + onSuccess?: (info: UploadItem) => void; + /** Upload error */ + onError?: (error: any) => void; + /** Max file size */ + maxSize?: number; + maxCount?: number; + /** + * File size unit + * + * True - 1024 + * + * False - 1000 + */ + isBinaryUnit?: boolean; + /** Number of decimal places to display maxSize */ + dp?: number; +}; + +const FileUpload: FC<Props> = ({ + kind, + action = '/api/v2/files', + headers, + onSuccess, + onError, + onChange, + value, + maxSize, + isBinaryUnit = true, + dp = 1, + ...props +}) => { + const [uidToPathMap, setMap] = useState<{ [key: string]: UploadFile }>({}); + + return ( + <Upload + drag={true} + data={kind ? { kind } : undefined} + limit={props.maxCount} + headers={{ ...getJWTHeaders(), ...headers }} + {...props} + action={action} + multiple + onChange={onUploadChange} + onRemove={onFileRemove} + beforeUpload={beforeFileUpload} + > + <div className={styles.file_upload_inner}> + <PlusCircle className={styles.plus_icon} /> + <div className={styles.file_upload_placeholder}>点击或拖拽文件到此处上传</div> + <small className={styles.file_upload_tip}> + {props.accept && `请上传${props.accept.split(',').join('/')}格式文件`} + {props.accept && maxSize && `,`} + {maxSize && `大小不超过${humanFileSize(maxSize, !isBinaryUnit, dp)}`} + </small> + </div> + </Upload> + ); + + function onUploadChange(fileList: UploadItem[], info: UploadItem) { + const { status, uid, originFile, response } = info; + + if (status === 'done') { + onSuccess?.(info); + const uploadedFile = (response as any)?.data?.uploaded_files[0]; + + let nextMap: typeof uidToPathMap; + + // Will replace current one when maxCount is 1 + if (props.maxCount === 1) { + nextMap = { [uid]: uploadedFile }; + } else { + nextMap = { ...uidToPathMap, [uid]: uploadedFile }; + } + setMap(nextMap); + onChange?.(Object.values(nextMap)); + } else if (status === 'error') { + Message.error(`${originFile?.name} 上传失败`); + onError?.(originFile); + } + } + function onFileRemove(file: UploadItem) { + const { uid } = file; + + if (uidToPathMap[uid]) { + const path = uidToPathMap[uid]; + const index = value?.findIndex((item) => item === path.internal_path); + + if (!isNil(index) && index > -1) { + const next = [...(value ?? [])]; + next.splice(index, 1); + + const newMap = omit(uidToPathMap, uid); + setMap(newMap); + + onChange?.( + next.map((item) => { + return { + display_file_name: item, + internal_path: item, + internal_directory: item, + }; + }), + ); + } + } + } + function beforeFileUpload(file: File, filesList: File[]) { + if (maxSize && file.size > maxSize) { + Message.warning(`文件大小不能超过 ${humanFileSize(maxSize, !isBinaryUnit, dp)}`); + return false; + } + return props.beforeUpload ? props.beforeUpload(file, filesList) : true; + } +}; + +export default FileUpload; diff --git a/web_console_v2/client/src/components/Header/ProjectSelectNew.tsx b/web_console_v2/client/src/components/Header/ProjectSelectNew.tsx new file mode 100644 index 000000000..b20e28573 --- /dev/null +++ b/web_console_v2/client/src/components/Header/ProjectSelectNew.tsx @@ -0,0 +1,135 @@ +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useRecoilQuery } from 'hooks/recoil'; +import { projectListQuery, projectState } from 'stores/project'; +import { useRecoilState } from 'recoil'; +import store from 'store2'; +import LOCAL_STORAGE_KEYS from 'shared/localStorageKeys'; +import { Select } from '@arco-design/web-react'; +import { IconDown } from '@arco-design/web-react/icon'; +import styled from 'styled-components'; +import { useUrlState } from 'hooks'; +import { Project } from 'typings/project'; +import { useLocation } from 'react-router-dom'; +const Option = Select.Option; + +type Props = { + isHidden?: boolean; +}; + +const StyledSelect = styled(Select)` + max-width: 240px; + .arco-select-view-value { + color: white; + } + .arco-select-view-input { + color: white; + } +`; + +interface IUrlState { + project_id?: ID; +} + +const ProjectSelectNew: FC<Props> = memo(({ isHidden }) => { + const location = useLocation(); + const [urlState, setUrlState] = useUrlState<IUrlState>( + { project_id: undefined }, + { navigateMode: 'replace' }, + ); + const [filterValue, setFilterValue] = useState<string>(''); + const projectsQuery = useRecoilQuery(projectListQuery); + const [selectProject, setSelectProject] = useRecoilState(projectState); + const projectList = useMemo(() => { + const tempList = projectsQuery?.data?.filter((item) => item.name.indexOf(filterValue) !== -1); + if (!filterValue) { + return tempList; + } + return tempList.sort((a, b) => (a.name.length < b.name.length ? -1 : 1)); + }, [filterValue, projectsQuery]); + + const idNotExist = (id?: ID) => { + return !id && id !== 0; + }; + + const refreshSelectProject = useCallback( + (selectProject: Project | undefined) => { + setSelectProject({ current: selectProject }); + store.set(LOCAL_STORAGE_KEYS.current_project, selectProject); + }, + [setSelectProject], + ); + + const refreshUrl = useCallback( + (project_id) => { + setUrlState((pre) => ({ + ...pre, + project_id: project_id, + })); + }, + [setUrlState], + ); + + const removeProject = () => { + setSelectProject({ current: undefined }); + store.remove(LOCAL_STORAGE_KEYS.current_project); + }; + + useEffect(() => { + const urlProjectIdExist = Boolean(urlState.project_id); + const notMatch = Boolean(selectProject.current?.id !== parseInt(urlState.project_id)); + if (urlProjectIdExist && notMatch) { + const selectProject = projectsQuery.data?.find((p) => p.id === parseInt(urlState.project_id)); + if (selectProject) { + refreshSelectProject(selectProject); + } + } else { + refreshUrl(selectProject.current?.id); + } + // watching location.pathname is necessary there for adding the project_id to the URL when switching pages + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectsQuery.data, location.pathname]); + + const handleOnChange = (projectId: ID) => { + if (idNotExist(projectId)) { + removeProject(); + } else { + const selectProject = projectsQuery.data.find((p) => p.id === projectId); + refreshSelectProject(selectProject); + } + refreshUrl(projectId); + }; + + if (projectsQuery.isLoading || !projectsQuery.data) { + return <div style={{ gridArea: 'project-select' }} />; + } + + return isHidden ? ( + <div className="empty" /> + ) : ( + <StyledSelect + size={'small'} + bordered={false} + arrowIcon={<IconDown />} + allowClear={true} + value={selectProject.current?.id} + placeholder={'请先选择工作区!'} + showSearch={true} + onChange={handleOnChange} + filterOption={false} + getPopupContainer={() => document.getElementById('page-header') as Element} + onInputValueChange={(value) => { + setFilterValue(value); + }} + > + {projectList?.map((item) => { + return ( + <Option key={item.id} value={item.id}> + {item.name} + </Option> + ); + })} + </StyledSelect> + ); +}); + +export default ProjectSelectNew; diff --git a/web_console_v2/client/src/components/Header/Version.tsx b/web_console_v2/client/src/components/Header/Version.tsx new file mode 100644 index 000000000..72364a927 --- /dev/null +++ b/web_console_v2/client/src/components/Header/Version.tsx @@ -0,0 +1,149 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo } from 'react'; +import ClickToCopy from 'components/ClickToCopy'; +import styled from 'styled-components'; +import { useQuery } from 'react-query'; +import { Branch } from 'components/IconPark'; +import { fetchSystemVersion } from 'services/system'; +import { CONSTANTS } from 'shared/constants'; +import { Popover } from '@arco-design/web-react'; +import { omitBy, isNull } from 'lodash-es'; + +const Container = styled.div` + display: flex; + grid-area: version; + align-items: center; + padding: 5px 8px; + color: rgb(var(--gray-10)); + line-height: 1; + font-size: 12px; + border-radius: 5px; + font-weight: bold; + overflow: hidden; + opacity: 0.5; + transition: 0.12s ease-in; + background-color: rgb(var(--gray-3)); + border: 1px solid rgb(var(--gray-3)); + + &:hover { + opacity: 0.9; + } +`; +const VersionIcon = styled(Branch)` + font-size: 12px; +`; +const VersionText = styled.div` + padding: 5px 8px 5px 6px; + margin: -5px -8px -5px 5px; + background-color: #fff; +`; +const DetailList = styled.ul` + list-style: none; +`; +const DetailItem = styled.li` + display: flex; + align-items: center; + justify-content: space-between; + width: 250px; + height: 30px; +`; + +const HeaderVersion: FC = () => { + const versionQuery = useQuery('fetchVersion', fetchSystemVersion, { + refetchOnWindowFocus: false, + staleTime: 100 * 60 * 1000, + }); + + const version = useMemo(() => { + if (!versionQuery.data?.data) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + + if (versionQuery.data.data.version) { + return versionQuery.data.data.version; + } + + if (versionQuery.data.data.revision) { + return versionQuery.data.data.revision.slice(-6); + } + + return CONSTANTS.EMPTY_PLACEHOLDER; + }, [versionQuery.data]); + + const sha = useMemo(() => { + if (!versionQuery.data?.data) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + + if (versionQuery.data.data.revision) { + return versionQuery.data.data.revision.slice(0, 12); + } + + return CONSTANTS.EMPTY_PLACEHOLDER; + }, [versionQuery.data]); + + const pubDate = useMemo(() => { + if (!versionQuery.data?.data) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + if (versionQuery.data.data.pub_date) { + return versionQuery.data.data.pub_date; + } + + return CONSTANTS.EMPTY_PLACEHOLDER; + }, [versionQuery.data]); + + const infoToBeCopy = useMemo(() => { + if (!versionQuery.data?.data) { + return ''; + } + const { revision, pub_date, version } = versionQuery.data.data; + + // Filter null field + return omitBy( + { + revision: revision, + pub_date: pub_date, + version: version, + }, + isNull, + ); + }, [versionQuery.data]); + + if (!versionQuery.data) { + return null; + } + + return ( + <ClickToCopy text={JSON.stringify(infoToBeCopy ?? '')}> + <Popover trigger={['hover']} content={renderDetails()} title={'当前版本'} position="br"> + <Container> + <VersionIcon /> + <VersionText>{version}</VersionText> + </Container> + </Popover> + </ClickToCopy> + ); + + function renderDetails() { + return ( + <DetailList> + <DetailItem> + <strong>Version</strong> + <span>{version}</span> + </DetailItem> + <DetailItem> + <strong>SHA</strong> + <span>{sha}</span> + </DetailItem> + <DetailItem> + <strong>发布日期</strong> + <span>{pubDate}</span> + </DetailItem> + </DetailList> + ); + } +}; + +export default HeaderVersion; diff --git a/web_console_v2/client/src/components/HorizontalBarChart/index.tsx b/web_console_v2/client/src/components/HorizontalBarChart/index.tsx new file mode 100644 index 000000000..5826eccad --- /dev/null +++ b/web_console_v2/client/src/components/HorizontalBarChart/index.tsx @@ -0,0 +1,108 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo } from 'react'; +import { Bar } from 'react-chartjs-2'; + +type Item = { + label: string; + value: any; +}; + +type Props = { + valueList: Item[]; + formatData?: (valueList: Item[]) => any; + options?: any; + width?: number; + height?: number; + maxValue?: number; +}; + +const defaultFormatData = (valueList: Item[]) => { + const labels: any[] = []; + const data: any[] = []; + + valueList.forEach((item) => { + labels.push(item.label); + data.push(item.value); + }); + + const finalData = { + labels, + datasets: [ + { + data, + backgroundColor: '#468DFF', + borderWidth: 0, + barPercentage: 0.6, + }, + ], + }; + + return finalData; +}; + +const defaultMaxValue = 1; + +const getDefaultOptions = (maxValue = 1) => ({ + maintainAspectRatio: false, + indexAxis: 'y', + // Elements options apply to all of the options unless overridden in a dataset + // In this case, we are setting the border of each horizontal bar to be 2px wide + elements: { + bar: { + borderWidth: 0, + }, + }, + responsive: true, + plugins: { + legend: { + display: false, + }, + title: { + display: false, + }, + }, + scales: { + y: { + grid: { + color: 'transparent', + tickColor: '#cecece', + }, + }, + x: { + grid: { + drawBorder: false, + }, + min: 0, + // max: maxValue * 1.2, + // ticks: { + // min: 0, + // max: maxValue * 1.2, + // suggestedMin: 0, + // suggestedMax: maxValue * 1.2, + // stepSize: 0.2, + // }, + }, + }, +}); + +const HorizontalBarChart: FC<Props> = ({ + valueList, + formatData = defaultFormatData, + options, + width, + height, + maxValue = defaultMaxValue, +}) => { + const data = useMemo(() => { + return formatData(valueList); + }, [valueList, formatData]); + + const defaultOptions = useMemo(() => { + return getDefaultOptions(maxValue); + }, [maxValue]); + + return <Bar data={data} options={options || defaultOptions} width={width} height={height} />; +}; + +export default HorizontalBarChart; diff --git a/web_console_v2/client/src/components/IconPark/fileIcon/Config.tsx b/web_console_v2/client/src/components/IconPark/fileIcon/Config.tsx new file mode 100644 index 000000000..d2e9eb75e --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/fileIcon/Config.tsx @@ -0,0 +1,24 @@ +/** + * @file Config config + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'config', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M0.920044 8.74992V7.24992C1.25004 7.21992 1.50504 7.20492 1.65504 7.09992C1.80504 7.05492 1.94004 6.90492 2.06004 6.73992C2.18004 6.55992 2.25504 6.36492 2.31504 6.07992C2.34504 5.88492 2.39004 5.55492 2.39004 5.07492C2.39004 4.26492 2.42004 3.70992 2.51004 3.37992C2.58504 3.07992 2.73504 2.79492 2.91504 2.61492C3.11004 2.43492 3.42504 2.28492 3.80004 2.16492C4.05504 2.11992 4.46004 2.04492 5.04504 2.04492H5.40504V3.51492C4.92504 3.51492 4.59504 3.54492 4.47504 3.61992C4.35504 3.66492 4.25004 3.73992 4.14504 3.87492C4.07004 3.97992 4.04004 4.15992 4.04004 4.39992C4.04004 4.65492 4.01004 5.16492 3.96504 5.86992C3.93504 6.30492 3.89004 6.63492 3.81504 6.85992C3.71004 7.11492 3.59004 7.30992 3.45504 7.48992C3.33504 7.63992 3.09504 7.81992 2.82504 7.99992C3.08004 8.14992 3.30504 8.29992 3.45504 8.47992C3.60504 8.65992 3.74004 8.91492 3.83004 9.15492C3.93504 9.43992 3.98004 9.78492 3.98004 10.2349C4.01004 10.9099 4.01004 11.3449 4.01004 11.5549C4.01004 11.8399 4.04004 12.0049 4.11504 12.1399C4.19004 12.2599 4.31004 12.3199 4.44504 12.3949C4.56504 12.4399 4.89504 12.4999 5.37504 12.4999V13.9699H5.00004C4.41504 13.9699 3.93504 13.9399 3.66504 13.8499C3.33504 13.7449 3.08004 13.5949 2.85504 13.3999C2.63004 13.2049 2.49504 12.9499 2.40504 12.6349C2.33004 12.3349 2.30004 11.8549 2.30004 11.1949C2.30004 10.4299 2.27004 9.93492 2.19504 9.72492C2.09004 9.39492 1.94004 9.13992 1.74504 8.98992C1.62504 8.83992 1.31004 8.74992 0.920044 8.74992ZM15.035 8.74992C14.705 8.77992 14.45 8.79492 14.3 8.89992C14.15 8.94492 14.015 9.09492 13.895 9.25992C13.775 9.43992 13.7 9.63492 13.64 9.91992C13.61 10.1149 13.565 10.4449 13.565 10.9249C13.565 11.7349 13.535 12.2899 13.445 12.6199C13.37 12.9499 13.22 13.2049 13.04 13.3849C12.845 13.5649 12.53 13.7149 12.155 13.8349C11.9 13.8799 11.495 13.9549 10.91 13.9549H10.55V12.4849C11.03 12.4849 11.315 12.4549 11.48 12.3799C11.63 12.3349 11.735 12.2299 11.81 12.1249C11.885 12.0199 11.915 11.8399 11.915 11.5999C11.915 11.3599 11.945 10.8649 11.99 10.1599C12.02 9.72492 12.095 9.37992 12.185 9.15492C12.29 8.86992 12.41 8.67492 12.56 8.49492C12.71 8.31492 12.92 8.16492 13.16 8.01492C12.755 7.75992 12.5 7.57992 12.38 7.41492C12.185 7.12992 12.02 6.78492 11.975 6.40992C11.9 6.10992 11.87 5.47992 11.87 4.51992C11.87 4.21992 11.84 4.00992 11.765 3.88992C11.69 3.78492 11.615 3.70992 11.48 3.63492C11.36 3.58992 11.03 3.52992 10.52 3.52992V2.05992H10.88C11.465 2.05992 11.945 2.08992 12.215 2.17992C12.545 2.28492 12.8 2.43492 13.025 2.62992C13.25 2.82492 13.385 3.07992 13.475 3.39492C13.55 3.69492 13.595 4.17492 13.595 4.83492C13.595 5.59992 13.625 6.07992 13.7 6.30492C13.805 6.63492 13.955 6.88992 14.15 6.97992C14.345 7.12992 14.66 7.17492 15.035 7.23492V8.74992ZM10.79 8.59992C10.535 8.52492 10.385 8.29992 10.385 8.04492C10.385 7.78992 10.565 7.56492 10.79 7.48992C10.865 7.45992 10.91 7.38492 10.895 7.30992C10.82 7.02492 10.715 6.78492 10.565 6.54492C10.535 6.46992 10.445 6.43992 10.37 6.49992C10.295 6.54492 10.19 6.57492 10.085 6.57492C9.75504 6.57492 9.50004 6.28992 9.50004 5.98992C9.50004 5.88492 9.53004 5.79492 9.57504 5.70492C9.60504 5.62992 9.57504 5.55492 9.53004 5.50992C9.30504 5.35992 9.02004 5.25492 8.76504 5.17992C8.69004 5.14992 8.61504 5.20992 8.58504 5.28492C8.51004 5.53992 8.28504 5.68992 8.03004 5.68992C7.77504 5.68992 7.55004 5.50992 7.47504 5.28492C7.44504 5.20992 7.37004 5.16492 7.29504 5.17992C7.01004 5.25492 6.77004 5.35992 6.53004 5.50992C6.45504 5.53992 6.42504 5.62992 6.48504 5.70492C6.53004 5.77992 6.56004 5.88492 6.56004 5.98992C6.56004 6.31992 6.27504 6.57492 5.97504 6.57492C5.87004 6.57492 5.78004 6.54492 5.69004 6.49992C5.61504 6.46992 5.54004 6.49992 5.49504 6.54492C5.34504 6.76992 5.24004 7.05492 5.16504 7.30992C5.13504 7.38492 5.19504 7.45992 5.27004 7.48992C5.52504 7.56492 5.67504 7.78992 5.67504 8.04492C5.67504 8.29992 5.49504 8.52492 5.27004 8.59992C5.19504 8.62992 5.15004 8.70492 5.16504 8.77992C5.24004 9.06492 5.34504 9.30492 5.49504 9.54492C5.52504 9.61992 5.61504 9.64992 5.69004 9.58992C5.76504 9.54492 5.87004 9.51492 5.97504 9.51492C6.30504 9.51492 6.56004 9.79992 6.56004 10.0999C6.56004 10.2049 6.53004 10.2949 6.48504 10.3849C6.45504 10.4599 6.48504 10.5349 6.53004 10.5799C6.75504 10.7299 7.04004 10.8349 7.29504 10.9099H7.32504C7.37004 10.9099 7.44504 10.8799 7.47504 10.8049C7.55004 10.5499 7.77504 10.3999 8.03004 10.3999C8.28504 10.3999 8.51004 10.5799 8.58504 10.8049C8.61504 10.8799 8.69004 10.9249 8.76504 10.9099C9.05005 10.8349 9.29004 10.7299 9.53004 10.5799C9.60504 10.5499 9.63505 10.4599 9.57504 10.3849C9.53004 10.3099 9.50004 10.2049 9.50004 10.0999C9.50004 9.76992 9.78504 9.51492 10.085 9.51492C10.19 9.51492 10.28 9.54492 10.37 9.58992C10.445 9.61992 10.52 9.58992 10.565 9.54492C10.715 9.31992 10.82 9.03492 10.895 8.77992C10.925 8.68992 10.865 8.61492 10.79 8.59992ZM7.98504 8.91492C7.47504 8.91492 7.07004 8.50992 7.07004 7.99992C7.07004 7.48992 7.47504 7.08492 7.98504 7.08492C8.49504 7.08492 8.90004 7.48992 8.90004 7.99992C8.90004 8.52492 8.49504 8.91492 7.98504 8.91492Z" + fill="#FEC745" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/fileIcon/Default.tsx b/web_console_v2/client/src/components/IconPark/fileIcon/Default.tsx new file mode 100644 index 000000000..bd56f5933 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/fileIcon/Default.tsx @@ -0,0 +1,26 @@ +/** + * @file Default default + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'default', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M9.91403 1.7002C10.2328 1.7002 10.5386 1.82706 10.7637 2.0528L13.0496 4.34485C13.274 4.5698 13.4 4.87454 13.4 5.19225V13.7002C13.4 14.0316 13.1313 14.3002 12.8 14.3002H3.19998C2.8686 14.3002 2.59998 14.0316 2.59998 13.7002V2.3002C2.59998 1.96882 2.8686 1.7002 3.19998 1.7002H9.91403ZM8.29998 9.2002C8.46566 9.2002 8.59998 9.33451 8.59998 9.5002V10.2369C8.59998 10.4026 8.46566 10.5369 8.29998 10.5369H5.59998C5.43429 10.5369 5.29998 10.4026 5.29998 10.2369V9.5002C5.29998 9.33451 5.43429 9.2002 5.59998 9.2002H8.29998ZM10.4 6.04139C10.5657 6.04139 10.7 6.17571 10.7 6.34139V7.1002C10.7 7.26588 10.5657 7.4002 10.4 7.4002H5.59998C5.43429 7.4002 5.29998 7.26588 5.29998 7.1002V6.34139C5.29998 6.17571 5.43429 6.04139 5.59998 6.04139H10.4Z" + fill="#628099" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/fileIcon/GitIgnore.tsx b/web_console_v2/client/src/components/IconPark/fileIcon/GitIgnore.tsx new file mode 100644 index 000000000..603eaf113 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/fileIcon/GitIgnore.tsx @@ -0,0 +1,24 @@ +/** + * @file GitIgnore git-ignore + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'git-ignore', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M14.9283 7.35862L8.64127 1.07199C8.55515 0.985854 8.4529 0.917525 8.34037 0.870906C8.22784 0.824287 8.10723 0.800293 7.98542 0.800293C7.86362 0.800293 7.74301 0.824287 7.63048 0.870906C7.51795 0.917525 7.4157 0.985854 7.32958 1.07199L5.86896 2.53262L6.96825 3.63191C7.22015 3.51161 7.50316 3.47235 7.7783 3.51956C8.05344 3.56676 8.30717 3.69811 8.50457 3.8955C8.70196 4.0929 8.83331 4.34663 8.88051 4.62177C8.92772 4.89691 8.88847 5.17992 8.76816 5.43182L10.5676 7.23128C10.855 7.09403 11.1816 7.06277 11.4898 7.14305C11.7979 7.22333 12.0678 7.40997 12.2517 7.66997C12.4355 7.92998 12.5215 8.24662 12.4945 8.56392C12.4675 8.88122 12.3292 9.17875 12.104 9.40393C11.8788 9.62911 11.5813 9.76743 11.264 9.79445C10.9467 9.82147 10.6301 9.73544 10.37 9.55158C10.11 9.36772 9.9234 9.09785 9.84312 8.78969C9.76285 8.48152 9.7941 8.1549 9.93135 7.86755L8.13189 6.06809C8.08902 6.08874 8.045 6.10691 8.00005 6.12254V9.8767C8.30028 9.98285 8.55332 10.1917 8.71445 10.4664C8.87557 10.7411 8.93441 11.0638 8.88056 11.3777C8.82671 11.6916 8.66364 11.9763 8.42017 12.1815C8.17671 12.3868 7.86851 12.4994 7.55007 12.4994C7.23163 12.4994 6.92344 12.3868 6.67997 12.1815C6.4365 11.9763 6.27343 11.6916 6.21958 11.3777C6.16573 11.0638 6.22457 10.7411 6.3857 10.4664C6.54682 10.1917 6.79986 9.98285 7.10009 9.8767V6.12254C6.92159 6.05944 6.75836 5.9595 6.62099 5.82921C6.48363 5.69892 6.37521 5.5412 6.30277 5.36628C6.23033 5.19136 6.1955 5.00315 6.20053 4.8139C6.20556 4.62464 6.25035 4.43855 6.33198 4.26773L5.23269 3.16844L1.07175 7.32893C0.98561 7.41505 0.91728 7.51729 0.870662 7.62982C0.824043 7.74235 0.800049 7.86296 0.800049 7.98477C0.800049 8.10657 0.824043 8.22718 0.870662 8.33972C0.91728 8.45225 0.98561 8.55449 1.07175 8.64061L7.35928 14.9272C7.4454 15.0134 7.54765 15.0817 7.66018 15.1283C7.77271 15.1749 7.89332 15.1989 8.01512 15.1989C8.13693 15.1989 8.25754 15.1749 8.37007 15.1283C8.4826 15.0817 8.58484 15.0134 8.67096 14.9272L14.9283 8.66986C15.0145 8.58374 15.0828 8.48149 15.1294 8.36896C15.1761 8.25643 15.2 8.13582 15.2 8.01402C15.2 7.89221 15.1761 7.7716 15.1294 7.65907C15.0828 7.54654 15.0145 7.44429 14.9283 7.35817V7.35862Z" + fill="#FFA841" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/fileIcon/JavaScript.tsx b/web_console_v2/client/src/components/IconPark/fileIcon/JavaScript.tsx new file mode 100644 index 000000000..0ee53ea43 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/fileIcon/JavaScript.tsx @@ -0,0 +1,24 @@ +/** + * @file Javascript javascript + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'javascript', + false, + (props: ISvgIconProps) => ( + <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M1 0C0.447715 0 0 0.447715 0 1V11C0 11.5523 0.447715 12 1 12H11C11.5523 12 12 11.5523 12 11V1C12 0.447715 11.5523 0 11 0H1ZM6.53036 9.35893C6.53036 10.5268 5.84464 11.0598 4.84554 11.0598C3.94286 11.0598 3.42054 10.5938 3.15268 10.0286L4.07143 9.47411C4.24821 9.7875 4.40893 10.0527 4.79732 10.0527C5.16696 10.0527 5.40268 9.90804 5.40268 9.34286V5.50982H6.53036V9.35893ZM9.19821 11.0598C8.15089 11.0598 7.47321 10.5616 7.14375 9.90804L8.0625 9.37768C8.30357 9.77143 8.61964 10.0634 9.17411 10.0634C9.64018 10.0634 9.94018 9.83036 9.94018 9.50625C9.94018 9.12054 9.63482 8.98393 9.11786 8.75625L8.83661 8.63571C8.02232 8.29018 7.48393 7.85357 7.48393 6.93482C7.48393 6.08839 8.12946 5.44554 9.13393 5.44554C9.85179 5.44554 10.3661 5.69464 10.7357 6.34821L9.85714 6.91071C9.66429 6.56518 9.45536 6.42857 9.13125 6.42857C8.80179 6.42857 8.59286 6.6375 8.59286 6.91071C8.59286 7.24821 8.80179 7.38482 9.28661 7.59643L9.56786 7.71696C10.5268 8.12679 11.0652 8.54732 11.0652 9.49018C11.0652 10.5027 10.267 11.0598 9.19821 11.0598Z" + fill="#FFAF38" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/fileIcon/Json.tsx b/web_console_v2/client/src/components/IconPark/fileIcon/Json.tsx new file mode 100644 index 000000000..1a9e2e934 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/fileIcon/Json.tsx @@ -0,0 +1,32 @@ +/** + * @file Json json + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'json', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M3.05956 9.3008C3.05956 11.5189 4.19803 13.6286 5.84481 14.6591C3.03344 13.7502 1.00012 11.1111 1.00012 7.99715C1.00012 4.19857 4.0258 1.10666 7.79859 1C10.902 1.10174 12.199 3.91217 12.199 6.22454C12.199 8.47716 11.7949 11.2187 8.14371 11.438C9.64657 10.9981 10.7505 9.53443 10.7505 7.7966C10.7505 5.71093 9.16045 4.02017 7.19904 4.02017C6.93893 4.02017 6.68535 4.0499 6.44114 4.10635C4.98564 4.39796 3.05956 5.60796 3.05956 9.3008Z" + fill="#846BCE" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M12.9409 6.6992C12.9409 4.48113 11.8025 2.37143 10.1557 1.34094C12.967 2.24983 15.0004 4.88888 15.0004 8.00285C15.0004 11.8014 11.9747 14.8933 8.2019 15C5.09852 14.8983 3.80149 12.0878 3.80149 9.77546C3.80149 7.52284 4.20558 4.78129 7.85677 4.56204C6.35392 5.0019 5.25 6.46557 5.25 8.2034C5.25 10.2891 6.84004 11.9798 8.80145 11.9798C9.06156 11.9798 9.31514 11.9501 9.55934 11.8936C11.0148 11.602 12.9409 10.392 12.9409 6.6992Z" + fill="#846BCE" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/fileIcon/Markdown.tsx b/web_console_v2/client/src/components/IconPark/fileIcon/Markdown.tsx new file mode 100644 index 000000000..ddc3fda30 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/fileIcon/Markdown.tsx @@ -0,0 +1,32 @@ +/** + * @file MarkDown markdown + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'markdown', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M13.5265 4.00024H11.5151V8.55859H9.04089L12.5205 12.4396L16.0001 8.55859H13.5265V4.00024Z" + fill="#3ADEAC" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M6.57023 4.00024H8.58165V12.4396H6.57023V7.12947L6.04568 7.75459L4.49563 9.62392L3.42792 8.3415L2.41145 7.13012V12.4399H0.400024V4.00061H2.41068L2.41099 4.00035L2.41121 4.00061H2.41145V4.0009L4.49057 6.47869L6.57023 4.00024Z" + fill="#3ADEAC" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/fileIcon/Python.tsx b/web_console_v2/client/src/components/IconPark/fileIcon/Python.tsx new file mode 100644 index 000000000..07b567b2f --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/fileIcon/Python.tsx @@ -0,0 +1,32 @@ +/** + * @file Python python + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'python', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M2.60638 10.1862C2.83607 10.4764 3.1504 10.6577 3.46472 10.7302C3.63672 10.75 3.73828 10.75 4.19008 10.75H4.51379V9.3543C4.51379 8.41541 5.27491 7.6543 6.21379 7.6543H8.46235C9.43564 7.64618 9.46794 7.63715 9.6476 7.58688C9.661 7.58312 9.67523 7.57914 9.69073 7.57492C10.1139 7.45402 10.4282 7.21224 10.6095 6.87373C10.6579 6.77702 10.7183 6.59568 10.7425 6.4627C10.7667 6.37807 10.7667 6.25718 10.7667 4.84273V3.30738L10.7425 3.22275C10.6941 3.07768 10.6095 2.89634 10.537 2.81172C10.5043 2.79539 10.4661 2.75148 10.4299 2.70979C10.4125 2.68976 10.3955 2.67025 10.3798 2.65456C9.88416 2.20725 8.97746 1.97755 7.72017 2.00173C7.59498 2.00173 7.46979 2.0107 7.37193 2.01772C7.30913 2.02222 7.25758 2.02591 7.2245 2.02591C6.08811 2.11054 5.39901 2.42486 5.14514 2.9447C5.13065 2.9761 5.11905 3.00026 5.10977 3.02527C5.0726 3.12549 5.0726 3.23949 5.0726 3.88767V4.63721H7.9136V4.84273C7.9136 5.02407 7.9136 5.04824 7.88942 5.04824H5.77378C3.69442 5.04824 3.67024 5.04824 3.56144 5.07242C3.01742 5.18123 2.60638 5.49555 2.34041 6.06375C2.11071 6.48688 2.02609 7.00672 2.00191 7.68372C1.97773 8.78385 2.18325 9.66637 2.60638 10.1862ZM6.75302 3.33156C6.80138 3.53708 6.6684 3.79095 6.46288 3.87558C6.40243 3.92394 6.37825 3.92394 6.25736 3.92394C6.16064 3.92394 6.12438 3.92394 6.07602 3.89976C5.82214 3.82722 5.68916 3.58543 5.73752 3.33156C5.78587 3.00515 6.1002 2.8359 6.39034 2.92052C6.57168 2.96888 6.70466 3.12604 6.75302 3.33156Z" + fill="#468DFF" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M13.3936 5.81379C13.1639 5.52364 12.8496 5.3423 12.5353 5.26977C12.3633 5.25 12.2617 5.25 11.8099 5.25H11.4862V6.6457C11.4862 7.58459 10.7251 8.3457 9.78621 8.3457H7.53765C6.56436 8.35382 6.53206 8.36285 6.3524 8.41312C6.339 8.41688 6.32477 8.42086 6.30927 8.42508C5.88614 8.54598 5.57182 8.78776 5.39048 9.12627C5.34212 9.22298 5.28168 9.40432 5.2575 9.5373C5.23332 9.62193 5.23332 9.74282 5.23332 11.1573V12.6926L5.2575 12.7772C5.30585 12.9223 5.39048 13.1037 5.46302 13.1883C5.49568 13.2046 5.53386 13.2485 5.5701 13.2902C5.58752 13.3102 5.60448 13.3298 5.62018 13.3454C6.11584 13.7928 7.02254 14.0224 8.27983 13.9983C8.40502 13.9983 8.53021 13.9893 8.62807 13.9823C8.69087 13.9778 8.74242 13.9741 8.7755 13.9741C9.91189 13.8895 10.601 13.5751 10.8549 13.0553C10.8694 13.0239 10.8809 12.9997 10.8902 12.9747C10.9274 12.8745 10.9274 12.7605 10.9274 12.1123V11.3628H8.0864V11.1573C8.0864 10.9759 8.0864 10.9518 8.11058 10.9518H10.2262C12.3056 10.9518 12.3298 10.9518 12.4386 10.9276C12.9826 10.8188 13.3936 10.5045 13.6596 9.93625C13.8893 9.51312 13.9739 8.99328 13.9981 8.31628C14.0223 7.21615 13.8167 6.33363 13.3936 5.81379ZM9.24698 12.6684C9.19862 12.4629 9.3316 12.209 9.53712 12.1244C9.59757 12.0761 9.62175 12.0761 9.74264 12.0761C9.83936 12.0761 9.87562 12.0761 9.92398 12.1002C10.1779 12.1728 10.3108 12.4146 10.2625 12.6684C10.2141 12.9949 9.8998 13.1641 9.60966 13.0795C9.42832 13.0311 9.29534 12.874 9.24698 12.6684Z" + fill="#468DFF" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/fileIcon/Yaml.tsx b/web_console_v2/client/src/components/IconPark/fileIcon/Yaml.tsx new file mode 100644 index 000000000..d42a8721d --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/fileIcon/Yaml.tsx @@ -0,0 +1,26 @@ +/** + * @file Yaml yaml + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'yaml', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M10.1821 2.51831H13.3465L5.6645 14.0183H2.50012L6.34112 8.26831L2.50012 2.51831H5.6645L7.92332 5.89975L10.1821 2.51831Z" + fill="#FFA841" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/ArrowFillDown.tsx b/web_console_v2/client/src/components/IconPark/icons/ArrowFillDown.tsx new file mode 100644 index 000000000..9082452f7 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/ArrowFillDown.tsx @@ -0,0 +1,26 @@ +/** + * @file Python python + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'arrow-fill-down', + false, + (props: ISvgIconProps) => ( + <svg width="8" height="5" viewBox="0 0 8 5" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M4.30622 4.24922C4.14638 4.4393 3.85377 4.4393 3.69393 4.24922L0.85292 0.870721C0.634116 0.610521 0.819096 0.213281 1.15907 0.213281L6.84108 0.213281C7.18105 0.213281 7.36603 0.610521 7.14723 0.870721L4.30622 4.24922Z" + fill="#86909C" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/ArrowFillRight.tsx b/web_console_v2/client/src/components/IconPark/icons/ArrowFillRight.tsx new file mode 100644 index 000000000..da7f3d9ca --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/ArrowFillRight.tsx @@ -0,0 +1,26 @@ +/** + * @file Python python + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'arrow-fill-right', + false, + (props: ISvgIconProps) => ( + <svg width="5" height="8" viewBox="0 0 5 8" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M4.5113 3.69366C4.70138 3.8535 4.70138 4.14611 4.5113 4.30595L1.13281 7.14696C0.872606 7.36576 0.475366 7.18078 0.475366 6.84081V1.1588C0.475366 0.818828 0.872606 0.633847 1.13281 0.852652L4.5113 3.69366Z" + fill="#86909C" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/ArrowUpFill.tsx b/web_console_v2/client/src/components/IconPark/icons/ArrowUpFill.tsx new file mode 100644 index 000000000..4193cc66a --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/ArrowUpFill.tsx @@ -0,0 +1,33 @@ +/** + * @file ArrowUpFill arrow-up-fill + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'arrow-up-fill', + false, + (props: ISvgIconProps) => ( + <svg + width={props.size || 16} + height={props.size || 16} + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <circle cx="7.99998" cy="8.0001" r="6.9" fill="currentColor" /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M8.26849 4.79241L11.1173 7.6412C11.2656 7.78954 11.2656 8.03006 11.1173 8.1784L10.5801 8.71561C10.4317 8.86396 10.1912 8.86396 10.0429 8.71561L8.66732 7.34017L8.6674 11.1659C8.6674 11.3757 8.49733 11.5458 8.28753 11.5458H7.52781C7.31801 11.5458 7.14794 11.3757 7.14794 11.1659L7.14756 7.52443L5.95692 8.71561C5.80857 8.86396 5.56805 8.86396 5.41971 8.71561L4.8825 8.1784C4.73415 8.03006 4.73415 7.78954 4.8825 7.6412L7.73128 4.79241C7.87963 4.64407 8.12015 4.64407 8.26849 4.79241Z" + fill="white" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/Audit.tsx b/web_console_v2/client/src/components/IconPark/icons/Audit.tsx new file mode 100644 index 000000000..0cff6ea5f --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/Audit.tsx @@ -0,0 +1,60 @@ +/** + * @file Audit audit + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'audit', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M11.8646 2.59896C11.8646 2.27813 11.6035 2.01562 11.2812 2.01562H3.11458C2.79229 2.01562 2.53125 2.27813 2.53125 2.59896V13.3906C2.53125 13.7115 2.79229 13.974 3.11458 13.974H6.61465V12.8073H3.69785V3.18233H10.6978V6.67706H11.8646V2.59896Z" + fill="#4E5969" + /> + <path + d="M7.19603 7.88301C7.17918 8.02807 7.0559 8.14066 6.90632 8.14066H5.15632L5.12231 8.1387C4.97725 8.12185 4.86465 7.99857 4.86465 7.84899V7.26566L4.86662 7.23165C4.88346 7.08659 5.00674 6.97399 5.15632 6.97399H6.90632L6.94034 6.97596C7.08539 6.9928 7.19799 7.11608 7.19799 7.26566V7.84899L7.19603 7.88301Z" + fill="#4E5969" + /> + <path + d="M9.2736 4.64259L9.23958 4.64062H5.15625C5.00667 4.64062 4.88339 4.75322 4.86655 4.89828L4.86458 4.93229V5.51562C4.86458 5.6652 4.97718 5.78848 5.12224 5.80533L5.15625 5.80729H9.23958C9.38916 5.80729 9.51244 5.6947 9.52929 5.54964L9.53125 5.51562V4.93229C9.53125 4.78271 9.41865 4.65944 9.2736 4.64259Z" + fill="#4E5969" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M9.09382 9.17188C9.09382 8.28592 9.81203 7.56771 10.698 7.56771C11.5839 7.56771 12.3022 8.28592 12.3022 9.17188C12.3022 9.85204 11.8789 10.4333 11.2813 10.6667V11.0677H12.7688C13.1554 11.0677 13.4688 11.3811 13.4688 11.7677V13.2844C13.4688 13.671 13.1554 13.9844 12.7688 13.9844H8.62715C8.24055 13.9844 7.92715 13.671 7.92715 13.2844V11.7677C7.92715 11.3811 8.24055 11.0677 8.62715 11.0677H10.1147V10.6667C9.51712 10.4333 9.09382 9.85204 9.09382 9.17188ZM10.698 8.73438C10.4564 8.73438 10.2605 8.93025 10.2605 9.17188C10.2605 9.4135 10.4564 9.60938 10.698 9.60938C10.9396 9.60938 11.1355 9.4135 11.1355 9.17188C11.1355 8.93025 10.9396 8.73438 10.698 8.73438ZM9.09382 12.8177V12.2344H12.3022V12.8177H9.09382Z" + fill="#4E5969" + /> + <path + d="M11.8646 2.59896C11.8646 2.27813 11.6035 2.01562 11.2812 2.01562H3.11458C2.79229 2.01562 2.53125 2.27813 2.53125 2.59896V13.3906C2.53125 13.7115 2.79229 13.974 3.11458 13.974H6.61465V12.8073H3.69785V3.18233H10.6978V6.67706H11.8646V2.59896Z" + stroke="#4E5969" + strokeWidth="0.2" + /> + <path + d="M7.19603 7.88301C7.17918 8.02807 7.0559 8.14066 6.90632 8.14066H5.15632L5.12231 8.1387C4.97725 8.12185 4.86465 7.99857 4.86465 7.84899V7.26566L4.86662 7.23165C4.88346 7.08659 5.00674 6.97399 5.15632 6.97399H6.90632L6.94034 6.97596C7.08539 6.9928 7.19799 7.11608 7.19799 7.26566V7.84899L7.19603 7.88301Z" + stroke="#4E5969" + strokeWidth="0.2" + /> + <path + d="M9.2736 4.64259L9.23958 4.64062H5.15625C5.00667 4.64062 4.88339 4.75322 4.86655 4.89828L4.86458 4.93229V5.51562C4.86458 5.6652 4.97718 5.78848 5.12224 5.80533L5.15625 5.80729H9.23958C9.38916 5.80729 9.51244 5.6947 9.52929 5.54964L9.53125 5.51562V4.93229C9.53125 4.78271 9.41865 4.65944 9.2736 4.64259Z" + stroke="#4E5969" + strokeWidth="0.2" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M9.09382 9.17188C9.09382 8.28592 9.81203 7.56771 10.698 7.56771C11.5839 7.56771 12.3022 8.28592 12.3022 9.17188C12.3022 9.85204 11.8789 10.4333 11.2813 10.6667V11.0677H12.7688C13.1554 11.0677 13.4688 11.3811 13.4688 11.7677V13.2844C13.4688 13.671 13.1554 13.9844 12.7688 13.9844H8.62715C8.24055 13.9844 7.92715 13.671 7.92715 13.2844V11.7677C7.92715 11.3811 8.24055 11.0677 8.62715 11.0677H10.1147V10.6667C9.51712 10.4333 9.09382 9.85204 9.09382 9.17188ZM10.698 8.73438C10.4564 8.73438 10.2605 8.93025 10.2605 9.17188C10.2605 9.4135 10.4564 9.60938 10.698 9.60938C10.9396 9.60938 11.1355 9.4135 11.1355 9.17188C11.1355 8.93025 10.9396 8.73438 10.698 8.73438ZM9.09382 12.8177V12.2344H12.3022V12.8177H9.09382Z" + stroke="#4E5969" + strokeWidth="0.2" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/CheckCircleFill.tsx b/web_console_v2/client/src/components/IconPark/icons/CheckCircleFill.tsx new file mode 100644 index 000000000..562cbee43 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/CheckCircleFill.tsx @@ -0,0 +1,38 @@ +/** + * @file CheckCircleFill check-circle-fill + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'check-circle-fill', + false, + (props: ISvgIconProps) => ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.00008 1.16675C10.2217 1.16675 12.8334 3.77842 12.8334 7.00008C12.8334 10.2217 10.2217 12.8334 7.00008 12.8334C3.77842 12.8334 1.16675 10.2217 1.16675 7.00008C1.16675 3.77842 3.77842 1.16675 7.00008 1.16675Z" + fill="#00B42A" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M9.14096 5.01191L9.47611 5.29313C9.66128 5.44835 9.68542 5.7243 9.53011 5.9094C9.53008 5.90943 9.53006 5.90946 9.52994 5.90941L6.60396 9.39454C6.44853 9.57948 6.17264 9.60358 5.9875 9.44839L5.31721 8.88595L8.52451 5.06575C8.67993 4.88081 8.95583 4.85671 9.14096 5.01191Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M4.19338 7.32429L4.48693 6.99827C4.64605 6.82154 4.91721 6.80429 5.09745 6.95942L7.15529 8.7306L6.59541 9.40428C6.44097 9.59011 6.16513 9.61556 5.97931 9.46112C5.97701 9.45922 5.97474 9.45729 5.97249 9.45534L4.23206 7.94772C4.04942 7.78951 4.02962 7.51321 4.18782 7.33058C4.18965 7.32847 4.19151 7.32637 4.19338 7.32429Z" + fill="white" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/CloseCircleFill.tsx b/web_console_v2/client/src/components/IconPark/icons/CloseCircleFill.tsx new file mode 100644 index 000000000..77432a50e --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/CloseCircleFill.tsx @@ -0,0 +1,30 @@ +/** + * @file CloseCircleFill close-circle-fill + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'close-circle-fill', + false, + (props: ISvgIconProps) => ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M7.00008 1.16675C10.2218 1.16675 12.8334 3.77842 12.8334 7.00008C12.8334 10.2218 10.2218 12.8334 7.00008 12.8334C3.77842 12.8334 1.16675 10.2218 1.16675 7.00008C1.16675 3.77842 3.77842 1.16675 7.00008 1.16675Z" + fill="#E63F3F" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M8.89476 4.68747L9.32786 5.12058C9.46455 5.25726 9.46455 5.47887 9.32786 5.61555L7.93563 7.00813L9.32786 8.40029C9.46455 8.53697 9.46455 8.75858 9.32786 8.89526L8.89476 9.32837C8.75808 9.46505 8.53647 9.46505 8.39979 9.32837L7.00696 7.93563L5.61555 9.32786C5.47887 9.46454 5.25726 9.46454 5.12058 9.32786L4.68747 8.89476C4.55079 8.75807 4.55079 8.53647 4.68747 8.39978L6.07829 7.00696L4.68747 5.61606C4.55079 5.47937 4.55079 5.25777 4.68747 5.12108L5.12058 4.68798C5.25726 4.5513 5.47887 4.5513 5.61555 4.68798L7.00696 6.07946L8.39979 4.68747C8.53647 4.55079 8.75808 4.55079 8.89476 4.68747Z" + fill="white" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/CompressedPackage.tsx b/web_console_v2/client/src/components/IconPark/icons/CompressedPackage.tsx new file mode 100644 index 000000000..47db914a8 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/CompressedPackage.tsx @@ -0,0 +1,26 @@ +/** + * @file CompressedPackage compressedPackage + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'compressedPackage', + false, + (props: ISvgIconProps) => ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M5.05801 1.4585C5.24687 1.4585 5.42597 1.54937 5.54687 1.70655L6.25363 2.62516H12.7037C13.0975 2.62516 13.4167 2.92115 13.4167 3.28627V11.8807C13.4167 12.2458 13.0975 12.5418 12.7037 12.5418H1.2963C0.90254 12.5418 0.583336 12.2458 0.583336 11.8807L0.583336 2.14789C0.583336 1.76715 0.868246 1.4585 1.2197 1.4585H5.05801ZM1.74984 3.79167V11.375L8.45817 11.375V10.5H9.04151V9.91667H8.74984C8.58876 9.91667 8.45817 9.78609 8.45817 9.625V9.04167C8.45817 8.88059 8.58876 8.75 8.74984 8.75H9.04151V8.16667H8.74984C8.58876 8.16667 8.45817 8.03609 8.45817 7.875V7.29167C8.45817 7.13059 8.58876 7 8.74984 7H9.04151V6.41667H8.74984C8.58876 6.41667 8.45817 6.28609 8.45817 6.125V5.54167C8.45817 5.38059 8.58876 5.25 8.74984 5.25H9.04151V4.66667H8.74984C8.58876 4.66667 8.45817 4.53609 8.45817 4.375V3.79167H1.74984ZM9.62463 4.66667H9.91629C10.0774 4.66667 10.208 4.79725 10.208 4.95834V5.54167C10.208 5.70275 10.0774 5.83334 9.91629 5.83334H9.62463V6.41667H9.91629C10.0774 6.41667 10.208 6.54725 10.208 6.70834V7.29167C10.208 7.45275 10.0774 7.58334 9.91629 7.58334H9.62463V8.16667H9.91629C10.0774 8.16667 10.208 8.29725 10.208 8.45834V9.04167C10.208 9.20275 10.0774 9.33334 9.91629 9.33334H9.62463V9.91667H9.91629C10.0774 9.91667 10.208 10.0473 10.208 10.2083V10.7917C10.208 10.9528 10.0774 11.0833 9.91629 11.0833H9.62256V11.375L12.2496 11.375V3.79167H9.62463V4.66667Z" + fill="#4E5969" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/EditNoUnderline.tsx b/web_console_v2/client/src/components/IconPark/icons/EditNoUnderline.tsx new file mode 100644 index 000000000..537bbcfa2 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/EditNoUnderline.tsx @@ -0,0 +1,32 @@ +/** + * @file EditNoUnderline edit-no-underline + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'edit-no-underline', + false, + (props: ISvgIconProps) => ( + <svg + width={props.size || 14} + height={props.size || 14} + viewBox="0 0 14 14" + fill="currentColor" + xmlns="http://www.w3.org/2000/svg" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M6.9923 4.14848L9.63547 6.79166L4.62709 11.8H2.54572C2.25802 11.8 2.02233 11.5774 2.0015 11.295L2 11.2543V9.17291L6.9923 4.14848ZM9.69573 2.12796L9.73145 2.16085L11.6055 4.0448C11.8177 4.25808 11.8173 4.60283 11.6045 4.81555L11.4028 5.01699L11.4064 5.0207L10.4071 6.01974L7.76094 3.37384L8.57897 2.54981L8.57381 2.5447L8.95868 2.15984C9.16058 1.95794 9.48132 1.94731 9.69573 2.12796Z" + fill="currentColor" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/ExclamationCircleFill.tsx b/web_console_v2/client/src/components/IconPark/icons/ExclamationCircleFill.tsx new file mode 100644 index 000000000..5e20a9bac --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/ExclamationCircleFill.tsx @@ -0,0 +1,30 @@ +/** + * @file ExclamationCircleFill exclamation-circle-fill + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'exclamation-circle-fill', + false, + (props: ISvgIconProps) => ( + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M10 18.75C14.8325 18.75 18.75 14.8325 18.75 10C18.75 5.16751 14.8325 1.25 10 1.25C5.16751 1.25 1.25 5.16751 1.25 10C1.25 14.8325 5.16751 18.75 10 18.75Z" + fill="currentColor" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M10.6295 12.3578C10.898 12.3578 11.1156 12.5754 11.1156 12.8439V14.1078C11.1156 14.3763 10.898 14.5939 10.6295 14.5939H9.36563C9.09716 14.5939 8.87952 14.3763 8.87952 14.1078V12.8439C8.87952 12.5754 9.09716 12.3578 9.36563 12.3578H10.6295ZM10.6295 5.21826C10.898 5.21826 11.1156 5.4359 11.1156 5.70437V10.8184C11.1156 11.0869 10.898 11.3046 10.6295 11.3046H9.36563C9.09716 11.3046 8.87952 11.0869 8.87952 10.8184V5.70437C8.87952 5.4359 9.09716 5.21826 9.36563 5.21826H10.6295Z" + fill="white" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/FileAddFill.tsx b/web_console_v2/client/src/components/IconPark/icons/FileAddFill.tsx new file mode 100644 index 000000000..9a6019b01 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/FileAddFill.tsx @@ -0,0 +1,38 @@ +/** + * @file FileAddFill file-add-fill + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'file-add-fill', + false, + (props: ISvgIconProps) => ( + <svg + width={props.size || 16} + height={props.size || 16} + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M9.86089 1.875C10.1708 1.875 10.4681 1.99834 10.6869 2.21781L12.9094 4.4462C13.1275 4.6649 13.25 4.96117 13.25 5.27005V13.5417C13.25 13.8638 12.9888 14.125 12.6667 14.125H3.33333C3.01117 14.125 2.75 13.8638 2.75 13.5417V2.45833C2.75 2.13617 3.01117 1.875 3.33333 1.875H9.86089Z" + fill="currentColor" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.87433 5.91699C7.59818 5.91699 7.37433 6.14085 7.37433 6.41699V7.794H5.99731C5.72117 7.794 5.49731 8.01786 5.49731 8.294V8.54534C5.49731 8.82149 5.72117 9.04534 5.99732 9.04534H7.37433V10.4224C7.37433 10.6985 7.59818 10.9224 7.87433 10.9224H8.12567C8.40181 10.9224 8.62567 10.6985 8.62567 10.4224V9.04534H10.0027C10.2788 9.04534 10.5027 8.82149 10.5027 8.54534V8.294C10.5027 8.01786 10.2788 7.794 10.0027 7.794H8.62567V6.41699C8.62567 6.14085 8.40181 5.91699 8.12567 5.91699H7.87433Z" + fill="white" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/FolderAddFill.tsx b/web_console_v2/client/src/components/IconPark/icons/FolderAddFill.tsx new file mode 100644 index 000000000..5cb926520 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/FolderAddFill.tsx @@ -0,0 +1,38 @@ +/** + * @file FolderAddFill folder-add-fill + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'folder-add-fill', + false, + (props: ISvgIconProps) => ( + <svg + width={props.size || 16} + height={props.size || 16} + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M6.05792 2.57031C6.24678 2.57031 6.42589 2.66119 6.54679 2.81837L7.25354 3.73698H13.8055C14.143 3.73698 14.4166 3.99815 14.4166 4.32031V12.847C14.4166 13.1692 14.143 13.4304 13.8055 13.4304H2.19436C1.85686 13.4304 1.58325 13.1692 1.58325 12.847V3.25971C1.58325 2.87896 1.86816 2.57031 2.21962 2.57031H6.05792Z" + fill="currentColor" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.87433 5.91699C7.59818 5.91699 7.37433 6.14085 7.37433 6.41699V7.794H5.99731C5.72117 7.794 5.49731 8.01786 5.49731 8.294V8.54534C5.49731 8.82149 5.72117 9.04534 5.99732 9.04534H7.37433V10.4224C7.37433 10.6985 7.59818 10.9224 7.87433 10.9224H8.12567C8.40181 10.9224 8.62567 10.6985 8.62567 10.4224V9.04534H10.0027C10.2788 9.04534 10.5027 8.82149 10.5027 8.54534V8.294C10.5027 8.01786 10.2788 7.794 10.0027 7.794H8.62567V6.41699C8.62567 6.14085 8.40181 5.91699 8.12567 5.91699H7.87433Z" + fill="white" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/InfoCircleFill.tsx b/web_console_v2/client/src/components/IconPark/icons/InfoCircleFill.tsx new file mode 100644 index 000000000..f6b9ffe17 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/InfoCircleFill.tsx @@ -0,0 +1,37 @@ +/** + * @file InfoCircleFill info-circle-fill + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'info-circle-fill', + false, + (props: ISvgIconProps) => ( + <svg + viewBox="0 0 12 12" + width={props.size || 12} + height={props.size || 12} + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M6.00008 0.166687C9.22177 0.166687 11.8334 2.77836 11.8334 6.00002C11.8334 9.22171 9.22177 11.8334 6.00008 11.8334C2.77842 11.8334 0.166748 9.22171 0.166748 6.00002C0.166748 2.77836 2.77842 0.166687 6.00008 0.166687Z" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M6.45214 4.92151C6.55953 4.92151 6.64659 5.00856 6.64659 5.11595L6.64623 7.86148L7.22343 7.8619C7.3437 7.8619 7.4412 7.95941 7.4412 8.07968V8.77654C7.4412 8.89681 7.3437 8.99431 7.22343 8.99431H4.82105C4.70077 8.99431 4.60327 8.89681 4.60327 8.77654V8.07968C4.60327 7.95941 4.70077 7.8619 4.82105 7.8619L5.40179 7.86148V6.06741L5.29023 6.06831C5.16995 6.06831 5.07245 5.9708 5.07245 5.85053V5.14226C5.07245 5.02199 5.16995 4.92449 5.29023 4.92449L5.5831 4.92199C5.58767 4.92167 5.59229 4.92151 5.59694 4.92151H6.45214ZM6.33817 3.01334C6.45845 3.01334 6.55595 3.11084 6.55595 3.23111V4.04C6.55595 4.16028 6.45845 4.25778 6.33817 4.25778H5.52929C5.40901 4.25778 5.31151 4.16028 5.31151 4.04V3.23111C5.31151 3.11084 5.40901 3.01334 5.52929 3.01334H6.33817Z" + fill="white" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/Loading.tsx b/web_console_v2/client/src/components/IconPark/icons/Loading.tsx new file mode 100644 index 000000000..14aa6d0ff --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/Loading.tsx @@ -0,0 +1,40 @@ +/** + * @file Loading loading + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'loading', + false, + (props: ISvgIconProps) => ( + <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M5.99886 0.166748C9.22052 0.166748 11.8322 2.77842 11.8322 6.00008C11.8322 9.22174 9.22052 11.8334 5.99886 11.8334C2.7772 11.8334 0.165527 9.22174 0.165527 6.00008C0.165527 2.77842 2.7772 0.166748 5.99886 0.166748ZM5.99886 2.11119C3.85109 2.11119 2.10997 3.85231 2.10997 6.00008C2.10997 8.14786 3.85109 9.88897 5.99886 9.88897C8.14663 9.88897 9.88775 8.14786 9.88775 6.00008C9.88775 3.85231 8.14663 2.11119 5.99886 2.11119Z" + fill="url(#paint0_angular)" + /> + <defs> + <radialGradient + id="paint0_angular" + cx="0" + cy="0" + r="1" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(5.99886 6.00008) rotate(-23.9625) scale(5.74517 5.76738)" + > + <stop offset="0.921737" stop-color="#165DFF" stop-opacity="0" /> + <stop offset="0.944115" stop-color="#165DFF" /> + <stop offset="0.989583" stop-color="#165DFF" /> + </radialGradient> + </defs> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/ModelCenter.tsx b/web_console_v2/client/src/components/IconPark/icons/ModelCenter.tsx new file mode 100644 index 000000000..72a7c3d47 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/ModelCenter.tsx @@ -0,0 +1,31 @@ +/** + * @file ModelCenter model-center + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'model-center', + false, + (props: ISvgIconProps) => ( + <svg + width={props.size || 16} + height={props.size || 15} + viewBox="0 0 16 15" + fill="currentColor" + xmlns="http://www.w3.org/2000/svg" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M8.34318 0.129989C8.12927 0.0133111 7.87074 0.0133111 7.65683 0.129989L1.17922 3.66323C1.1146 3.69847 1.0615 3.75158 1.02625 3.8162C0.924876 4.00205 0.993361 4.2349 1.17922 4.33628L7.65683 7.86952C7.87074 7.9862 8.12927 7.9862 8.34318 7.86952L14.8208 4.33628C15.0066 4.2349 15.0751 4.00205 14.9738 3.8162C14.9385 3.75158 14.8854 3.69847 14.8208 3.66323L8.34318 0.129989ZM8 6.42402L3.55552 3.99976L8 1.57549L12.4445 3.99976L8 6.42402ZM1.42008 6.31449C1.59064 6.25036 1.7918 6.25575 2.00046 6.36556L8 9.52322L13.9996 6.36556C14.2082 6.25575 14.4094 6.25036 14.5799 6.31449C14.7493 6.3782 14.8849 6.50906 14.9675 6.66597C15.0501 6.82288 15.0812 7.00875 15.0378 7.18446C14.9941 7.36137 14.8758 7.52413 14.6671 7.63395L8.42027 10.9218C8.30388 11.0104 8.15978 11.064 8 11.0595C7.84023 11.064 7.69613 11.0104 7.57974 10.9218L1.33289 7.63395C1.12423 7.52413 1.0059 7.36137 0.962204 7.18446C0.9188 7.00875 0.949893 6.82288 1.03248 6.66597C1.11507 6.50906 1.25067 6.3782 1.42008 6.31449ZM1.42008 9.31449C1.59064 9.25036 1.7918 9.25575 2.00046 9.36556L8 12.5232L13.9996 9.36556C14.2082 9.25575 14.4094 9.25036 14.5799 9.31449C14.7493 9.3782 14.8849 9.50906 14.9675 9.66597C15.0501 9.82288 15.0812 10.0087 15.0378 10.1845C14.9941 10.3614 14.8758 10.5241 14.6671 10.6339L8.42027 13.9218C8.30388 14.0104 8.15978 14.064 8 14.0595C7.84023 14.064 7.69613 14.0104 7.57974 13.9218L1.33289 10.6339C1.12423 10.5241 1.0059 10.3614 0.962204 10.1845C0.9188 10.0087 0.949893 9.82288 1.03248 9.66597C1.11507 9.50906 1.25067 9.3782 1.42008 9.31449Z" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/NormalFile.tsx b/web_console_v2/client/src/components/IconPark/icons/NormalFile.tsx new file mode 100644 index 000000000..2134dd727 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/NormalFile.tsx @@ -0,0 +1,26 @@ +/** + * @file NormalFile normal-file + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'normal-file', + false, + (props: ISvgIconProps) => ( + <svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.86089 0.875C8.17084 0.875 8.46806 0.998344 8.68694 1.21781L10.9094 3.4462C11.1275 3.6649 11.25 3.96117 11.25 4.27005V12.5417C11.25 12.8638 10.9888 13.125 10.6667 13.125H1.33333C1.01117 13.125 0.75 12.8638 0.75 12.5417V1.45833C0.75 1.13617 1.01117 0.875 1.33333 0.875H7.86089ZM7.39952 2.22286H2.0817V11.756H9.91667V4.74572C9.91667 4.59446 9.85669 4.44937 9.74989 4.34225L7.80418 2.39082C7.69696 2.2833 7.55137 2.22286 7.39952 2.22286ZM6.29167 8.16667C6.45275 8.16667 6.58333 8.29725 6.58333 8.45833V9.17456C6.58333 9.33565 6.45275 9.46623 6.29167 9.46623H3.66667C3.50558 9.46623 3.375 9.33565 3.375 9.17456V8.45833C3.375 8.29725 3.50558 8.16667 3.66667 8.16667H6.29167ZM8.33333 5.09561C8.49442 5.09561 8.625 5.22619 8.625 5.38727V6.125C8.625 6.28608 8.49442 6.41667 8.33333 6.41667H3.66667C3.50558 6.41667 3.375 6.28608 3.375 6.125V5.38727C3.375 5.22619 3.50558 5.09561 3.66667 5.09561H8.33333Z" + fill="#42A5F5" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/PlusBold.tsx b/web_console_v2/client/src/components/IconPark/icons/PlusBold.tsx new file mode 100644 index 000000000..7a60f477f --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/PlusBold.tsx @@ -0,0 +1,26 @@ +/** + * @file Plus plus + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'plus', + false, + (props: ISvgIconProps) => ( + <svg fill="none" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg"> + <path + clipRule="evenodd" + d="m5.5 1.99951c-.27614 0-.5.22386-.5.5v2.5h-2.5c-.27614 0-.5.22386-.5.5v1c0 .27614.22386.5.5.5h2.5v2.5c0 .27614.22386.5.5.5h1c.27614 0 .5-.22386.5-.5v-2.5h2.5c.27614 0 .5-.22386.5-.5v-1c0-.27614-.22386-.5-.5-.5h-2.5v-2.5c0-.27614-.22386-.5-.5-.5z" + fill="currentColor" + fillRule="evenodd" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/Python.tsx b/web_console_v2/client/src/components/IconPark/icons/Python.tsx new file mode 100644 index 000000000..b43f43d04 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/Python.tsx @@ -0,0 +1,28 @@ +/** + * @file Python python + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'python', + false, + (props: ISvgIconProps) => ( + <svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M5.74663 12.9995C4.29495 12.9499 3.3892 12.5156 3.15346 11.7464L3.12865 11.6595V10.1706C3.12865 8.45839 3.12865 8.43357 3.2155 8.16061C3.3768 7.67671 3.77384 7.35412 4.3694 7.21764L4.50588 7.19282H6.1933C7.39682 7.19282 7.90553 7.19282 7.95516 7.16801C8.30257 7.09356 8.5135 7.03153 8.72442 6.87023C8.98498 6.68412 9.1835 6.36152 9.25795 6.01411C9.33239 5.75355 9.33239 5.77837 9.33239 4.88503V4.05373H10.5359L10.6476 4.07854C11.268 4.26466 11.7394 4.84781 11.9256 5.74115C12 6.08856 12 6.11337 12 6.9943C12 7.85042 12 7.85042 11.9256 8.1482C11.8511 8.42116 11.7891 8.65691 11.665 8.89265C11.4293 9.35172 11.0818 9.67432 10.6476 9.82321C10.387 9.91006 10.598 9.89765 8.16609 9.91006H5.96996V10.3319H8.88572V11.8332C8.86091 11.9201 8.83609 12.0441 8.77405 12.1558C8.69961 12.2675 8.5135 12.4536 8.40183 12.5529C8.00479 12.8134 7.40923 12.9747 6.61515 13.0119H5.74663V12.9995ZM7.75664 12.069C8.0296 12.0193 8.24053 11.7464 8.1909 11.4734C8.14127 11.2377 7.97997 11.0764 7.75664 11.0391C7.40923 10.9895 7.11145 11.2997 7.16108 11.6347C7.21071 11.8953 7.42164 12.0566 7.66979 12.069H7.75664ZM1.50327 9.94728C1.18067 9.87284 0.858077 9.68673 0.622335 9.38895C0.188073 8.85543 -0.0228541 7.94968 0.00196089 6.8206C0.0267759 6.12578 0.113628 5.59226 0.349371 5.158C0.622335 4.57484 1.04419 4.25225 1.60253 4.14058C1.71419 4.11577 1.73901 4.11577 3.8731 4.11577H6.04441C6.06922 4.11577 6.06922 4.09095 6.06922 3.90484V3.69391H3.15346V2.92465C3.15346 2.09335 3.15346 2.11816 3.22791 1.95686C3.48846 1.42334 4.19569 1.10075 5.36199 1.0139C5.44885 1.0139 5.65977 0.989081 5.8707 0.989081C7.16108 0.964266 8.09164 1.20001 8.60035 1.65909C8.64998 1.70872 8.71202 1.79557 8.76165 1.82038C8.83609 1.90723 8.92294 2.09335 8.97257 2.24224L8.99739 2.32909V3.90484C8.99739 5.35651 8.99739 5.48059 8.97257 5.56744C8.94776 5.70392 8.88572 5.89004 8.83609 5.9893C8.64998 6.33671 8.32738 6.58486 7.89312 6.70893C7.62016 6.78338 7.73182 6.78338 5.88311 6.79578C4.03439 6.79578 4.14606 6.79578 3.89791 6.87023C3.3892 7.00671 3.01698 7.40375 2.8805 7.96209C2.80605 8.22264 2.80605 8.19783 2.80605 9.09117V9.94728H2.24772C1.71419 9.9721 1.5529 9.9721 1.50327 9.94728ZM4.58032 2.91224C4.79125 2.82539 4.92773 2.56483 4.8781 2.3539C4.82847 2.14298 4.69199 1.98168 4.50588 1.93205C4.2081 1.8452 3.8855 2.0189 3.83587 2.3539C3.78624 2.61446 3.92273 2.86261 4.18328 2.93706C4.23291 2.96187 4.27014 2.96187 4.3694 2.96187C4.49347 2.96187 4.51829 2.96187 4.58032 2.91224Z" + fill="#FED142" + /> + <path + d="M1.50327 9.94729C1.18067 9.87284 0.858077 9.68673 0.622335 9.38895C0.188073 8.85543 -0.0228541 7.94968 0.00196089 6.8206C0.0267759 6.12578 0.113628 5.59226 0.34937 5.158C0.622335 4.57484 1.04419 4.25225 1.60253 4.14058C1.71419 4.11577 1.73901 4.11577 3.8731 4.11577H6.04441C6.06922 4.11577 6.06922 4.09095 6.06922 3.90484V3.69391H3.15346V2.92465C3.15346 2.09335 3.15346 2.11816 3.22791 1.95687C3.48846 1.42334 4.19569 1.10075 5.36199 1.0139C5.44885 1.0139 5.65977 0.989081 5.8707 0.989081C7.16108 0.964266 8.09164 1.20001 8.60035 1.65909C8.64998 1.70872 8.71202 1.79557 8.76164 1.82038C8.83609 1.90724 8.92294 2.09335 8.97257 2.24224L8.99739 2.32909V3.90484C8.99739 5.35652 8.99739 5.48059 8.97257 5.56744C8.94776 5.70393 8.88572 5.89004 8.83609 5.9893C8.64998 6.33671 8.32738 6.58486 7.89312 6.70893C7.62016 6.78338 7.73182 6.78338 5.88311 6.79578C4.03439 6.79578 4.14606 6.79578 3.89791 6.87023C3.3892 7.00671 3.01698 7.40375 2.8805 7.96209C2.80605 8.22265 2.80605 8.19783 2.80605 9.09117V9.94729H2.24772C1.71419 9.9721 1.5529 9.9721 1.50327 9.94729ZM4.58032 2.91224C4.79125 2.82539 4.92773 2.56483 4.8781 2.3539C4.82847 2.14298 4.69199 1.98168 4.50588 1.93205C4.2081 1.8452 3.8855 2.0189 3.83587 2.3539C3.78624 2.61446 3.92273 2.86261 4.18328 2.93706C4.23291 2.96187 4.27014 2.96187 4.3694 2.96187C4.49347 2.96187 4.51829 2.96187 4.58032 2.91224Z" + fill="#3571A3" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/RightAngle.tsx b/web_console_v2/client/src/components/IconPark/icons/RightAngle.tsx new file mode 100644 index 000000000..d708f446e --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/RightAngle.tsx @@ -0,0 +1,36 @@ +/** + * @file RightAngle right-angle + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'right-angle', + false, + (props: ISvgIconProps) => ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M7.00008 12.8334C10.2217 12.8334 12.8334 10.2217 12.8334 7.00008C12.8334 3.77842 10.2217 1.16675 7.00008 1.16675C3.77842 1.16675 1.16675 3.77842 1.16675 7.00008C1.16675 10.2217 3.77842 12.8334 7.00008 12.8334Z" + fill="#FA9600" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.41813 3.81226C7.59711 3.81226 7.74221 3.95735 7.74221 4.13633V7.54571C7.74221 7.72469 7.59711 7.86979 7.41813 7.86979H6.57554C6.39656 7.86979 6.25146 7.72469 6.25146 7.54571V4.13633C6.25146 3.95735 6.39656 3.81226 6.57554 3.81226H7.41813Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M6.25146 6.70321C6.25146 6.52422 6.39656 6.37913 6.57554 6.37913H9.98492C10.1639 6.37913 10.309 6.52422 10.309 6.70321V7.5458C10.309 7.72478 10.1639 7.86987 9.98492 7.86987H6.57554C6.39656 7.86987 6.25146 7.72478 6.25146 7.5458V6.70321Z" + fill="white" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/Rocket.tsx b/web_console_v2/client/src/components/IconPark/icons/Rocket.tsx new file mode 100644 index 000000000..c6779812d --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/Rocket.tsx @@ -0,0 +1,48 @@ +/** + * @file Rocket rocket + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'rocket', + false, + (props: ISvgIconProps) => ( + <svg + width={props.size || 14} + height={props.size || 14} + viewBox="0 0 14 12" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M9.67204 4.95249C10.0076 4.95249 10.2797 4.68042 10.2797 4.34481C10.2797 4.0092 10.0076 3.73713 9.67204 3.73713C9.33642 3.73713 9.06436 4.0092 9.06436 4.34481C9.06436 4.68042 9.33642 4.95249 9.67204 4.95249Z" + fill="#165DFF" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M10.3546 1.48755L12.5277 1.48755V3.66058C12.5277 4.81123 12.0706 5.91475 11.2569 6.72839L9.79466 8.19068L9.82447 10.7994L6.24477 12.5127L5.94033 10.8687C5.45125 11.2109 4.89447 11.4502 4.3038 11.5683L1.98271 12.0325L2.44693 9.71142C2.56704 9.11086 2.81234 8.54533 3.16381 8.05037L1.49438 7.78752L3.21586 4.19075L5.8245 4.22061L7.28684 2.75828C8.10047 1.94464 9.204 1.48755 10.3546 1.48755ZM10.5351 6.00655L8.76916 7.77284L8.79626 10.1598L7.00592 11.0166L6.80011 9.90527C6.67381 9.22326 6.34364 8.5956 5.85319 8.10515C5.34044 7.5924 4.67833 7.25542 3.96202 7.14263L3.00675 6.99223L3.85546 5.21897L6.24215 5.24629L6.24216 5.24584L6.24255 5.24624L8.00868 3.48012C8.63087 2.85793 9.47474 2.50838 10.3546 2.50838L11.5068 2.50838V3.66058C11.5068 4.54049 11.1573 5.38436 10.5351 6.00655ZM5.13135 8.82699C5.39191 9.08754 5.58805 9.40323 5.70652 9.74888C5.25845 10.1636 4.70391 10.4472 4.1036 10.5673L3.28402 10.7312L3.44794 9.91162C3.57042 9.29918 3.86316 8.73438 4.29165 8.28167C4.60494 8.40194 4.89143 8.58706 5.13135 8.82699Z" + fill="#165DFF" + /> + <path + d="M9.67204 4.95249C10.0076 4.95249 10.2797 4.68042 10.2797 4.34481C10.2797 4.0092 10.0076 3.73713 9.67204 3.73713C9.33642 3.73713 9.06436 4.0092 9.06436 4.34481C9.06436 4.68042 9.33642 4.95249 9.67204 4.95249Z" + stroke="#165DFF" + strokeWidth="0.3" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M10.3546 1.48755L12.5277 1.48755V3.66058C12.5277 4.81123 12.0706 5.91475 11.2569 6.72839L9.79466 8.19068L9.82447 10.7994L6.24477 12.5127L5.94033 10.8687C5.45125 11.2109 4.89447 11.4502 4.3038 11.5683L1.98271 12.0325L2.44693 9.71142C2.56704 9.11086 2.81234 8.54533 3.16381 8.05037L1.49438 7.78752L3.21586 4.19075L5.8245 4.22061L7.28684 2.75828C8.10047 1.94464 9.204 1.48755 10.3546 1.48755ZM10.5351 6.00655L8.76916 7.77284L8.79626 10.1598L7.00592 11.0166L6.80011 9.90527C6.67381 9.22326 6.34364 8.5956 5.85319 8.10515C5.34044 7.5924 4.67833 7.25542 3.96202 7.14263L3.00675 6.99223L3.85546 5.21897L6.24215 5.24629L6.24216 5.24584L6.24255 5.24624L8.00868 3.48012C8.63087 2.85793 9.47474 2.50838 10.3546 2.50838L11.5068 2.50838V3.66058C11.5068 4.54049 11.1573 5.38436 10.5351 6.00655ZM5.13135 8.82699C5.39191 9.08754 5.58805 9.40323 5.70652 9.74888C5.25845 10.1636 4.70391 10.4472 4.1036 10.5673L3.28402 10.7312L3.44794 9.91162C3.57042 9.29918 3.86316 8.73438 4.29165 8.28167C4.60494 8.40194 4.89143 8.58706 5.13135 8.82699Z" + stroke="#165DFF" + strokeWidth="0.3" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/SortArrow.tsx b/web_console_v2/client/src/components/IconPark/icons/SortArrow.tsx new file mode 100644 index 000000000..4230067b9 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/SortArrow.tsx @@ -0,0 +1,28 @@ +/** + * @file SortArrow sort-arrow + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'sort-arrow', + false, + (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M5.78475 9.3417C5.67168 9.20601 5.76817 9 5.9448 9H10.0552C10.2318 9 10.3283 9.20601 10.2152 9.34171L8.38411 11.5391C8.18421 11.7789 7.81579 11.7789 7.61589 11.5391L5.78475 9.3417Z" + fill="#86909C" + /> + <path + d="M5.78475 6.6583C5.67168 6.79399 5.76817 7 5.9448 7H10.0552C10.2318 7 10.3283 6.79399 10.2152 6.65829L8.38411 4.46093C8.18421 4.22106 7.81579 4.22106 7.61589 4.46093L5.78475 6.6583Z" + fill="#86909C" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/StarFull.tsx b/web_console_v2/client/src/components/IconPark/icons/StarFull.tsx new file mode 100644 index 000000000..63e6f3ba2 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/StarFull.tsx @@ -0,0 +1,25 @@ +/** + * @file StarFull star-full + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'star-full', + false, + (props: ISvgIconProps) => ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.2408 10.8C7.08861 10.7268 6.91139 10.7268 6.7592 10.8L3.78203 12.2319C3.39024 12.4204 2.94359 12.1061 2.98868 11.6736L3.32683 8.43064C3.34313 8.27436 3.29248 8.11848 3.18743 8.00162L0.984664 5.55114C0.699033 5.23339 0.864024 4.7256 1.28187 4.63642L4.50431 3.9487C4.65798 3.9159 4.79057 3.81957 4.86925 3.68355L6.5191 0.831355C6.73304 0.461513 7.26696 0.461513 7.4809 0.831355L9.13075 3.68355C9.20942 3.81957 9.34202 3.9159 9.49569 3.9487L12.7181 4.63642C13.136 4.7256 13.301 5.23339 13.0153 5.55114L10.8126 8.00162C10.7075 8.11848 10.6569 8.27436 10.6732 8.43064L11.0113 11.6736C11.0564 12.1061 10.6098 12.4204 10.218 12.2319L7.2408 10.8Z" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/TeamOutlined.tsx b/web_console_v2/client/src/components/IconPark/icons/TeamOutlined.tsx new file mode 100644 index 000000000..6d13a0750 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/TeamOutlined.tsx @@ -0,0 +1,29 @@ +/** + * @file UserGroup user-group + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'team-out-lined', + false, + (props: ISvgIconProps) => ( + <svg + viewBox="64 64 896 896" + focusable="false" + data-icon="team" + width="1em" + height="1em" + fill="currentColor" + aria-hidden="true" + > + <path d="M824.2 699.9a301.55 301.55 0 00-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5a300.95 300.95 0 00-86.4 60.4C345 754.6 314 826.8 312 903.8a8 8 0 008 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5A226.62 226.62 0 01612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c.1 4.3 3.7 7.7 8 7.7h56a8 8 0 008-8.2c-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5a126.86 126.86 0 01-37.5-91.8c.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5A127.3 127.3 0 01612 612zM361.5 510.4c-.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1a127.54 127.54 0 01-38.7-95.4c.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204a8 8 0 008 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/Todo.tsx b/web_console_v2/client/src/components/IconPark/icons/Todo.tsx new file mode 100644 index 000000000..41f18f19b --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/Todo.tsx @@ -0,0 +1,31 @@ +/** + * @file Todo todo + * @author Auto Generated by IconPark + */ + +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper( + 'todo', + false, + (props: ISvgIconProps) => ( + <svg + width={props.size || 16} + height={props.size || 16} + viewBox="0 0 16 16" + fill="currentColor" + xmlns="http://www.w3.org/2000/svg" + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M6.33301 1.93354C6.33301 1.74944 6.18377 1.60021 5.99967 1.60021H5.16247C4.97837 1.60021 4.82913 1.74944 4.82913 1.93354L4.8288 2.60021H2.99967C2.63148 2.60021 2.33301 2.91526 2.33301 3.30391V12.5632C2.33301 12.9518 2.63148 13.2669 2.99967 13.2669H12.9997C13.3679 13.2669 13.6663 12.9518 13.6663 12.5632V3.30391C13.6663 2.91526 13.3679 2.60021 12.9997 2.60021L11.1771 2.6001V1.93343C11.1771 1.74934 11.0279 1.6001 10.8438 1.6001L9.99967 1.60021C9.81558 1.60021 9.66634 1.74944 9.66634 1.93354V2.60021H6.33301V1.93354ZM9.88209 5.85927C10.0121 5.7293 10.2228 5.7293 10.3528 5.85927L10.8234 6.32994C10.9534 6.45991 10.9534 6.67064 10.8234 6.80061L8.00164 9.62238L7.99942 9.62462L7.52875 10.0953C7.47877 10.1453 7.41683 10.176 7.35214 10.1876L7.31307 10.1922H7.27377C7.19526 10.1876 7.11807 10.1553 7.05809 10.0953L5.17542 8.21261C5.04544 8.08264 5.04544 7.87192 5.17542 7.74195L5.64608 7.27128C5.77605 7.14131 5.98678 7.14131 6.11675 7.27128L7.29347 8.44793L9.88209 5.85927Z" + /> + </svg> + ), + (props: ISvgIconProps) => ` +`, +); diff --git a/web_console_v2/client/src/components/IconPark/icons/UnStruct.tsx b/web_console_v2/client/src/components/IconPark/icons/UnStruct.tsx new file mode 100644 index 000000000..061c20cf3 --- /dev/null +++ b/web_console_v2/client/src/components/IconPark/icons/UnStruct.tsx @@ -0,0 +1,22 @@ +/* tslint:disable: max-line-length */ +/* eslint-disable max-len */ +import React from 'react'; +import { ISvgIconProps, IconWrapper } from '../runtime'; + +export default IconWrapper('ad-product', false, (props: ISvgIconProps) => ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clipPath="url(#clip0_12570_67994)"> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.33333 2.00001C7.33333 1.63182 7.03486 1.33334 6.66667 1.33334H1.66667C1.29848 1.33334 1 1.63182 1 2.00001V14C1 14.3682 1.29848 14.6667 1.66667 14.6667H6.66667C7.03486 14.6667 7.33333 14.3682 7.33333 14V2.00001ZM15 9.00001C15 8.63182 14.7015 8.33334 14.3333 8.33334H9C8.63181 8.33334 8.33333 8.63182 8.33333 9.00001V14C8.33333 14.3682 8.63181 14.6667 9 14.6667H14.3333C14.7015 14.6667 15 14.3682 15 14V9.00001ZM6 2.66668H2.33333V13.3333H6V2.66668ZM9.66667 9.66668H13.6667V13.3333H9.66667V9.66668ZM14.3333 1.33334C14.7015 1.33334 15 1.63182 15 2.00001V7.00001C15 7.3682 14.7015 7.66668 14.3333 7.66668H9C8.63181 7.66668 8.33333 7.3682 8.33333 7.00001V2.00001C8.33333 1.63182 8.63181 1.33334 9 1.33334H14.3333ZM9.66667 2.66668H13.6667V6.33334H9.66667V2.66668Z" + fill="#4E5969" + /> + </g> + <defs> + <clipPath id="clip0_12570_67994"> + <rect width="16" height="16" fill="white" /> + </clipPath> + </defs> + </svg> +)); diff --git a/web_console_v2/client/src/components/InfoItem/index.tsx b/web_console_v2/client/src/components/InfoItem/index.tsx new file mode 100644 index 000000000..791f712ca --- /dev/null +++ b/web_console_v2/client/src/components/InfoItem/index.tsx @@ -0,0 +1,108 @@ +/* istanbul ignore file */ + +import React, { FC, useState, useEffect } from 'react'; +import styled from 'styled-components'; + +import { Input } from '@arco-design/web-react'; + +const Container = styled.div<{ + isBlock?: boolean; +}>` + display: ${(props) => (props.isBlock ? 'block' : 'inline-block')}; +`; + +const Label = styled.div` + font-size: 12px; + color: var(--textColor); + margin-bottom: 6px; +`; +const Content = styled.div<{ + valueColor?: string; + onClick?: any; +}>` + display: inline-block; + padding: 4px 8px; + background-color: #f6f7fb; + font-size: 12px; + color: ${(props) => props.valueColor || 'var(--textColorStrong)'}; + ${(props) => props.onClick && 'cursor: pointer'}; +`; + +const HoverInput = styled(Input.TextArea)` + width: 100%; + padding: 4px; + font-weight: 500; + font-size: 12px; + background-color: #f6f7fb; + &:hover { + background-color: #fff; + border: 1px solid var(--lineColor); + } + &:focus { + background-color: #fff; + border-color: var(--primaryColor); + } +`; + +type Props = { + /** Display title(header) */ + title?: string; + /** Display content(footer) */ + value?: any; + /** Value's color */ + valueColor?: string; + /** Is container display: block, otherwise inline-block */ + isBlock?: boolean; + /** Is input mode */ + isInputMode?: boolean; + /** Is value's type React.ReactElement */ + isComponentValue?: boolean; + onClick?: () => void; + onInputBlur?: (str: string) => void; +}; + +const InfoItem: FC<Props> = ({ + title, + value, + valueColor, + isBlock = false, + isInputMode = false, + isComponentValue = false, + onClick, + onInputBlur, +}) => { + const [innerValue, setInnerValue] = useState(); + + useEffect(() => { + if (!isComponentValue) { + setInnerValue((prevState) => value); + } + }, [value, isComponentValue]); + + return ( + <Container isBlock={isBlock}> + <Label>{title}</Label> + {isInputMode && !isComponentValue ? ( + <HoverInput + autoSize={{ + minRows: 1, + maxRows: 4, + }} + value={innerValue} + onClick={(e) => e.stopPropagation()} + onChange={(value: string, e) => setInnerValue(e.target.value)} + onBlur={(e) => { + e.stopPropagation(); + onInputBlur && onInputBlur(e.target.value); + }} + /> + ) : ( + <Content onClick={onClick} valueColor={valueColor}> + {value} + </Content> + )} + </Container> + ); +}; + +export default InfoItem; diff --git a/web_console_v2/client/src/components/InputGroup/NumberTextInput.test.tsx b/web_console_v2/client/src/components/InputGroup/NumberTextInput.test.tsx new file mode 100644 index 000000000..c373e023d --- /dev/null +++ b/web_console_v2/client/src/components/InputGroup/NumberTextInput.test.tsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import NumberInputNumber, { CpuInput, MemInput } from './NumberTextInput'; + +function typeInput(input: HTMLElement, value: string | number) { + fireEvent.change(input, { + target: { value }, + }); +} + +function triggerValueChange(input: HTMLElement, value: string | number) { + typeInput(input, value); + fireEvent.blur(input); +} + +// partly borrow from arco design repo +describe('<NumberTextInput />', () => { + it('init value correctly', () => { + const defaultValue = 8; + render(<NumberInputNumber defaultValue={defaultValue} min={0} max={12} />); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue(defaultValue.toString()); + typeInput(input, 1000); + expect(input).toHaveValue('1000'); + fireEvent.blur(input); + expect(input).toHaveValue('12'); + + typeInput(input, -1000); + expect(input).toHaveValue('-1000'); + fireEvent.blur(input); + expect(input).toHaveValue('0'); + }); + + it('init value with empty string correctly', () => { + render(<NumberInputNumber value="" />); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('init value with string correctly', () => { + render(<NumberInputNumber value="8.0000" precision={2} />); + expect(screen.getByRole('textbox')).toHaveValue('8.00'); + }); + + it('value control mode', () => { + const Demo = () => { + const [value, setValue] = useState<number | undefined>(0); + return ( + <div> + <button id="clear" onClick={() => setValue(undefined)}> + clear + </button> + <NumberInputNumber value={value} min={10} />; + </div> + ); + }; + render(<Demo />); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('0'); + fireEvent.click(screen.getByRole('button')); + expect(input).toHaveValue(''); + }); + + it('typing input', () => { + render(<NumberInputNumber min={0} max={100} />); + const input = screen.getByRole('textbox'); + triggerValueChange(input, 'abcdefg'); + expect(input).toHaveValue(''); + + triggerValueChange(input, '100abcdefg'); + expect(input).toHaveValue('100'); + + triggerValueChange(input, '1.0000abcdef'); + expect(input).toHaveValue('1'); + + triggerValueChange(input, '1000'); + // because the max is 100 + expect(input).toHaveValue('100'); + + triggerValueChange(input, '-100'); + // because the min is 0 + expect(input).toHaveValue('0'); + }); + + it('onChange calling', () => { + const onChange = jest.fn(); + render(<NumberInputNumber onChange={onChange} />); + const input = screen.getByRole('textbox'); + + typeInput(input, '1000'); + expect(onChange).toHaveBeenCalledTimes(0); + fireEvent.blur(input); + expect(onChange).toHaveBeenCalledWith(1000); + expect(onChange).toHaveBeenCalledTimes(1); + + // should ignore empty string + typeInput(input, ''); + expect(onChange).toHaveBeenCalledTimes(1); + fireEvent.blur(input); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('onChange calling with correct approximate value', () => { + const onChange = jest.fn(); + const { rerender } = render(<NumberInputNumber defaultValue={8.5} precision={0} />); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('9'); + + rerender(<NumberInputNumber onChange={onChange} precision={2} />); + triggerValueChange(input, '8.666'); + expect(onChange).toHaveBeenCalledWith(8.67); + }); +}); + +describe('CpuInput', () => { + it('should render correctly', () => { + const wrapper = render(<CpuInput />); + const $input = wrapper.getByRole('textbox') as HTMLInputElement; + const $unit = wrapper.getByText('Core'); + expect($input).toBeInTheDocument(); + expect($unit).toBeInTheDocument(); + expect($input.value).toBe(''); + }); + it('should render correctly with value', () => { + const wrapper = render(<CpuInput value="1500m" />); + const $input = wrapper.getByRole('textbox') as HTMLInputElement; + const $unit = wrapper.getByText('Core'); + expect($input).toBeInTheDocument(); + expect($unit).toBeInTheDocument(); + expect($input.value).toBe('1.5'); + }); + + it('onChange calling with unit value', () => { + const onChange = jest.fn(); + const wrapper = render(<CpuInput value="1500m" onChange={onChange} />); + const $input = wrapper.getByRole('textbox') as HTMLInputElement; + const $unit = wrapper.getByText('Core'); + expect($input).toBeInTheDocument(); + expect($unit).toBeInTheDocument(); + expect($input.value).toBe('1.5'); + + triggerValueChange($input, '3'); + expect(onChange).toHaveBeenCalledWith('3000m'); + }); +}); + +describe('MemInput', () => { + it('should render correctly', () => { + const wrapper = render(<MemInput />); + const $input = wrapper.getByRole('textbox') as HTMLInputElement; + const $unit = wrapper.getByText('Gi'); + expect($input).toBeInTheDocument(); + expect($unit).toBeInTheDocument(); + expect($input.value).toBe(''); + }); + it('should render correctly with value', () => { + const wrapper = render(<MemInput value="3Gi" />); + const $input = wrapper.getByRole('textbox') as HTMLInputElement; + const $unit = wrapper.getByText('Gi'); + expect($input).toBeInTheDocument(); + expect($unit).toBeInTheDocument(); + expect($input.value).toBe('3'); + }); + + it('onChange calling with unit value', () => { + const onChange = jest.fn(); + const wrapper = render(<MemInput value="3Gi" onChange={onChange} />); + const $input = wrapper.getByRole('textbox') as HTMLInputElement; + const $unit = wrapper.getByText('Gi'); + expect($input).toBeInTheDocument(); + expect($unit).toBeInTheDocument(); + expect($input.value).toBe('3'); + + triggerValueChange($input, '64'); + expect(onChange).toHaveBeenCalledWith('64Gi'); + }); +}); diff --git a/web_console_v2/client/src/components/InputGroup/NumberTextInput.tsx b/web_console_v2/client/src/components/InputGroup/NumberTextInput.tsx new file mode 100644 index 000000000..529bb036e --- /dev/null +++ b/web_console_v2/client/src/components/InputGroup/NumberTextInput.tsx @@ -0,0 +1,157 @@ +import React, { FC, useState, useEffect, useCallback } from 'react'; +import { Input, InputNumberProps } from '@arco-design/web-react'; +import { convertCpuCoreToM, convertCpuMToCore } from 'shared/helpers'; + +/** + * <input type="text"> 伪装成的 number input,实现了 ArcoDesign InputNumber 的部分功能: + * * 不支持键盘上下箭头加减值 + * * 不支持 mode="button" 模式 + */ +const IntegerTextInput: FC<InputNumberProps> = (props) => { + const { + min = 0, + max = Number.MAX_SAFE_INTEGER, + value, + defaultValue, + precision = 0, + prefix, + suffix, + error, + onChange, + disabled, + } = props; + const isControlledMode = Object.prototype.hasOwnProperty.call(props, 'value'); + const [innerValue, setInnerValue] = useState<number | undefined>( + getInitValue(value, defaultValue), + ); + const [inputVal, setInputVal] = useState<string | undefined>(() => { + return typeof innerValue === 'number' ? innerValue.toFixed(precision) : undefined; + }); + const setInnerValueProxy = useCallback( + (val: number | string | undefined) => { + switch (typeof val) { + case 'undefined': + setInnerValue(undefined); + setInputVal(''); + break; + case 'number': + setInnerValue(val); + setInputVal(val.toFixed(precision)); + break; + case 'string': + const digital = parseFloat(val); + if (isNaN(digital)) { + return; + } + setInnerValue(digital); + setInputVal(digital.toFixed(precision)); + break; + } + }, + [precision], + ); + + const setValue = (val: number) => { + let outputVal = val; + if (val > max) { + outputVal = max; + } else if (val < min) { + outputVal = min; + } + + if (outputVal === innerValue) { + setInnerValueProxy(outputVal); + return; + } + if (!isControlledMode) { + setInnerValueProxy(outputVal); + } + + onChange?.(outputVal); + }; + const handleBlur = (evt: React.FocusEvent<HTMLInputElement>) => { + const val = evt.target.value; + const isFloat = precision > 0; + const digitalVal = isFloat ? parseFloat(val) : parseInt(val); + if (isNaN(digitalVal)) { + setInnerValueProxy(innerValue); + return; + } + + if (isFloat) { + const fixedNumber = digitalVal.toFixed(precision); + setValue(parseFloat(fixedNumber)); + return; + } + setValue(digitalVal); + }; + + useEffect(() => { + if (isControlledMode) { + const digitalVal = value as number; + setInnerValueProxy(digitalVal); + } + }, [value, isControlledMode, min, max, setInnerValueProxy]); + + return ( + <Input + error={error} + value={inputVal} + onBlur={handleBlur} + onChange={setInputVal} + prefix={prefix} + suffix={suffix} + disabled={disabled} + /> + ); +}; + +function getInitValue(value?: string | number, defaultVal?: number): number | undefined { + if (typeof value !== 'undefined') { + const valueType = typeof value; + if (valueType === 'string') { + const digital = parseFloat(value as string); + return isNaN(digital) ? undefined : digital; + } + if (valueType === 'number') { + return value as number; + } + } + + return defaultVal; +} + +type OnChangeWithSuffixProps = Omit<InputNumberProps, 'onChange'> & { + onChange?: (val: string) => void; +}; +export const OnChangeWithSuffix: FC<OnChangeWithSuffixProps> = ({ onChange, suffix, ...rest }) => { + const onChangeWrapper = (val: number) => { + onChange?.(`${val}${suffix || ''}`); + }; + return <IntegerTextInput {...rest} suffix={suffix} onChange={onChangeWrapper} />; +}; + +export const CpuInput: FC<Omit<OnChangeWithSuffixProps, 'suffix'>> = ({ + onChange, + value, + ...props +}) => { + const onChangeWrapper = (val: string) => { + onChange?.(convertCpuCoreToM(val, true)); + }; + + return ( + <OnChangeWithSuffix + suffix="Core" + precision={1} + onChange={onChangeWrapper} + value={value ? convertCpuMToCore(value as string, true) : undefined} + {...props} + /> + ); +}; +export const MemInput: FC<Omit<OnChangeWithSuffixProps, 'suffix'>> = ({ ...props }) => { + return <OnChangeWithSuffix suffix="Gi" {...props} />; +}; + +export default IntegerTextInput; diff --git a/web_console_v2/client/src/components/InputGroup/index.test.tsx b/web_console_v2/client/src/components/InputGroup/index.test.tsx new file mode 100644 index 000000000..654e65afd --- /dev/null +++ b/web_console_v2/client/src/components/InputGroup/index.test.tsx @@ -0,0 +1,405 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { typeInput } from 'shared/testUtils'; +import InputGroup, { TColumn } from './index'; +import i18n from 'i18n'; + +const columns: TColumn[] = [ + { + type: 'TEXT', + title: 'Role', + dataIndex: 'role', + span: 6, + tooltip: '', + }, + { + type: 'INPUT_NUMBER', + title: 'CPU', + dataIndex: 'cpu', + placeholder: '请输入', + unitLabel: 'core', + max: 100, + precision: 1, + span: 6, + tooltip: i18n.t('tip_please_input_positive_integer'), + }, + { + type: 'INPUT_NUMBER', + title: 'MEM', + dataIndex: 'mem', + unitLabel: 'GiB', + span: 6, + tooltip: i18n.t('tip_please_input_positive_integer'), + }, + { + type: 'INPUT_NUMBER', + title: '实例数', + dataIndex: 'instance', + min: 1, + max: 10, + precision: 1, + mode: 'button', + span: 6, + rules: [ + { + max: 10, + message: '太多拉!', + validator(val: number, cb: any) { + cb(val > 10 ? '太多拉' : undefined); + }, + }, + ], + tooltip: i18n.t('tip_replicas_range'), + }, +]; + +// confirm whether the value is right grid by grid +function checkLengthAndValue(inputList: HTMLElement[], valueList: any[], columns: TColumn[]) { + const inputColumns = columns.filter((col) => col.type === 'INPUT' || col.type === 'INPUT_NUMBER'); + const totalLength = inputColumns.length * valueList.length; + expect(inputList.length).toBe(totalLength); + + if (valueList.length === 0 || columns.length === 0) { + return; + } + + for (let i = 0; i < valueList.length; i++) { + for (let j = 0; j < inputColumns.length; j++) { + const { dataIndex, precision, type } = inputColumns[j] as any; + const inputIndex = i * inputColumns.length + j; + const val = valueList[i][dataIndex]; + switch (type) { + case 'INPUT_NUMBER': + expect(inputList[inputIndex]).toHaveValue( + typeof val === 'number' ? val.toFixed(precision) : parseInt(val).toFixed(precision), + ); + break; + case 'INPUT': + expect(inputList[inputIndex]).toHaveValue(val); + break; + } + } + } +} + +describe('<InputGroup />', () => { + it('initial with default value', () => { + const defaultValue: any[] = [ + { + role: 'worker', + cpu: 1000, + mem: '200', + instance: 1, + }, + { + role: 'master', + cpu: 1000, + mem: '200', + instance: 2, + }, + { + role: 'slave', + cpu: 1000, + mem: '200', + instance: 30, + }, + ]; + + render(<InputGroup columns={columns} defaultValue={defaultValue} />); + checkLengthAndValue(screen.queryAllByRole('textbox'), defaultValue, columns); + }); + + it('controlled mode', () => { + const { rerender } = render(<InputGroup columns={columns} value={[]} />); + // can't find any 'textbox' element + expect(screen.queryAllByRole('gridcell').length).toBe(0); + rerender( + <InputGroup + columns={columns} + value={[ + { + role: 'slave', + cpu: 1000, + mem: '200', + instance: 30, + }, + ]} + />, + ); + expect(screen.queryAllByRole('gridcell').length).toBe(1 * columns.length); + rerender( + <InputGroup + columns={columns} + value={[ + { + role: 'slave', + cpu: 1000, + mem: '200', + instance: 30, + }, + { + role: 'slave', + cpu: 1000, + mem: '200', + instance: 30, + }, + ]} + />, + ); + expect(screen.getAllByRole('gridcell').length).toBe(2 * columns.length); + }); + + it('only trigger onChange when all grid pass validation', async () => { + const columns: TColumn[] = [ + { + type: 'INPUT', + dataIndex: 'name', + title: 'test', + span: 8, + rules: [ + { + validator(value, cb) { + cb(/failed/i.test(value) ? 'name failed: ' + value : undefined); + }, + }, + ], + }, + { + type: 'INPUT_NUMBER', + dataIndex: 'sum', + title: 'sum', + span: 16, + rules: [ + { + validator(value, cb) { + cb(value > 10 ? 'sum failed: ' + value : undefined); + }, + }, + ], + }, + ]; + const valueList = [ + { name: 'aaa', sum: 1 }, + { name: 'bbb', sum: 2 }, + ]; + const onChange = jest.fn(); + render(<InputGroup defaultValue={valueList} columns={columns} onChange={onChange} />); + const inputList = screen.queryAllByRole('textbox'); + + typeInput(inputList[0], 'failed'); + fireEvent.blur(inputList[0]); + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(0); + }); + + typeInput(inputList[1], 100); + fireEvent.blur(inputList[1]); + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(0); + }); + + typeInput(inputList[0], 'success'); + fireEvent.blur(inputList[0]); + typeInput(inputList[1], 1); + fireEvent.blur(inputList[1]); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([{ name: 'success', sum: 1 }])); + }); + }); + + it('add button behavior', async () => { + const columns: TColumn[] = [ + { + title: 'name', + dataIndex: 'name', + type: 'INPUT', + span: 12, + }, + { + title: 'sum', + dataIndex: 'sum', + type: 'INPUT_NUMBER', + span: 12, + }, + ]; + const onChange = jest.fn(); + render(<InputGroup columns={columns} onChange={onChange} />); + checkLengthAndValue(screen.queryAllByRole('textbox'), [], columns); + + const addBtn = screen.getByTestId('addBtn'); + fireEvent.click(addBtn); + checkLengthAndValue(screen.queryAllByRole('textbox'), [{ name: '', sum: 0 }], columns); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith([{ name: '', sum: 0 }]); + }); + }); + + it('remove button behavior', async () => { + const columns: TColumn[] = [ + { + title: 'name', + dataIndex: 'name', + type: 'INPUT', + span: 12, + }, + { + title: 'sum', + dataIndex: 'sum', + type: 'INPUT_NUMBER', + span: 12, + }, + ]; + const valueList = [ + { name: 'a', sum: 1 }, + { name: 'b', sum: 2 }, + { name: 'c', sum: 3 }, + ]; + const onChange = jest.fn(); + render(<InputGroup columns={columns} defaultValue={[...valueList]} onChange={onChange} />); + checkLengthAndValue(screen.queryAllByRole('textbox'), valueList, columns); + + const performDelete = async (rowIndex: number) => { + const delBtnList = screen.queryAllByTestId('delBtn'); + valueList.splice(rowIndex, 1); + fireEvent.click(delBtnList[rowIndex]); + + const inputList = screen.queryAllByRole('textbox'); + await waitFor(() => { + checkLengthAndValue(inputList, valueList, columns); + expect(onChange).toHaveBeenCalledWith(valueList); + }); + }; + // test un-order deleting + await performDelete(1); + // after deleting one row , there're still two rows. + await performDelete(1); + // only one row left at this moment. + await performDelete(0); + }); + + // the ui should not change when the value props remains unchanged. + it('add/remove button behavior under controlled mode', async () => { + const columns: TColumn[] = [ + { + title: 'name', + dataIndex: 'name', + type: 'INPUT', + span: 12, + }, + { + title: 'sum', + dataIndex: 'sum', + type: 'INPUT_NUMBER', + span: 12, + }, + ]; + const valueList = [ + { name: 'a', sum: 1 }, + { name: 'b', sum: 2 }, + { name: 'c', sum: 3 }, + ]; + const onChange = jest.fn(); + render(<InputGroup columns={columns} value={[...valueList]} onChange={onChange} />); + const addBtn = screen.getByTestId('addBtn'); + fireEvent.click(addBtn); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(valueList.concat([{ name: '', sum: 0 }])); + }); + expect(screen.getAllByRole('textbox').length).toBe(valueList.length * columns.length); + + const delBtnList = screen.getAllByTestId('delBtn'); + fireEvent.click(delBtnList[0]); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(valueList.slice(1)); + }); + expect(screen.getAllByRole('textbox').length).toBe(valueList.length * columns.length); + }); + + it('disableAddAndDelete should works', () => { + const columns: TColumn[] = [ + { + title: 'name', + dataIndex: 'name', + type: 'INPUT', + span: 12, + }, + { + title: 'sum', + dataIndex: 'sum', + type: 'INPUT_NUMBER', + span: 12, + }, + ]; + const valueList = [ + { name: 'a', sum: 1 }, + { name: 'b', sum: 2 }, + { name: 'c', sum: 3 }, + ]; + + render(<InputGroup columns={columns} defaultValue={valueList} disableAddAndDelete={true} />); + expect(screen.queryByTestId('addBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delBtn')).not.toBeInTheDocument(); + }); + + it('formatValue should works', async () => { + const onChange = jest.fn(); + const columns: TColumn[] = [ + { + title: 'name', + dataIndex: 'name', + type: 'INPUT', + span: 12, + unitLabel: 'name', + formatValue(val, col) { + return val + col.unitLabel; + }, + }, + { + title: 'sum', + dataIndex: 'sum', + type: 'INPUT_NUMBER', + span: 12, + min: 1, + unitLabel: 'sum', + formatValue(value, col) { + return value * 2; + }, + }, + ]; + const valueList = [{ name: 'a', sum: '1' }]; + + render(<InputGroup columns={columns} defaultValue={valueList} onChange={onChange} />); + fireEvent.click(screen.getByTestId('addBtn')); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([{ name: 'aname', sum: 2 }])); + }); + }); + + it('text with unitLabel should display correctly', () => { + const columns: TColumn[] = [ + { + title: '__name__', + dataIndex: 'name', + type: 'TEXT', + span: 24, + unitLabel: 'm', + }, + ]; + const valueList = [{ name: '100m100m' }]; + render(<InputGroup columns={columns} defaultValue={valueList} />); + expect(screen.getByText(/^100m100$/)).toBeInTheDocument(); + expect(screen.getByText(/^m$/)).toBeInTheDocument(); + }); + + it('should render tooltip', async () => { + const wrapper = render(<InputGroup columns={columns} value={[]} />); + const $tooltipList = wrapper.getAllByTestId('tooltip-icon'); + expect($tooltipList.length).toBe(3); + + fireEvent.mouseOver($tooltipList[0]); + + await waitFor(() => { + expect(screen.queryByText('tip_please_input_positive_integer')).toBeInTheDocument(); + }); + }); +}); diff --git a/web_console_v2/client/src/components/InputGroup/index.tsx b/web_console_v2/client/src/components/InputGroup/index.tsx new file mode 100644 index 000000000..868dc7aa1 --- /dev/null +++ b/web_console_v2/client/src/components/InputGroup/index.tsx @@ -0,0 +1,390 @@ +import React, { CSSProperties, FC, useEffect, useMemo } from 'react'; +import { + InputNumberProps, + Grid, + Form, + Input, + InputNumber, + Button, + RulesProps, + Tooltip, + Space, +} from '@arco-design/web-react'; +import { IconDelete, IconPlus, IconQuestionCircle } from '@arco-design/web-react/icon'; +import styled from 'styled-components'; +import NumberTextInput from './NumberTextInput'; + +export type TColumn = TInputColumn | TInputNumberColumn | TTextColumn; + +interface TInputColumn extends TColumnBasic { + type: 'INPUT'; + formatValue?: (value: string, column: TColumn, allValues: TValue[]) => string; +} +interface TInputNumberColumn + extends Pick<InputNumberProps, 'min' | 'max' | 'precision' | 'mode'>, + TColumnBasic { + type: 'INPUT_NUMBER'; + formatValue?: (value: number, column: TColumn, allValues: TValue[]) => number; +} +interface TTextColumn extends TColumnBasic { + type: 'TEXT'; + unitLabel?: string; +} + +interface TColumnBasic { + title: string; + dataIndex: string; + span: number; + placeholder?: string; + unitLabel?: string; + rules?: RulesProps[]; + tooltip?: string; + disabled?: boolean; +} + +type TValue = Record<string, any>; +type TInnerValue = { [key: string]: TValue[] }; +type TProps = { + /** customize style */ + style?: CSSProperties; + /** addition className */ + className?: string; + /** Definition of grid column */ + columns: TColumn[]; + /** Default value of form grid, often is an array of object */ + defaultValue?: TValue[]; + /** Use value to control the component when under controlled mode */ + value?: TValue[]; + /** + * @description Customize text on the button of "add a new row" + * @default "add" + */ + /** text of add button */ + addBtnText?: string; + /** disable add and delete */ + disableAddAndDelete?: boolean; + /** listen for the values changing */ + onChange?: (data: TValue[]) => void; +}; + +export type TInputGroupProps = TProps; + +const StyledContainer = styled.div` + .arco-input { + font-size: var(--textFontSizePrimary); + } + .arco-form-item { + margin-bottom: 8px; + } +`; +const StyledPlainText = styled.span` + display: flex; + box-sizing: border-box; + min-width: 111px; + height: 32px; + border-radius: 2px; + border: 1px solid #e5e8ef; + padding: 0 12px; + line-height: 32px; + .plainSuffix { + flex: 1; + text-align: right; + } +`; +const StyledHeader = styled.header` + margin-bottom: 6px; +`; +const StyledTitle = styled.span` + line-height: 20px; + font-size: var(--textFontSizePrimary); + color: var(--textColor); +`; +const StyledDeleteBtn = styled(Button)` + margin-top: 2px; + color: var(--textColor) !important; +`; +const StyledAddButton = styled(Button)` + width: 100px; + margin-left: -5px; + padding-left: 5px; + padding-right: 5px; + text-align: left; + font-size: var(--textFontSizePrimary); + &.arco-btn-text:not(.arco-btn-disabled):not(.arco-btn-loading):hover { + background: transparent; + } +`; +const StyledQuestionIcon = styled(IconQuestionCircle)` + font-size: var(--textFontSizePrimary); + color: var(--textColor); +`; + +const { Row, Col } = Grid; +const ROOT_FIELD = 'root'; +const FORM_FIELD_SPAN = 22; +const ROW_GUTTER = 12; +const InputGroup: FC<TProps> = (props) => { + const [form] = Form.useForm(); + const { + value, + style, + className, + columns, + addBtnText = 'add', + defaultValue = [], + disableAddAndDelete = false, + onChange, + } = props; + const controlled = Object.prototype.hasOwnProperty.call(props, 'value'); + + const columnSpanList = useMemo(() => { + let noSpanCount = columns.length; + let occupiedSpan = 0; + + for (const col of columns) { + if (col.span) { + noSpanCount -= 1; + occupiedSpan += col.span; + } + } + + if (noSpanCount > 0) { + throw new Error('InputGroup: every column should have span'); + } + + if (occupiedSpan !== 24) { + throw new Error('InputGroup: total columns span must be equal to 24'); + } + + return columns.map((col) => col.span); + }, [columns]); + useEffect(() => { + if (controlled) { + form.setFieldValue(ROOT_FIELD, value); + } + }, [value, controlled, form]); + + return ( + <StyledContainer style={style} className={className}> + <StyledHeader> + <Row gutter={ROW_GUTTER}> + <Col span={!disableAddAndDelete ? FORM_FIELD_SPAN : 24}> + <Row gutter={ROW_GUTTER}> + {columns.map((column, i) => ( + <Col span={columnSpanList[i]} key={column.dataIndex}> + <Space> + <StyledTitle>{column.title}</StyledTitle> + {column.tooltip && ( + <Tooltip content={column.tooltip}> + <StyledQuestionIcon data-testid="tooltip-icon" /> + </Tooltip> + )} + </Space> + </Col> + ))} + </Row> + </Col> + </Row> + </StyledHeader> + <div> + <Form + form={form} + initialValues={{ + [ROOT_FIELD]: value || defaultValue || [], + }} + onChange={(_, allValue: TInnerValue) => { + form.validate((error) => { + if (!error) { + onChangeWrapper(allValue[ROOT_FIELD]); + } + }); + }} + > + <Form.List field={ROOT_FIELD}> + {(fields, { remove, add }) => { + return ( + <> + {fields.map((item, index) => { + return ( + <Row gutter={ROW_GUTTER} key={item.field + item.key}> + <Col span={!disableAddAndDelete ? FORM_FIELD_SPAN : 24}> + <Row gutter={ROW_GUTTER}> + {columns.map((col, i) => { + const { dataIndex, rules } = col; + const field = item.field + '.' + dataIndex; + return ( + <Col key={field} span={columnSpanList[i]}> + <Form.Item + role="gridcell" + rules={rules} + field={field} + wrapperCol={{ span: 24 }} + > + {renderFormItem(col)} + </Form.Item> + </Col> + ); + })} + </Row> + </Col> + {!disableAddAndDelete && ( + <Col span={24 - FORM_FIELD_SPAN}> + <StyledDeleteBtn + name="delete row button" + size="small" + type="text" + data-index={index} + data-testid="delBtn" + icon={<IconDelete />} + onClick={() => { + controlled + ? performFormActionUnderControlled('delete', index) + : remove(index); + }} + /> + </Col> + )} + </Row> + ); + })} + {!disableAddAndDelete && ( + <StyledAddButton + type="text" + data-testid="addBtn" + icon={<IconPlus />} + onClick={() => { + controlled + ? performFormActionUnderControlled('add') + : add(getDefaultRowValueFromColumns(columns)); + }} + > + {addBtnText} + </StyledAddButton> + )} + </> + ); + }} + </Form.List> + </Form> + </div> + </StyledContainer> + ); + + function onChangeWrapper(values: TValue[]) { + const formattedValues = values.map((row) => { + for (const k in row) { + const col = columns.find((col) => col.dataIndex === k); + if (!col) { + continue; + } + if ( + (col.type === 'INPUT' || col.type === 'INPUT_NUMBER') && + typeof col.formatValue === 'function' + ) { + row[k] = col.formatValue(row[k] as never, col, values); + } + } + return row; + }); + + onChange?.(formattedValues); + } + + function performFormActionUnderControlled(action: 'add' | 'delete', index?: number) { + const currentValues = [...(form.getFieldValue(ROOT_FIELD) ?? [])]; + + if (action === 'add') { + currentValues.push(getDefaultRowValueFromColumns(columns)); + } else { + currentValues.splice(index!, 1); + } + onChangeWrapper(currentValues); + } +}; + +function getDefaultRowValueFromColumns(columns: TColumn[]) { + const ret: Record<string, any> = {}; + for (const col of columns) { + switch (col.type) { + case 'INPUT': + case 'TEXT': + ret[col.dataIndex] = ''; + break; + case 'INPUT_NUMBER': + ret[col.dataIndex] = col.min ?? 0; + break; + } + } + + return ret; +} + +function renderFormItem(column: TColumn) { + switch (column.type) { + case 'INPUT': + return ( + <Input + placeholder={column.placeholder} + suffix={column.unitLabel} + disabled={column.disabled} + /> + ); + case 'INPUT_NUMBER': + return column.mode ? ( + <InputNumber + min={column.min} + max={column.max} + precision={column.precision} + mode={column.mode} + suffix={column.unitLabel} + disabled={column.disabled} + /> + ) : ( + <NumberTextInput + min={column.min} + max={column.max} + precision={column.precision} + mode={column.mode} + suffix={column.unitLabel} + disabled={column.disabled} + /> + ); + case 'TEXT': + return <PlainText suffix={column.unitLabel} />; + } +} + +type TPlainTextProps = { + suffix?: string; + value?: string | number; + defaultValue?: string | number; +}; +function PlainText({ suffix, defaultValue, value }: TPlainTextProps) { + const purifyValue = useMemo(() => { + if (!value) { + return ''; + } + + if (!suffix) { + return value; + } + + if (typeof value === 'string') { + // there may be an unit label at tail + const tailString = value.slice(-1 * suffix.length); + if (tailString === suffix) { + return value.slice(0, value.length - suffix.length); + } + } + + return value; + }, [suffix, value]); + return ( + <StyledPlainText> + {purifyValue || defaultValue || ''} + {suffix ? <span className="plainSuffix">{suffix}</span> : null} + </StyledPlainText> + ); +} + +export default InputGroup; diff --git a/web_console_v2/client/src/components/InvitionTable/index.module.less b/web_console_v2/client/src/components/InvitionTable/index.module.less new file mode 100644 index 000000000..61138d7a7 --- /dev/null +++ b/web_console_v2/client/src/components/InvitionTable/index.module.less @@ -0,0 +1,34 @@ +.table_container{ + width: 480px; +} +.title_name{ + color:#1D2129; + font-size: 14px; + font-weight: 500; +} +.limited_length_text{ + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + font-size: 12; + font-weight: 400; + color: '#86909C'; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + margin-right: 8px; +} + +.select_container{ + width: 480px; + background-color: var(--componentBackgroundColorGray); + margin: 10px 0px !important; +} +.select_content_left{ + margin:10px 0px !important; +} +.select_content_right{ + margin:10px 0px !important; + padding-left: 0px !important; + padding-right: 0px !important; + max-width: 400px; +} diff --git a/web_console_v2/client/src/components/InvitionTable/index.tsx b/web_console_v2/client/src/components/InvitionTable/index.tsx new file mode 100644 index 000000000..ae02a2786 --- /dev/null +++ b/web_console_v2/client/src/components/InvitionTable/index.tsx @@ -0,0 +1,171 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; + +import { Table, Input, Grid, Tag, Space, Divider, Tooltip } from '@arco-design/web-react'; +import { IconSearch } from '@arco-design/web-react/icon'; +import { transformRegexSpecChar } from 'shared/helpers'; +import GridRow from 'components/_base/GridRow'; +import { Participant, ParticipantType } from 'typings/participant'; +import { useRecoilQuery } from 'hooks/recoil'; +import { participantListQuery } from 'stores/participant'; +import ConnectionStatus from 'views/Partner/PartnerList/ConnectionStatus'; +import { CONSTANTS } from 'shared/constants'; +import { debounce } from 'lodash-es'; + +import styles from './index.module.less'; + +const { Row, Col } = Grid; + +export const PartnerItem: FC<{ + data: Participant; + isNeedTip?: boolean; +}> = ({ data, isNeedTip = false }) => { + const isLightClient = data.type === ParticipantType.LIGHT_CLIENT; + return ( + <div style={{ paddingRight: 2 }}> + <GridRow gap={2} justify="space-between"> + <div className={styles.title_container}> + <span className={styles.title_name}>{data.name}</span> + <Tooltip content={data.comment}> + <div className={`${styles.limited_length_text} choose `}> + {data.comment || '无描述'} + </div> + </Tooltip> + </div> + + <div className="choose"> + {data?.support_blockchain && ( + <Tag color="blue" size="small"> + 区块链服务 + </Tag> + )} + {isLightClient ? ( + CONSTANTS.EMPTY_PLACEHOLDER + ) : ( + <ConnectionStatus id={data.id} isNeedTip={isNeedTip} /> + )} + </div> + </GridRow> + </div> + ); +}; + +interface Props { + onChange?: (selectedParticipants: Participant[]) => void; + participantsType: ParticipantType; + isSupportCheckbox?: boolean; +} +const InvitionTable: FC<Props> = ({ onChange, participantsType, isSupportCheckbox = true }) => { + const [filterText, setFilterText] = useState(''); + const [selectedParticipants, setSelectedParticipants] = useState<Participant[]>([]); + // TODO: Need to filter out the successful connection + const { isLoading, data: participantList } = useRecoilQuery(participantListQuery); + + const Title = ( + <Space split={<Divider type="vertical" />} size="medium"> + <span> + {participantsType === ParticipantType.LIGHT_CLIENT ? '轻量级合作伙伴' : '标准合作伙伴'} + </span> + <Input + prefix={<IconSearch />} + placeholder="输入合作伙伴名称搜索" + allowClear + style={{ flexShrink: 0 }} + onPressEnter={handleSearch} + onChange={debounce((keyword) => { + setFilterText(keyword); + }, 300)} + /> + </Space> + ); + const columns = [ + { + title: Title, + render: (text: any, record: any) => <PartnerItem data={record} />, + }, + ]; + + const showList = useMemo(() => { + if (participantList) { + const regx = new RegExp(`^.*${transformRegexSpecChar(filterText)}.*$`); + return participantList.filter((item) => { + return ( + regx.test(item.name) && + (item.type === participantsType || + (!item.type && participantsType === ParticipantType.PLATFORM)) + ); + }); + } + return []; + }, [participantList, filterText, participantsType]); + + useEffect(() => { + if (participantsType) { + setSelectedParticipants([]); + } + }, [participantsType]); + return ( + <> + <Table + className={styles.table_container} + columns={columns} + data={showList} + rowKey="id" + rowSelection={{ + selectedRowKeys: selectedParticipants.map((item) => item.id), + checkCrossPage: true, + onChange: onValueChange, + type: + isSupportCheckbox && participantsType === ParticipantType.PLATFORM + ? 'checkbox' + : 'radio', + preserveSelectedRowKeys: true, + }} + pagination={{ + pageSize: 5, + showTotal: true, + total: showList.length, + hideOnSinglePage: true, + }} + loading={isLoading} + /> + <Row gutter={24} className={styles.select_container}> + <Col span={4} className={styles.select_content_left}> + 已选{selectedParticipants.length}个 + </Col> + <Col span={20} className={styles.select_content_right}> + <Space wrap> + {selectedParticipants.map((item) => ( + <Tag + key={item.id} + color="arcoblue" + closable + onClose={() => { + removeTag(item.id); + }} + > + {item.name} + </Tag> + ))} + </Space> + </Col> + </Row> + </> + ); + + function handleSearch(e: any) { + setFilterText(e.target.value); + // Block the enter event of the submit button of the form + e.preventDefault(); + } + function onValueChange(selectedRowKeys: ID[], selectedRows: Participant[]) { + onChange?.(selectedRows); + setSelectedParticipants(selectedRows); + } + function removeTag(id: ID) { + const newSelectParticipants = selectedParticipants.filter((item) => item.id !== id); + setSelectedParticipants(newSelectParticipants); + onChange?.(newSelectParticipants); + } +}; + +export default InvitionTable; diff --git a/web_console_v2/client/src/components/LineChart/index.tsx b/web_console_v2/client/src/components/LineChart/index.tsx new file mode 100644 index 000000000..a4ddf5851 --- /dev/null +++ b/web_console_v2/client/src/components/LineChart/index.tsx @@ -0,0 +1,92 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo } from 'react'; +import { Line } from 'react-chartjs-2'; + +type Item = { + label: string; + value: any; +}; + +type Props = { + valueList: Item[]; + formatData?: (valueList: Item[]) => any; + options?: any; + width?: number; + height?: number; + maxValue?: number; +}; + +const defaultFormatData = (valueList: Item[]) => { + const labels: any[] = []; + const data: any[] = []; + + valueList.forEach((item) => { + labels.push(item.label); + data.push(item.value); + }); + + const finalData = { + labels, + datasets: [ + { + data, + backgroundColor: '#468DFF', + borderColor: 'rgb(53, 162, 235)', + }, + ], + }; + + return finalData; +}; + +const defaultMaxValue = 1; + +const getDefaultOptions = (maxValue = 1) => ({ + maintainAspectRatio: false, + responsive: true, + plugins: { + legend: { + display: false, + position: 'top', + }, + title: { + display: false, + }, + }, + scales: { + x: { + offset: true, + grid: { + color: 'transparent', + tickColor: '#cecece', + }, + }, + y: { + grid: { + borderColor: 'transparent', + }, + }, + }, +}); + +const LineChart: FC<Props> = ({ + valueList, + formatData = defaultFormatData, + options, + width, + height, + maxValue = defaultMaxValue, +}) => { + const data = useMemo(() => { + return formatData(valueList); + }, [valueList, formatData]); + + const defaultOptions = useMemo(() => { + return getDefaultOptions(maxValue); + }, [maxValue]); + + return <Line data={data} options={options || defaultOptions} width={width} height={height} />; +}; + +export default LineChart; diff --git a/web_console_v2/client/src/components/LineChartWithCard/index.tsx b/web_console_v2/client/src/components/LineChartWithCard/index.tsx new file mode 100644 index 000000000..eb51fdf48 --- /dev/null +++ b/web_console_v2/client/src/components/LineChartWithCard/index.tsx @@ -0,0 +1,134 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo } from 'react'; +import styled from 'styled-components'; + +import LineChart from 'components/LineChart'; +import NoResult from 'components/NoResult'; +import TitleWithIcon from 'components/TitleWithIcon'; +import { QuestionCircle } from 'components/IconPark'; +import { useModelMetriesResult } from 'hooks/modelCenter'; +import { formatTimestamp } from 'shared/date'; +import { Space } from '@arco-design/web-react'; + +const Card = styled.div<{ height?: number }>` + display: flex; + align-items: center; + justify-content: center; + position: relative; + ${(props) => props.height && `height: ${props.height}px`}; + border: 1px solid var(--lineColor); + border-radius: 2px; + padding: 30px 16px; +`; +const Title = styled(TitleWithIcon)` + position: absolute; + left: 16px; + top: 12px; + color: var(--textColor); + font-size: 12px; +`; +const Content = styled.div` + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0 auto; +`; +type Item = { + label: string; + value: any; +}; + +export type Props = { + valueList: Item[]; + height?: number; + title?: string; + tip?: string; +}; + +export type ModelMetricsProps = { + id: ID; + participantId?: ID; + isTraining?: boolean; +}; + +type VariantComponent = { + ModelMetrics: FC<ModelMetricsProps>; +}; + +export const LineChartWithCard: FC<Props> & VariantComponent = ({ + valueList = [], + height = 260, + title = 'Acc', + tip = '', +}) => { + return ( + <Card height={height}> + <Title + title={title || ''} + isShowIcon={Boolean(tip)} + isLeftIcon={false} + isBlock={false} + tip={tip} + icon={QuestionCircle} + /> + {valueList.length > 0 ? ( + <Content> + <LineChart valueList={valueList} /> + </Content> + ) : ( + <NoResult.NoData /> + )} + </Card> + ); +}; + +const ModelMetrics: FC<ModelMetricsProps> = ({ id, participantId, isTraining = true }) => { + const { data } = useModelMetriesResult(id, participantId); + + const metricsList = useMemo(() => { + if (!data) { + return []; + } + + const list: Array<{ + label: string; + valueList: Array<{ + label: string; + value: number; + }>; + }> = []; + const obj = (isTraining ? data.train : data.eval) ?? {}; + + Object.keys(obj).forEach((key) => { + const steps: number[] = obj[key]?.steps ?? []; + const values: number[] = obj[key]?.values ?? []; + + list.push({ + label: key.toUpperCase(), + valueList: steps.map((item, index) => { + return { + label: formatTimestamp(item), + value: values[index] || 0, + }; + }), + }); + }); + + return list; + }, [data, isTraining]); + + return ( + <Space direction="vertical" style={{ width: '100%' }}> + {metricsList.map((item) => { + return <LineChartWithCard key={item.label} valueList={item.valueList} title={item.label} />; + })} + </Space> + ); +}; + +LineChartWithCard.ModelMetrics = ModelMetrics; +export default LineChartWithCard; diff --git a/web_console_v2/client/src/components/MockDevtools/MockControlPanel.tsx b/web_console_v2/client/src/components/MockDevtools/MockControlPanel.tsx new file mode 100644 index 000000000..faec24891 --- /dev/null +++ b/web_console_v2/client/src/components/MockDevtools/MockControlPanel.tsx @@ -0,0 +1,147 @@ +/* istanbul ignore file */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { MixinCircle } from 'styles/mixins'; +import { Modal, Switch, Table, Tag, Input, Divider, Tooltip, Button } from '@arco-design/web-react'; +import { useToggle } from 'react-use'; +import LOCAL_STORAGE_KEYS from 'shared/localStorageKeys'; +import { removeRequestMock, toggleRequestMockState } from './utils'; +import { useListenKeyboard, useReactiveLocalStorage } from 'hooks'; +import store from 'store2'; +import { Storage } from 'components/IconPark'; + +const FloatButton = styled.button` + ${MixinCircle(50)} + + position: fixed; + z-index: 9999; + right: 5px; + bottom: 64px; + background-color: rgb(var(--blue-1)); + color: white; + cursor: pointer; + font-size: 12px; + + &, + &:focus, + &:active { + border: none; + outline: none; + box-shadow: none; + } +`; +const Kbd = styled.kbd` + padding: 0 5px; + font-size: 12px; + background-color: #fff; + color: rgb(var(--gray-10)); + border-radius: 2px; +`; + +const methodColor: { [key: string]: string } = { + get: 'blue', + post: 'green', + put: 'orange', + patch: 'cyan', + delete: 'red', +}; + +const tableCols = [ + { + title: 'Method', + dataIndex: 'method', + render: (text: string) => ( + <Tag color={methodColor[text.toLowerCase()]}>{text.toUpperCase()}</Tag> + ), + }, + { + title: 'Path', + dataIndex: 'path', + render: (text: string) => <h4>{text}</h4>, + }, + { + title: 'Enable mock', + key: 'toggle', + render: (_: any, record: { key: string; value: boolean }) => ( + <Switch checked={record.value} onChange={(val) => toggleRequestMockState(record.key, val)} /> + ), + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: { key: string }) => ( + <Button type="text" status="danger" onClick={() => removeRequestMock(record.key)}> + 删除 + </Button> + ), + }, +]; + +const MOCK_BUTTON_VISIBLE_KEY = 'mock_button_visible'; + +/* i18n ignore */ +function MockControlPanel() { + const [keyword, setKeyword] = useState(''); + const [visible] = useReactiveLocalStorage<any>(MOCK_BUTTON_VISIBLE_KEY, false); + const [modalVisible, toggleModal] = useToggle(false); + const [mockConfigs] = useReactiveLocalStorage<{ [key: string]: boolean }>( + LOCAL_STORAGE_KEYS.mock_configs, + ); + + useListenKeyboard('ctrl + m', () => { + const curr = store.get(MOCK_BUTTON_VISIBLE_KEY); + store.set(MOCK_BUTTON_VISIBLE_KEY, !curr); + }); + + if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_FULLY_MOCK) { + const dataSource = Object.entries(mockConfigs || {}) + .map(([key, value]) => { + const [method, path] = key.split('|'); + return { + key, + method, + path, + value, + }; + }) + .filter(({ path }) => path.includes(keyword)); + + return ( + <> + {visible.toString() === 'true' && ( + <Tooltip + position="left" + content={() => ( + <> + Mock 控制面板,<Kbd>Ctrl</Kbd> + <Kbd>M</Kbd> 切换按钮的 隐藏/显示 + </> + )} + > + <FloatButton onClick={toggleModal}> + <Storage style={{ fontSize: '24px', color: 'var(--primaryColor)' }} /> + </FloatButton> + </Tooltip> + )} + + <Modal + title="Mock 接口列表" + alignCenter + visible={modalVisible} + onOk={() => toggleModal(false)} + onCancel={() => toggleModal(false)} + style={{ width: '1000px' }} + > + <Input.Search placeholder="根据 Path 搜索" onSearch={setKeyword} searchButton /> + <Divider /> + + <Table columns={tableCols} size="small" data={dataSource} pagination={{ pageSize: 10 }} /> + </Modal> + </> + ); + } + + return null; +} + +export default MockControlPanel; diff --git a/web_console_v2/client/src/components/MockDevtools/index.js b/web_console_v2/client/src/components/MockDevtools/index.js new file mode 100644 index 000000000..9ed9227ee --- /dev/null +++ b/web_console_v2/client/src/components/MockDevtools/index.js @@ -0,0 +1,7 @@ +if (process.env.NODE_ENV === 'production') { + module.exports = function () { + return null; + }; +} else { + module.exports = require('./MockControlPanel'); +} diff --git a/web_console_v2/client/src/components/MockDevtools/utils.ts b/web_console_v2/client/src/components/MockDevtools/utils.ts new file mode 100644 index 000000000..90c3da638 --- /dev/null +++ b/web_console_v2/client/src/components/MockDevtools/utils.ts @@ -0,0 +1,45 @@ +/* istanbul ignore file */ + +import { AxiosRequestConfig } from 'axios'; +import { omit } from 'lodash-es'; +import LOCAL_STORAGE_KEYS from 'shared/localStorageKeys'; +import store from 'store2'; + +export function getMockConfigs() { + return store.get(LOCAL_STORAGE_KEYS.mock_configs) || {}; +} + +export function isThisRequestMockEnabled(config: AxiosRequestConfig): boolean { + const key = `${config.method}|${config.url}`; + + return Boolean(getRequestMockState(key)); +} + +export function getRequestMockState(key: string): boolean | undefined { + return getMockConfigs()[key]; +} + +export function setRequestMockState(key: string, val: boolean): void { + if ( + !['post', 'get', 'patch', 'delete', 'put', 'head', 'options', 'connect'].some((method) => + key.toLowerCase().startsWith(method), + ) + ) { + throw new Error('Key 名不合法!'); + } + + const mocksConfig = store.get(LOCAL_STORAGE_KEYS.mock_configs) || {}; + mocksConfig[key] = val; + + store.set(LOCAL_STORAGE_KEYS.mock_configs, mocksConfig); +} + +export function removeRequestMock(key: string): void { + const mocksConfig = getMockConfigs(); + + store.set(LOCAL_STORAGE_KEYS.mock_configs, omit(mocksConfig, key)); +} + +export function toggleRequestMockState(key: string, val?: boolean): void { + setRequestMockState(key, typeof val === 'boolean' ? val : !getRequestMockState(key)); +} diff --git a/web_console_v2/client/src/components/Modal/index.tsx b/web_console_v2/client/src/components/Modal/index.tsx new file mode 100644 index 000000000..509fce694 --- /dev/null +++ b/web_console_v2/client/src/components/Modal/index.tsx @@ -0,0 +1,101 @@ +/* istanbul ignore file */ + +import React, { FC } from 'react'; +import i18n from 'i18n'; + +import { Z_INDEX_GREATER_THAN_HEADER } from 'components/Header'; + +import { Modal } from '@arco-design/web-react'; + +import { ModalProps } from '@arco-design/web-react/es/Modal/modal'; +import { ConfirmProps } from '@arco-design/web-react/es/Modal/confirm'; + +type ModalType = typeof Modal & { + delete: (props: ConfirmProps) => ReturnType<typeof Modal.confirm>; + stop: (props: ConfirmProps) => ReturnType<typeof Modal.confirm>; + terminate: (props: ConfirmProps) => ReturnType<typeof Modal.confirm>; + reject: (props: ConfirmProps) => ReturnType<typeof Modal.confirm>; +}; + +export const CUSTOM_CLASS_NAME = 'custom-modal'; + +export function withConfirmProps(props: ConfirmProps) { + return { + className: CUSTOM_CLASS_NAME, + zindex: Z_INDEX_GREATER_THAN_HEADER, + okText: i18n.t('confirm'), + cancelText: i18n.t('cancel'), + ...props, + }; +} + +export function withDeleteProps(props: ConfirmProps) { + return withConfirmProps({ + okText: i18n.t('delete'), + okButtonProps: { + status: 'danger', + }, + ...props, + }); +} + +export function withStopProps(props: ConfirmProps) { + return withConfirmProps({ + okText: i18n.t('stop'), + okButtonProps: { + status: 'danger', + }, + ...props, + }); +} + +export function withTerminate(props: ConfirmProps) { + return withConfirmProps({ + okText: i18n.t('terminate'), + okButtonProps: { + status: 'danger', + }, + ...props, + }); +} + +export function withRejectProps(props: ConfirmProps) { + return withConfirmProps({ + okText: '确认拒绝', + okButtonProps: { + status: 'danger', + }, + ...props, + }); +} + +const ProxyModal: FC<ModalProps> = (props) => { + return <Modal wrapClassName={CUSTOM_CLASS_NAME} {...props} />; +}; + +const MyModal = ProxyModal as ModalType; + +// Custom method +MyModal.delete = (props: ConfirmProps) => { + return Modal.confirm(withDeleteProps(props)); +}; +MyModal.stop = (props: ConfirmProps) => { + return Modal.confirm(withStopProps(props)); +}; +MyModal.terminate = (props: ConfirmProps) => { + return Modal.confirm(withTerminate(props)); +}; +MyModal.reject = (props: ConfirmProps) => { + return Modal.confirm(withRejectProps(props)); +}; + +// Proxy all static method +MyModal.info = (props: ConfirmProps) => Modal.info(withConfirmProps(props)); +MyModal.success = (props: ConfirmProps) => Modal.success(withConfirmProps(props)); +MyModal.error = (props: ConfirmProps) => Modal.error(withConfirmProps(props)); +MyModal.warning = (props: ConfirmProps) => Modal.warning(withConfirmProps(props)); +MyModal.confirm = (props: ConfirmProps) => Modal.confirm(withConfirmProps(props)); +MyModal.destroyAll = () => Modal.destroyAll(); +MyModal.useModal = () => Modal.useModal(); + +export default MyModal; diff --git a/web_console_v2/client/src/components/ModelCodesEditorButton/index.module.less b/web_console_v2/client/src/components/ModelCodesEditorButton/index.module.less new file mode 100644 index 000000000..4430a863d --- /dev/null +++ b/web_console_v2/client/src/components/ModelCodesEditorButton/index.module.less @@ -0,0 +1,9 @@ +.drawer_container{ + :global { + .arco-drawer-content{ + padding: 10px 0 0; + height: 100%; + background-color: #1e1e1e; + } + } +} diff --git a/web_console_v2/client/src/components/MoreActions/index.tsx b/web_console_v2/client/src/components/MoreActions/index.tsx new file mode 100644 index 000000000..f3b4215e7 --- /dev/null +++ b/web_console_v2/client/src/components/MoreActions/index.tsx @@ -0,0 +1,152 @@ +/* istanbul ignore file */ + +import React, { ReactNode, CSSProperties } from 'react'; +import styled, { createGlobalStyle } from 'styled-components'; + +import { Menu, Popover, Tooltip } from '@arco-design/web-react'; +import IconButton from 'components/IconButton'; +import { More } from 'components/IconPark'; + +import { PopoverProps } from '@arco-design/web-react/es/Popover'; + +export const GLOBAL_CLASS_NAME = 'global-more-actions'; + +const GlobalStyle = createGlobalStyle` + .${GLOBAL_CLASS_NAME} { + min-width: 72px; + border: 1px solid #e5e6e8; + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: hidden; + padding: 0; + z-index: var(--zIndexLessThanModal); + + .arco-popover-content { + padding: 0; + .arco-popover-arrow { + display: none !important; + } + .arco-popover-inner { + border-radius: 0; + .arco-popover-inner-content { + padding: 6px 0; + } + } + } + } +`; + +const ActionListContainer = styled(Menu)` + .arco-menu-inner { + margin: 0; + padding: 0; + } + + && .actionItem { + width: 100%; + min-height: 36px; + text-align: center; + margin: 0; + padding: 0; + + .item { + padding: 0 16px; + } + + &:not(.arco-menu-disabled) { + color: var(--fontColor, var(--textColor)); + } + } +` as typeof Menu; + +export interface ActionItem { + /** Display Label */ + label: string; + onClick?: () => void; + /** Sometimes you need to disable the button */ + disabled?: boolean; + /** Sometimes you want a hint when the button is disabled */ + disabledTip?: string; + /** Danger button style, red color */ + danger?: boolean; + /** Just for test */ + testId?: string; +} + +export interface Props extends PopoverProps { + /** DataSource */ + actionList: ActionItem[]; + /** + * Customize content render + */ + renderContent?: (actionList: ActionItem[]) => ReactNode; + children?: any; + zIndex?: number | string; + className?: string | undefined; +} + +function renderDefaultContent(actionList: ActionItem[]) { + return ( + <ActionListContainer selectable={false}> + {actionList.map((item, index) => ( + // Because "div" has no disable effect, replace div with "Menu.Item" here + <Menu.Item + className="actionItem" + key={`${item.label}__${index}`} + disabled={Boolean(item.disabled)} + onClick={(event) => { + event?.stopPropagation(); + item.onClick?.(); + }} + style={ + { + '--fontColor': item.danger ? 'var(--errorColor)' : null, + } as CSSProperties + } + > + {item.disabledTip && item.disabled ? ( + <Tooltip content={item.disabledTip}> + <span className="item" data-testid={item.testId}> + {item.label} + </span> + </Tooltip> + ) : ( + <span className="item" data-testid={item.testId}> + {item.label} + </span> + )} + </Menu.Item> + ))} + </ActionListContainer> + ); +} + +function MoreActions({ + actionList, + trigger = 'click', + children, + renderContent, + zIndex = 'var(--zIndexLessThanModal)', + className, + ...resetProps +}: Props) { + return ( + <span onClick={(e) => e.stopPropagation()} className={className}> + <GlobalStyle /> + <Popover + content={renderContent ? renderContent(actionList) : renderDefaultContent(actionList)} + position="bl" + className={GLOBAL_CLASS_NAME} + triggerProps={{ + trigger, + }} + style={{ zIndex: zIndex as any }} + {...resetProps} + > + {children ?? <IconButton type="text" icon={<More />} data-testid="btn-more-actions" />} + </Popover> + </span> + ); +} + +export default MoreActions; diff --git a/web_console_v2/client/src/components/MultiSelect/index.module.less b/web_console_v2/client/src/components/MultiSelect/index.module.less new file mode 100644 index 000000000..7e845112a --- /dev/null +++ b/web_console_v2/client/src/components/MultiSelect/index.module.less @@ -0,0 +1,37 @@ +.select{ + :global{ + .arco-input-tag-input.arco-input-tag-input-size-default{ + font-size: 13px !important; + } + } +} + +.header{ + display: flex; + justify-content: space-between; + padding: 5px 12px; + border-bottom: 1px solid var(--lineColor); +} + +.label_strong{ + font-size: 14px; + color: var(--textColorStrong); +} + +.label{ + font-size: 14px; + color: var(--textColor); + margin-right: 8px; +} + +.label_index{ + display: inline-block; + width: 30px; + font-size: 14px; + color: var(--textColorSecondary); +} + +.item_cotainer{ + display: flex; + justify-content: space-between; +} diff --git a/web_console_v2/client/src/components/MultiSelect/index.tsx b/web_console_v2/client/src/components/MultiSelect/index.tsx new file mode 100644 index 000000000..3f941c4c6 --- /dev/null +++ b/web_console_v2/client/src/components/MultiSelect/index.tsx @@ -0,0 +1,116 @@ +/* istanbul ignore file */ + +import React, { FC } from 'react'; + +import { Select, Checkbox, Tag } from '@arco-design/web-react'; +import { IconSearch } from '@arco-design/web-react/icon'; + +import { SelectProps } from '@arco-design/web-react/es/Select'; +import styled from './index.module.less'; +export interface OptionItem { + /** Display label */ + label: string; + /** Form value */ + value: any; +} + +export interface Props extends SelectProps { + value?: any[]; + onChange?: (val: any) => void; + /** + * DataSource + */ + optionList: OptionItem[]; + /** + * Hide header layout + */ + isHideHeader?: boolean; + /** + * Hide index label + */ + isHideIndex?: boolean; +} + +const MultiSelect: FC<Props> = ({ + value, + onChange = () => {}, + optionList, + isHideHeader = false, + isHideIndex = false, + ...props +}) => { + const isAllChecked = value?.length === optionList.length; + return ( + <Select + mode="multiple" + value={value} + arrowIcon={false} + showSearch={true} + suffixIcon={<IconSearch fontSize={14} />} + onChange={(value, options) => { + onChange(value); + }} + className={styled.select} + dropdownRender={(menu) => ( + <div> + {!isHideHeader && ( + <div className={styled.header}> + <span className={styled.label_strong}>{`已选择 ${value?.length ?? 0} 项`}</span> + <div> + <span className={styled.label}>全选</span> + <Checkbox + disabled={!optionList || optionList.length === 0} + checked={isAllChecked} + onChange={(checked: boolean) => { + if (checked) { + onChange(optionList.map((item) => item.value)); + } else { + onChange([]); + } + }} + /> + </div> + </div> + )} + {menu} + </div> + )} + renderTag={ + isAllChecked + ? (props: any) => { + // only show first item + if (props.value !== optionList[0]?.value) { + return null as any; + } + return ( + <Tag + closable + onClose={() => { + onChange([]); + }} + > + 全选 + </Tag> + ); + } + : undefined + } + {...props} + > + {optionList.map((item, index) => { + return ( + <Select.Option key={item.value} value={item.value} title={item.label}> + <div className={styled.item_cotainer}> + <div> + {!isHideIndex && <span className={styled.label_index}>{index + 1}</span>} + <span className={styled.label_strong}>{item.label}</span> + </div> + </div> + </Select.Option> + ); + })} + </Select> + ); +}; + +export default MultiSelect; diff --git a/web_console_v2/client/src/components/NewDatasetSelect/hooks.ts b/web_console_v2/client/src/components/NewDatasetSelect/hooks.ts new file mode 100644 index 000000000..4426cb732 --- /dev/null +++ b/web_console_v2/client/src/components/NewDatasetSelect/hooks.ts @@ -0,0 +1,175 @@ +import { PageMeta } from 'typings/app'; +import { useRecoilValue } from 'recoil'; +import { useMemo, useState } from 'react'; +import { IPageInfo, Props } from './index'; +import { projectState } from 'stores/project'; +import { Message } from '@arco-design/web-react'; +import { useQuery, UseQueryResult } from 'react-query'; +import { DATASET_LIST_QUERY_KEY } from 'views/Datasets/DatasetList'; +import { + Dataset, + DatasetKindLabelCapitalMapper, + DatasetStateFront, + ParticipantDataset, +} from 'typings/dataset'; +import { + fetchDatasetDetail, + fetchDatasetList, + fetchParticipantDatasetList, +} from 'services/dataset'; +import { FILTER_OPERATOR_MAPPER, filterExpressionGenerator } from 'views/Datasets/shared'; + +type TGetDatasetList = [ + Array<Dataset | ParticipantDataset>, + UseQueryResult< + { + data: Array<Dataset | ParticipantDataset>; + }, + unknown + >, + IPageInfo, + (args: any) => void, + () => void, + (args: Array<Dataset | ParticipantDataset>) => void, +]; + +/** + * fetch dataset list depends lazy or not + * @param isParticipant + * @param queryParams + * @param kind + * @param lazyLoad + * @param disabled + * @param datasetJobKind + */ +export function useGetDatasetList({ + isParticipant, + queryParams, + kind, + lazyLoad = { + enable: false, + page_size: 10, + }, + disabled, + datasetJobKind, +}: Pick< + Props, + 'isParticipant' | 'queryParams' | 'kind' | 'lazyLoad' | 'disabled' | 'datasetJobKind' +>): TGetDatasetList { + const selectedProject = useRecoilValue(projectState); + const [pageInfo, setPageInfo] = useState<IPageInfo>({ + page: 1, + totalPages: 0, + keyword: '', + }); + const [options, setOptions] = useState([] as Array<ParticipantDataset | Dataset>); + + const query = useQuery<{ + data: Array<Dataset | ParticipantDataset>; + page_meta?: PageMeta; + }>( + [ + DATASET_LIST_QUERY_KEY, + selectedProject.current?.id, + isParticipant, + kind, + lazyLoad?.enable ? pageInfo.page : null, + lazyLoad?.enable ? pageInfo.keyword : null, + disabled, + datasetJobKind, + ], + () => { + const pageParams = lazyLoad?.enable + ? { + page: pageInfo.page, + page_size: lazyLoad.page_size, + } + : {}; + const filter = filterExpressionGenerator( + { + project_id: selectedProject.current?.id, + name: pageInfo.keyword, + is_published: isParticipant ? undefined : true, + dataset_kind: isParticipant ? undefined : DatasetKindLabelCapitalMapper[kind!], + }, + FILTER_OPERATOR_MAPPER, + ); + if (isParticipant) { + return fetchParticipantDatasetList(selectedProject.current?.id!, { + ...queryParams, + ...pageParams, + }); + } + return fetchDatasetList({ + filter, + ...queryParams, + ...pageParams, + state_frontend: [DatasetStateFront.SUCCEEDED], + dataset_job_kind: datasetJobKind, + }); + }, + { + enabled: Boolean(selectedProject.current && !disabled), + retry: 2, + refetchOnWindowFocus: false, + onSuccess: (res) => { + setPageInfo((pre) => { + const { page_meta } = res; + return { + ...pre, + page: page_meta?.current_page || pre.page, + totalPages: page_meta?.total_pages || pre.totalPages, + }; + }); + setOptions((pre) => { + const { data } = res; + const addOption = (data ?? []) as Dataset[]; + return pre.concat(addOption); + }); + }, + }, + ); + + const list = useMemo(() => { + return options; + }, [options]); + + const clearList = () => { + setOptions([]); + }; + + return [list, query, pageInfo, setPageInfo, clearList, setOptions] as TGetDatasetList; +} + +/** + * Gets the details of the last selected dataset when the page is initialized + * @param datasetId + * @param pageInit + */ +export function useGetLastSelectedDataset(datasetId: ID, pageInit: boolean) { + const detailDataQuery = useQuery( + ['fetch_last_dataset_detail', datasetId, pageInit], + () => fetchDatasetDetail(datasetId), + { + enabled: Boolean(pageInit && (datasetId || datasetId === 0)), + retry: false, + refetchOnWindowFocus: false, + onError: (error: any) => { + Message.error(error.message); + }, + }, + ); + + const isLoading = useMemo(() => { + return detailDataQuery.isFetching; + }, [detailDataQuery]); + + const data = useMemo(() => { + if (!detailDataQuery?.data) { + return undefined; + } + return detailDataQuery?.data?.data; + }, [detailDataQuery]); + + return [isLoading, data] as [boolean, undefined | Dataset | ParticipantDataset]; +} diff --git a/web_console_v2/client/src/components/NewDatasetSelect/index.tsx b/web_console_v2/client/src/components/NewDatasetSelect/index.tsx new file mode 100644 index 000000000..5b404fdd7 --- /dev/null +++ b/web_console_v2/client/src/components/NewDatasetSelect/index.tsx @@ -0,0 +1,228 @@ +/* istanbul ignore file */ +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { Select } from '@arco-design/web-react'; +import { fetchDatasetDetail } from 'services/dataset'; +import { useTranslation } from 'react-i18next'; +import { Dataset, DatasetKindLabel, ParticipantDataset, DataJobBackEndType } from 'typings/dataset'; +import { SelectProps } from '@arco-design/web-react/es/Select'; +import { OptionInfo } from '@arco-design/web-react/es/Select/interface'; +import { debounce } from 'lodash-es'; +import { useGetDatasetList, useGetLastSelectedDataset } from './hooks'; +import { renderOption } from '../DatasetSelect'; + +interface ILazyLoad { + enable: boolean; + page_size?: number; +} + +export interface IPageInfo { + page?: number; + totalPages?: number; + keyword?: string; +} + +export interface Props extends SelectProps { + /** Is participant dataset */ + isParticipant?: boolean; + /** extra API query params */ + queryParams?: object; + /** raw or processed dataset */ + kind?: DatasetKindLabel; + shouldGetDatasetDetailAfterOnChange?: boolean; + onChange?: (value: any, option: OptionInfo | OptionInfo[], datasetDetail?: Dataset) => void; + /** open pagination and fetch list with page params */ + lazyLoad?: ILazyLoad; + /** DATA_ALIGNMENT or other type dataset */ + datasetJobKind?: DataJobBackEndType; +} + +const DatasetSelect: FC<Props> = ({ + value, + onChange, + queryParams, + isParticipant = false, + kind = DatasetKindLabel.PROCESSED, + shouldGetDatasetDetailAfterOnChange = false, + lazyLoad = { + enable: false, + page_size: 10, + }, + disabled = false, + datasetJobKind, + ...props +}) => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const refCanTriggerLoadMore = useRef(true); + const pageInit = useRef(true); + const [ + datasetList, + datasetQuery, + pageInfo, + setPageInfo, + clearList, + setOptions, + ] = useGetDatasetList({ + isParticipant, + queryParams, + kind, + lazyLoad, + disabled, + datasetJobKind, + }); + + // fetch the details of the last selected dataset + const [lastDatasetLoading, lastDatasetDetail] = useGetLastSelectedDataset( + value as ID, + pageInit.current, + ); + + const popupScrollHandler = (element: any) => { + const curPage = pageInfo.page || 0; + const curTotalPage = pageInfo.totalPages || 0; + if (!lazyLoad?.enable || curPage >= curTotalPage) { + return; + } + const { scrollTop, scrollHeight, clientHeight } = element; + const scrollBottom = scrollHeight - (scrollTop + clientHeight); + if (scrollBottom < 10) { + if (!datasetQuery.isFetching && refCanTriggerLoadMore.current) { + setPageInfo((pre: any) => ({ + ...pre, + page: pre.page + 1, + })); + refCanTriggerLoadMore.current = false; + } + } else { + refCanTriggerLoadMore.current = true; + } + }; + + const debouncedFetchUser = debounce((inputValue: string) => { + if (!lazyLoad?.enable) { + return; + } + setPageInfo({ + keyword: inputValue, + page: 1, + totalPages: 0, + }); + clearList(); + }, 500); + + const isControlled = typeof value !== 'undefined'; + const valueProps = isControlled ? { value } : {}; + const disableProps = isLoading || disabled ? { disabled: true } : {}; + useEffect(() => { + setPageInfo({ + keyword: '', + page: 1, + totalPages: 0, + }); + setOptions([]); + }, [datasetJobKind, setPageInfo, setOptions]); + + return ( + <Select + onSearch={debouncedFetchUser} + onPopupScroll={popupScrollHandler} + placeholder={t('placeholder_select')} + onChange={onSelectChange} + value={value} + showSearch + allowClear + filterOption={(inputValue, option) => { + return option.props.extra.name.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0; + }} + loading={datasetQuery.isFetching || isLoading || lastDatasetLoading} + {...valueProps} + {...props} + {...disableProps} + > + {datasetList.map((item) => ( + <Select.Option + key={isParticipant ? (item as ParticipantDataset).uuid : (item as Dataset).id} + value={isParticipant ? (item as ParticipantDataset).uuid : (item as Dataset).id} + extra={item} + > + {renderOption(item, isParticipant)} + </Select.Option> + ))} + {Boolean(lastDatasetDetail && lastDatasetDetail?.name) && ( + <Select.Option + key={ + isParticipant + ? (lastDatasetDetail as ParticipantDataset).uuid + : (lastDatasetDetail as Dataset).id + } + value={ + isParticipant + ? (lastDatasetDetail as ParticipantDataset).uuid + : (lastDatasetDetail as Dataset).id + } + extra={lastDatasetDetail} + > + {renderOption(lastDatasetDetail!, isParticipant)} + </Select.Option> + )} + </Select> + ); + + async function onSelectChange(id: string, options: OptionInfo | OptionInfo[]) { + pageInit.current = false; + let datasetDetail; + try { + if (shouldGetDatasetDetailAfterOnChange && !isParticipant) { + setIsLoading(true); + const resp = await fetchDatasetDetail(id); + datasetDetail = resp.data; + } + } catch (error) {} + setIsLoading(false); + onChange?.(id, options, datasetDetail); + } +}; + +type PathProps = { + /** Accept dataset path */ + value?: string; + /** Accept dataset path */ + onChange?: (val?: string, options?: OptionInfo | OptionInfo[]) => void; +} & Omit<Props, 'value' | 'onChange' | 'isParticipant'>; + +export const DatasetPathSelect: FC<PathProps> = ({ + value, + onChange, + queryParams, + kind = DatasetKindLabel.PROCESSED, + ...props +}) => { + const [datasetList] = useGetDatasetList({ + isParticipant: false, // Don't support participant dataset, because it's no path field + queryParams, + kind, + }); + + const datasetId = useMemo(() => { + const dataset = datasetList.find((item) => { + return (item as Dataset).path === value; + }) as Dataset; + + return dataset?.id; + }, [datasetList, value]); + + const valueProps = datasetId ? { value: datasetId } : {}; + + return ( + <DatasetSelect + onChange={(_, option) => { + const dataset = (option as OptionInfo)?.extra; + onChange?.(dataset?.path ?? undefined, option); + }} + {...valueProps} + {...props} + /> + ); +}; + +export default DatasetSelect; diff --git a/web_console_v2/client/src/components/ProgressWithText/index.module.less b/web_console_v2/client/src/components/ProgressWithText/index.module.less new file mode 100644 index 000000000..d2e64acd4 --- /dev/null +++ b/web_console_v2/client/src/components/ProgressWithText/index.module.less @@ -0,0 +1,13 @@ +.progress_container{ + font-size: 12px; + line-height: 22px; + color: rgb(var(--gray-7)); + .progress_name{ + display: block; + margin-bottom: -10px; + font-weight: 400; + font-size: 12px; + line-height: 20px; + color: #1D2129; + } +} diff --git a/web_console_v2/client/src/components/ProgressWithText/index.tsx b/web_console_v2/client/src/components/ProgressWithText/index.tsx new file mode 100644 index 000000000..79ffd7e1d --- /dev/null +++ b/web_console_v2/client/src/components/ProgressWithText/index.tsx @@ -0,0 +1,33 @@ +import React, { ReactElement } from 'react'; +import styles from './index.module.less'; +import { Progress, ProgressProps, Tooltip, TooltipProps } from '@arco-design/web-react'; +interface Props extends ProgressProps { + statusText: string; + toolTipPosition?: TooltipProps['position']; + toolTipContent?: TooltipProps['content']; +} +function ProgressWithText({ + style, + className, + percent, + status, + statusText, + toolTipPosition = 'top', + toolTipContent, + ...props +}: Props): ReactElement { + return ( + <Tooltip position={toolTipPosition} content={toolTipContent}> + <div className={`${styles.progress_container} ${className}`} style={style}> + <span className={styles.progress_name}>{statusText ?? '-'}</span> + <Progress + percent={percent ?? 100} + status={status ?? 'normal'} + showText={false} + trailColor="var(--color-primary-light-1)" + /> + </div> + </Tooltip> + ); +} +export default ProgressWithText; diff --git a/web_console_v2/client/src/components/PropList/index.tsx b/web_console_v2/client/src/components/PropList/index.tsx new file mode 100644 index 000000000..ee784f198 --- /dev/null +++ b/web_console_v2/client/src/components/PropList/index.tsx @@ -0,0 +1,62 @@ +/* istanbul ignore file */ + +import React, { FC, ReactNode } from 'react'; +import styled from 'styled-components'; +import { Grid } from '@arco-design/web-react'; +import { Label, LabelStrong } from 'styles/elements'; +import { Copy } from 'components/IconPark'; +import ClickToCopy from 'components/ClickToCopy'; + +const Row = Grid.Row; +const Col = Grid.Col; + +export interface Item { + key: string; + value: ReactNode; + isCanCopy?: boolean; + onClick?: () => void; +} +export interface Props { + leftSpan?: number; + rightSpan?: number; + list: Item[]; +} + +const StyledRow = styled(Row)` + margin-top: 16px; +`; +const StyledCopyIcon = styled(Copy)` + margin-left: 20px; + font-size: 14px; + &:hover { + color: #1664ff; + } +`; + +const PropList: FC<Props> = ({ list, leftSpan = 4, rightSpan = 20 }) => { + return ( + <> + {list.map((item) => { + return ( + <StyledRow> + <Col span={leftSpan}> + <Label>{item.key}</Label> + </Col> + <Col span={rightSpan}> + {item.isCanCopy ? ( + <ClickToCopy text={String(item.value)}> + <LabelStrong onClick={item.onClick}> + {item.value} <StyledCopyIcon /> + </LabelStrong> + </ClickToCopy> + ) : ( + <LabelStrong onClick={item.onClick}>{item.value}</LabelStrong> + )} + </Col> + </StyledRow> + ); + })} + </> + ); +}; +export default PropList; diff --git a/web_console_v2/client/src/components/ReadFile/index.module.less b/web_console_v2/client/src/components/ReadFile/index.module.less new file mode 100644 index 000000000..0d72cab4c --- /dev/null +++ b/web_console_v2/client/src/components/ReadFile/index.module.less @@ -0,0 +1,90 @@ +.read_file_container{ + position: relative; + min-height: 32px; + border-radius: 2px; + background-color: var(--color-fill-2); +} + +.read_file{ + background-color: var(--color-fill-2); + position: absolute; + top: 0; + z-index: 2; + display: flex; + height: 32px; + width: 100%; + padding-left: 16px; + padding-right: 12px; + align-items: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.4s cubic-bezier(0.4,0,0.2,1); + :global{ + .filename { + padding-left: 10px; + flex: 1; + } + + .anticon-check-circle { + color: var(--errorColor); + } + } +} + +.read_file_upload{ + padding: 0; +} + +.read_file_without_upload{ + transition: display 0.4s cubic-bezier(0.4,0,0.2,1),opacity 0.4s cubic-bezier(0.4,0,0.2,1); + max-height: 400px; + will-change: display; +} + +.read_file_content_inner{ + padding: 20px 0 40px; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; +} + +.read_file_upload_hint{ + display: block; + font-size: 12px; + line-height: 18px; + color: var(--textColorSecondary); +} + +.read_file_upload_placeholder{ + margin-bottom: 4px; + line-height: 24px; + font-size: 16px; +} + +.read_file_plus_icon{ + font-size: 16px; +} + +.hidden { + display: none; +} + +.visible { + opacity: 1; + pointer-events: initial; + + > .anticon-check-circle { + animation: zoomIn 0.3s cubic-bezier(0.12, 0.4, 0.29, 1.46); + } +} + +.delete_file_btn{ + position: absolute; + right: -20px; + cursor: pointer; + + &:hover { + color: var(--primaryColor); + } +} diff --git a/web_console_v2/client/src/components/ResourceConfig/index.tsx b/web_console_v2/client/src/components/ResourceConfig/index.tsx new file mode 100644 index 000000000..4665074fd --- /dev/null +++ b/web_console_v2/client/src/components/ResourceConfig/index.tsx @@ -0,0 +1,550 @@ +/* istanbul ignore file */ + +import React, { FC, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import i18n from 'i18n'; + +import { convertCpuCoreToM } from 'shared/helpers'; + +import { Form, Collapse } from '@arco-design/web-react'; +import InputGroup, { TColumn } from 'components/InputGroup'; +import BlockRadio from 'components/_base/BlockRadio'; + +import { ResourceTemplateType, AlgorithmType } from 'typings/modelCenter'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; + +const StyledCollapse = styled(Collapse)` + overflow: initial; + .arco-collapse-item-header { + position: relative; + left: -14px; + border-width: 0; + &-title { + font-weight: 400 !important; + font-size: 12px; + } + } + .arco-collapse-item-content { + background-color: transparent; + } + .arco-collapse-item-content-box { + padding: 0; + } +`; + +const RESOURCE_HIGH_TREE = { + master_replicas: 1, + master_cpu: 0, + master_mem: 0, + ps_replicas: 1, + ps_cpu: 0, + ps_mem: 0, + worker_replicas: 1, + worker_cpu: 16, + worker_mem: 64, + worker_roles: 'worker', + ps_roles: 'ps', + master_roles: 'master', +}; +const RESOURCE_HIGH_NN = { + master_replicas: 1, + master_cpu: 2, + master_mem: 32, + ps_replicas: 1, + ps_cpu: 8, + ps_mem: 32, + worker_replicas: 1, + worker_cpu: 8, + worker_mem: 32, + worker_roles: 'worker', + ps_roles: 'ps', + master_roles: 'master', +}; +const RESOURCE_MEDIUM_TREE = { + master_replicas: 1, + master_cpu: 0, + master_mem: 0, + ps_replicas: 1, + ps_cpu: 0, + ps_mem: 0, + worker_replicas: 1, + worker_cpu: 8, + worker_mem: 32, + worker_roles: 'worker', + ps_roles: 'ps', + master_roles: 'master', +}; +const RESOURCE_MEDIUM_NN = { + master_replicas: 1, + master_cpu: 1, + master_mem: 16, + ps_replicas: 1, + ps_cpu: 4, + ps_mem: 16, + worker_replicas: 1, + worker_cpu: 4, + worker_mem: 16, + worker_roles: 'worker', + ps_roles: 'ps', + master_roles: 'master', +}; +const RESOURCE_LOW_TREE = { + master_replicas: 1, + master_cpu: 0, + master_mem: 0, + ps_replicas: 1, + ps_cpu: 0, + ps_mem: 0, + worker_replicas: 1, + worker_cpu: 4, + worker_mem: 8, + worker_roles: 'worker', + ps_roles: 'ps', + master_roles: 'master', +}; +const RESOURCE_LOW_NN = { + master_replicas: 1, + master_cpu: 1, + master_mem: 4, + ps_replicas: 1, + ps_cpu: 2, + ps_mem: 4, + worker_replicas: 1, + worker_cpu: 2, + worker_mem: 4, + worker_roles: 'worker', + ps_roles: 'ps', + master_roles: 'master', +}; +const RESOURCE_CUSTOM_NN = { + master_replicas: 1, + master_cpu: 1, + master_mem: 4, + ps_replicas: 1, + ps_cpu: 2, + ps_mem: 4, + worker_replicas: 1, + worker_cpu: 4, + worker_mem: 8, + worker_roles: 'worker', + ps_roles: 'ps', + master_roles: 'master', +}; + +enum TResourceFieldType { + MASTER = 'master', + PS = 'ps', + WORKER = 'worker', +} +type TResource = typeof RESOURCE_LOW_NN; + +const roleFieldList = [TResourceFieldType.MASTER, TResourceFieldType.PS, TResourceFieldType.WORKER]; +const resourceFieldList = ['replicas', 'cpu', 'mem']; + +export const classifyResourceWithTemplateType = (resource: Partial<TResource>) => { + const ret: Record<string, [Partial<Record<keyof TResource, number | string>>]> = {}; + for (const key in resource) { + const [type] = key.split('_'); + if (!ret[type]) { + ret[type] = [{}]; + } + ret[type][0][key as keyof TResource] = resource[key as keyof TResource]; + } + return ret; +}; + +export const unwrapResourceFromTemplateType = (payload: Record<string, any>) => { + const copied = { ...payload }; + for (const key in copied) { + if ( + [TResourceFieldType.MASTER, TResourceFieldType.PS, TResourceFieldType.WORKER].includes( + key as TResourceFieldType, + ) === false + ) { + continue; + } + + const [resource] = copied[key]; + + for (const k in resource) { + const [, resType] = k.split('_'); + let unit = ''; + let value = resource[k]; + + switch (resType as TResourceType) { + case 'cpu': + unit = 'm'; + value = convertCpuCoreToM(value, false); + break; + case 'mem': + unit = 'Gi'; + break; + } + copied[k] = value + unit; + } + delete copied[key]; + } + return copied; +}; + +export const wrapResource = (formValue: Record<string, any>) => { + const classifiedFormValue = classifyResourceWithTemplateType(formValue); + + const tempObj: any = {}; + + roleFieldList.forEach((role) => { + if (!classifiedFormValue[role] || !classifiedFormValue[role][0]) return; + const tempFormValue = classifiedFormValue[role][0] as any; + + Object.keys(tempFormValue).forEach((key) => { + const [role, field] = key.split('_'); + if (resourceFieldList.includes(field)) { + if (!tempObj[role]) { + tempObj[role] = [ + { + [`${role}_roles`]: role, + }, + ]; + } + + if (field === 'cpu') { + // convert cpu unit, m to Core + tempObj[role][0][key] = parseFloat(tempFormValue[key]) / 1000; + } else { + tempObj[role][0][key] = parseInt(tempFormValue[key]); + } + } + }); + }); + + return { ...formValue, ...tempObj }; +}; + +export const resourceTemplateParamsMap = { + [ResourceTemplateType.HIGH]: { + [EnumAlgorithmProjectType.TREE_VERTICAL]: classifyResourceWithTemplateType(RESOURCE_HIGH_TREE), + [EnumAlgorithmProjectType.TREE_HORIZONTAL]: classifyResourceWithTemplateType( + RESOURCE_HIGH_TREE, + ), + [EnumAlgorithmProjectType.NN_VERTICAL]: classifyResourceWithTemplateType(RESOURCE_HIGH_NN), + [EnumAlgorithmProjectType.NN_HORIZONTAL]: classifyResourceWithTemplateType(RESOURCE_HIGH_NN), + }, + [ResourceTemplateType.MEDIUM]: { + [EnumAlgorithmProjectType.TREE_VERTICAL]: classifyResourceWithTemplateType( + RESOURCE_MEDIUM_TREE, + ), + [EnumAlgorithmProjectType.TREE_HORIZONTAL]: classifyResourceWithTemplateType( + RESOURCE_MEDIUM_TREE, + ), + [EnumAlgorithmProjectType.NN_VERTICAL]: classifyResourceWithTemplateType(RESOURCE_MEDIUM_NN), + [EnumAlgorithmProjectType.NN_HORIZONTAL]: classifyResourceWithTemplateType(RESOURCE_MEDIUM_NN), + }, + [ResourceTemplateType.LOW]: { + [EnumAlgorithmProjectType.TREE_VERTICAL]: classifyResourceWithTemplateType(RESOURCE_LOW_TREE), + [EnumAlgorithmProjectType.TREE_HORIZONTAL]: classifyResourceWithTemplateType(RESOURCE_LOW_TREE), + [EnumAlgorithmProjectType.NN_VERTICAL]: classifyResourceWithTemplateType(RESOURCE_LOW_NN), + [EnumAlgorithmProjectType.NN_HORIZONTAL]: classifyResourceWithTemplateType(RESOURCE_LOW_NN), + }, + [ResourceTemplateType.CUSTOM]: { + [EnumAlgorithmProjectType.TREE_VERTICAL]: classifyResourceWithTemplateType(RESOURCE_LOW_TREE), + [EnumAlgorithmProjectType.TREE_HORIZONTAL]: classifyResourceWithTemplateType(RESOURCE_LOW_TREE), + [EnumAlgorithmProjectType.NN_VERTICAL]: classifyResourceWithTemplateType(RESOURCE_CUSTOM_NN), + [EnumAlgorithmProjectType.NN_HORIZONTAL]: classifyResourceWithTemplateType(RESOURCE_CUSTOM_NN), + }, +}; + +export const resourceTemplateTypeOptions = [ + { + value: ResourceTemplateType.HIGH, + label: i18n.t('model_center.label_radio_high'), + }, + { + value: ResourceTemplateType.MEDIUM, + label: i18n.t('model_center.label_radio_medium'), + }, + { + value: ResourceTemplateType.LOW, + label: i18n.t('model_center.label_radio_low'), + }, + { + value: ResourceTemplateType.CUSTOM, + label: i18n.t('model_center.label_radio_custom'), + }, +]; + +type TResourceType = 'cpu' | 'mem' | 'replicas' | undefined; +const getAlgorithmColumns = ( + roleType: TResourceFieldType, + columnTypes: [TResourceType, TResourceType, TResourceType], + disabled = false, + localDisabledList: string[] = [], +): TColumn[] => { + const type = 'INPUT_NUMBER'; + const columnsMap: Record<string, TColumn> = { + cpu: { + type, + dataIndex: `${roleType}_cpu`, + title: i18n.t('cpu'), + unitLabel: 'Core', + placeholder: i18n.t('placeholder_cpu'), + rules: [{ required: true, message: i18n.t('model_center.msg_required') }], + span: 0, + min: 0.1, + tooltip: i18n.t('tip_please_input_positive_number'), + precision: 1, + disabled: disabled || !!localDisabledList.find((item: string) => item === `${roleType}.cpu`), + }, + replicas: { + type, + dataIndex: `${roleType}_replicas`, + title: i18n.t('replicas'), + placeholder: i18n.t('placeholder_input'), + min: 1, + max: 100, + precision: 0, + rules: [ + { required: true, message: i18n.t('model_center.msg_required') }, + { min: 1, type: 'number' }, + { max: 100, type: 'number' }, + ], + span: 0, + tooltip: i18n.t('tip_replicas_range'), + mode: 'button', + disabled: + disabled || !!localDisabledList.find((item: string) => item === `${roleType}.replicas`), + }, + mem: { + type, + dataIndex: `${roleType}_mem`, + title: i18n.t('mem'), + unitLabel: 'Gi', + placeholder: i18n.t('placeholder_mem'), + rules: [{ required: true, message: i18n.t('model_center.msg_required') }], + span: 0, + min: 1, + tooltip: i18n.t('tip_please_input_positive_integer'), + disabled: disabled || !!localDisabledList.find((item: string) => item === `${roleType}.mem`), + }, + }; + + const ret: TColumn[] = [ + { + type: 'TEXT', + dataIndex: `${roleType}_roles`, + title: 'roles', + span: 0, + disabled: true, + }, + ]; + for (const type of columnTypes) { + if (type) { + ret.push(columnsMap[type]); + } + } + + return ret.map((col) => ({ + ...col, + span: Math.floor(24 / ret.length), + })); +}; + +export type MixedAlgorithmType = + | EnumAlgorithmProjectType.TREE_VERTICAL + | EnumAlgorithmProjectType.TREE_HORIZONTAL + | EnumAlgorithmProjectType.NN_VERTICAL + | EnumAlgorithmProjectType.NN_HORIZONTAL + | AlgorithmType.TREE + | AlgorithmType.NN; + +export type Value = { + resource_type: ResourceTemplateType | `${ResourceTemplateType}`; + + master_cpu?: string; + master_mem?: string; + master_replicas?: string; + + ps_cpu?: string; + ps_mem?: string; + ps_replicas?: string; + + worker_cpu?: string; + worker_mem?: string; + worker_replicas?: string; +}; + +export type Props = { + value?: Value; + onChange?: (value: Value) => void; + disabled?: boolean; + localDisabledList?: string[]; + defaultResourceType?: ResourceTemplateType | `${ResourceTemplateType}`; + algorithmType?: MixedAlgorithmType; + collapsedTitle?: string; + isIgnoreFirstRender?: boolean; + isTrustedCenter?: boolean; + collapsedOpen?: boolean; +}; + +const formLayout = { + labelCol: { + span: 0, + }, + wrapperCol: { + span: 24, + }, +}; + +export const ResourceConfig: FC<Props> = ({ + disabled: disabledFromProps = false, + localDisabledList = [], + defaultResourceType = ResourceTemplateType.LOW, + algorithmType = EnumAlgorithmProjectType.TREE_VERTICAL, + collapsedTitle = i18n.t('model_center.title_resource_config_detail'), + isIgnoreFirstRender = true, + isTrustedCenter = false, + collapsedOpen = true, + value, + onChange, +}) => { + const isControlled = typeof value === 'object' && value !== null; + const [form] = Form.useForm(); + + const [collapseActiveKey, setCollapseActiveKey] = useState<string[]>(collapsedOpen ? ['1'] : []); // default open status controlled by 'collapsedOpen' + const [resourceType, setResourceType] = useState(() => { + if (isControlled) { + return value?.resource_type; + } + + return defaultResourceType || ResourceTemplateType.LOW; + }); + const isAlreadyClickResource = useRef(false); + + useEffect(() => { + if (isControlled) { + form.setFieldsValue(wrapResource({ ...value })); + } + }, [form, isControlled, value]); + + useEffect(() => { + if (resourceType && algorithmType) { + const innerValue = { + ...(resourceTemplateParamsMap[resourceType] as any)[algorithmType], + }; + const finaleValue = { + resource_type: resourceType, + ...unwrapResourceFromTemplateType(innerValue), + }; + if (!isControlled) { + form.setFieldsValue(innerValue); + onChange?.(finaleValue as any); + } + if (isControlled) { + // Ignore first render + if (isIgnoreFirstRender && isAlreadyClickResource.current) { + onChange?.(finaleValue as any); + } + if (!isIgnoreFirstRender) { + onChange?.(finaleValue as any); + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form, resourceType, algorithmType, isControlled, isIgnoreFirstRender]); + + const disabled = resourceType !== ResourceTemplateType.CUSTOM || disabledFromProps; + + return ( + <Form + form={form} + {...formLayout} + initialValues={{ + resource_type: resourceType, + }} + onChange={(_, values) => { + const finaleValue = unwrapResourceFromTemplateType(values); + onChange?.(finaleValue as any); + }} + > + <Form.Item field="resource_type"> + <BlockRadio + isCenter={true} + options={resourceTemplateTypeOptions} + disabled={disabledFromProps} + onChange={(value: ResourceTemplateType) => { + isAlreadyClickResource.current = true; + setResourceType(value); + if (value === ResourceTemplateType.CUSTOM) { + setCollapseActiveKey(['1']); + } + }} + /> + </Form.Item> + + <StyledCollapse + activeKey={collapseActiveKey} + expandIconPosition="left" + onChange={onCollapseChange} + lazyload={false} + bordered={false} + > + <Collapse.Item header={collapsedTitle} name="1"> + {algorithmType === EnumAlgorithmProjectType.NN_VERTICAL ? ( + <> + <Form.Item field={TResourceFieldType.WORKER}> + <InputGroup + columns={getAlgorithmColumns( + TResourceFieldType.WORKER, + ['replicas', 'cpu', 'mem'], + disabled, + localDisabledList, + )} + disableAddAndDelete={true} + /> + </Form.Item> + <Form.Item field={TResourceFieldType.MASTER}> + <InputGroup + columns={getAlgorithmColumns( + TResourceFieldType.MASTER, + ['replicas', 'cpu', 'mem'], + disabled, + localDisabledList, + )} + disableAddAndDelete={true} + /> + </Form.Item> + <Form.Item field={TResourceFieldType.PS}> + <InputGroup + columns={getAlgorithmColumns( + TResourceFieldType.PS, + ['replicas', 'cpu', 'mem'], + disabled, + localDisabledList, + )} + disableAddAndDelete={true} + /> + </Form.Item> + </> + ) : ( + <Form.Item field={TResourceFieldType.WORKER}> + <InputGroup + columns={getAlgorithmColumns( + TResourceFieldType.WORKER, + isTrustedCenter ? ['replicas', 'cpu', 'mem'] : [undefined, 'cpu', 'mem'], + disabled, + localDisabledList, + )} + disableAddAndDelete={true} + /> + </Form.Item> + )} + </Collapse.Item> + </StyledCollapse> + </Form> + ); + + function onCollapseChange(key: string, keys: string[]) { + setCollapseActiveKey(keys); + } +}; + +export default ResourceConfig; diff --git a/web_console_v2/client/src/components/ScheduledTaskSetter/index.tsx b/web_console_v2/client/src/components/ScheduledTaskSetter/index.tsx new file mode 100644 index 000000000..634f3427e --- /dev/null +++ b/web_console_v2/client/src/components/ScheduledTaskSetter/index.tsx @@ -0,0 +1,60 @@ +import React, { FC, useEffect } from 'react'; +import styled from 'styled-components'; +import { useToggle } from 'react-use'; +import CronTimePicker, { parseCron, PickerValue, toCron } from 'components/CronTimePicker'; +import { Switch } from '@arco-design/web-react'; +import i18n from 'i18n'; + +const SwitchContainer = styled.div` + margin-bottom: 16px; +`; + +type Props = { + value?: string; + onChange?: (value: string) => void; +}; + +const ScheduleTaskSetter: FC<Props> = (prop: Props) => { + const { value, onChange } = prop; + const isEnabled = !!value; + const [inputVisible, toggleVisible] = useToggle(isEnabled); + + useEffect(() => { + toggleVisible(isEnabled); + }, [isEnabled, toggleVisible]); + + const onSwitchChange = (checked: boolean) => { + toggleVisible(checked); + onValueChange(checked ? 'null' : ''); + }; + const onValueChange = (val: string) => { + onChange && onChange(val); + }; + + return ( + <> + <SwitchContainer> + <Switch checked={inputVisible} onChange={onSwitchChange} /> + </SwitchContainer> + + {inputVisible && ( + <CronTimePicker + value={parseCron(value || '')} + onChange={(value: PickerValue) => { + onValueChange(toCron(value)); + }} + /> + )} + </> + ); +}; +export function scheduleTaskValidator(val: any, cb: (error?: string) => void) { + // !val means switch be set to close status --> validate to pass + // val !== 'null' means has chosen correct time and validate to pass + if (!val || val !== 'null') { + return cb(); + } + return cb(i18n.t('model_center.msg_time_required')); +} + +export default ScheduleTaskSetter; diff --git a/web_console_v2/client/src/components/Sidebar/index.less b/web_console_v2/client/src/components/Sidebar/index.less new file mode 100644 index 000000000..a686961ab --- /dev/null +++ b/web_console_v2/client/src/components/Sidebar/index.less @@ -0,0 +1,46 @@ +.side-bar-container{ + display: flex; + flex-direction: column; + align-items: flex-end; + width: 200px; + padding: 16px 8px 8px; + background-color: white; + box-shadow: 1px 0px 0px var(--lineColor); + overflow-y: auto; + &.isFolded { + width: 48px; + padding: 16px 4px 8px; + align-items: center; + .arco-menu-item { + padding-left: 0 !important; + padding-right: 0; + text-align: center; + .anticon { + margin-right: 0; + } + } + } + &.isHidden { + display: none; + } +} + +.side-bar-menu{ + flex: 1; + user-select: none; +} + +.side-bar-fold-button{ + width: 24px; + height: 24px; + justify-content: center; + align-items: center; + display: inline-flex; + background-color: rgb(var(--gray-1)); + color: rgb(var(--gray-6)); + border-radius: 2px; + cursor: pointer; + &:hover { + background-color: rgb(var(--gray-2)); + } +} diff --git a/web_console_v2/client/src/components/StatisticList/index.tsx b/web_console_v2/client/src/components/StatisticList/index.tsx new file mode 100644 index 000000000..079862cb9 --- /dev/null +++ b/web_console_v2/client/src/components/StatisticList/index.tsx @@ -0,0 +1,152 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo } from 'react'; +import styled from 'styled-components'; + +import { Card, Empty } from '@arco-design/web-react'; +import TitleWithIcon, { Props as TitleWithIconProps } from 'components/TitleWithIcon'; +import { IconQuestionCircle } from '@arco-design/web-react/icon'; +import { CONSTANTS } from 'shared/constants'; +import { formatObjectToArray } from 'shared/helpers'; +import { useModelMetriesResult } from 'hooks/modelCenter'; + +const Container = styled.div` + display: inline-block; +`; + +const Label = styled.div` + font-weight: 500; + font-size: 20px; + color: var(--textColorStrong); +`; + +type NumberItemProps = { + value?: string | number; + className?: string; +} & TitleWithIconProps; + +type ModelEvaluationVariantProps = { + id: ID; + participantId?: ID; + isTraining?: boolean; +}; + +export const NumberItem: FC<NumberItemProps> = ({ + className, + value = CONSTANTS.EMPTY_PLACEHOLDER, + ...props +}) => { + return ( + <Container className={className}> + <TitleWithIcon isShowIcon={Boolean(props.tip)} icon={IconQuestionCircle} {...props} /> + <Label>{value}</Label> + </Container> + ); +}; + +const CardContainer = styled.div` + display: flex; + justify-content: flex-start; + flex-wrap: wrap; +`; +const StyledNumberItem = styled(NumberItem)<{ $cols: number }>` + width: ${(props) => String(100 / props.$cols) + '%'}; +`; + +export type OptionItem = { + /** Display title */ + text: string; + /** Display value */ + value: string | number; + /** Tip */ + tip?: string; +}; + +export type Props = { + /** DataSource */ + data: OptionItem[]; + /** How many cols in one row */ + cols?: number; +}; + +export type SubComponent = { + ModelEvaluation: FC<ModelEvaluationVariantProps>; +}; + +export const StatisticList: FC<Props> & SubComponent = ({ data, cols = 6 }) => { + return ( + <Card> + <CardContainer> + {data.length > 0 ? ( + data.map((item) => ( + <StyledNumberItem + $cols={cols} + key={item.text} + title={item.text} + value={item.value} + tip={item.tip} + isShowIcon={Boolean(item.tip)} + icon={IconQuestionCircle} + /> + )) + ) : ( + <Empty /> + )} + </CardContainer> + </Card> + ); +}; + +const sortingKeys = [ + 'acc', + 'auc', + 'precision', + 'recall', + 'f1', + 'ks', + 'mse', + 'msre', + 'abs', + 'loss', + 'tp', + 'tn', + 'fp', + 'fn', +]; + +const labelKeyMap: Record<string, string> = { + loss: 'LOSS', + f1: 'F1 score', + auc: 'AUC', + acc: 'Accuracy', + precision: 'Precision', + recall: 'Recall', +}; +export const ModelEvaluation: FC<ModelEvaluationVariantProps> = ({ + id, + participantId, + isTraining = true, +}) => { + const { data } = useModelMetriesResult(id, participantId); + + const list = useMemo(() => { + if (!data) { + return []; + } + + return formatObjectToArray(isTraining ? data.train : data.eval, sortingKeys).map( + ({ label, value }) => ({ + text: labelKeyMap[label] ?? label.toUpperCase(), + value: value.values?.length + ? value.values[value.values.length - 1]?.toFixed(3) + : CONSTANTS.EMPTY_PLACEHOLDER, + }), + ); + }, [data, isTraining]); + + return <StatisticList data={list} />; +}; + +StatisticList.ModelEvaluation = ModelEvaluation; + +export default StatisticList; diff --git a/web_console_v2/client/src/components/StatusProgress/index.module.less b/web_console_v2/client/src/components/StatusProgress/index.module.less new file mode 100644 index 000000000..315765fb3 --- /dev/null +++ b/web_console_v2/client/src/components/StatusProgress/index.module.less @@ -0,0 +1,10 @@ +.status_progress { + display: flex; + flex-direction: column; + justify-content: space-around; + cursor: pointer; +} +.status_progress_text{ + line-height: 20px; + margin-bottom: 2px; +} \ No newline at end of file diff --git a/web_console_v2/client/src/components/StatusProgress/index.tsx b/web_console_v2/client/src/components/StatusProgress/index.tsx new file mode 100644 index 000000000..49b325ad2 --- /dev/null +++ b/web_console_v2/client/src/components/StatusProgress/index.tsx @@ -0,0 +1,62 @@ +import React, { FC, useMemo } from 'react'; +import { Progress, Tooltip } from '@arco-design/web-react'; +import styled from './index.module.less'; + +type Option = { + status: string; + text: string; + color: string; + percent: number; +}; +type Props = { + options: Option[]; + status: string; + isTip?: boolean; + toolTipContent?: React.ReactNode; + className?: string; +}; + +const defaultStatus: Option = { + status: 'DEFAULT', + text: '未知', + color: '#165DFF', + percent: 0, +}; + +const StatusProgress: FC<Props> = ({ + options, + status, + isTip = false, + toolTipContent, + className, +}) => { + const currentStatus = useMemo(() => { + return options.find((option) => option.status === status) || defaultStatus; + }, [status, options]); + if (isTip) { + return ( + <Tooltip content={toolTipContent}> + <div className={`${styled.status_progress} ${className}`}> + <span className={styled.status_progress_text}>{currentStatus.text}</span> + <Progress + color={currentStatus?.color} + percent={currentStatus?.percent || 0} + showText={false} + /> + </div> + </Tooltip> + ); + } + return ( + <div className={`${styled.status_progress} ${className}`}> + <span className={styled.status_progress_text}>{currentStatus.text}</span> + <Progress + color={currentStatus?.color} + percent={currentStatus?.percent || 0} + showText={false} + /> + </div> + ); +}; + +export default StatusProgress; diff --git a/web_console_v2/client/src/components/Table/index.tsx b/web_console_v2/client/src/components/Table/index.tsx new file mode 100644 index 000000000..38a79e765 --- /dev/null +++ b/web_console_v2/client/src/components/Table/index.tsx @@ -0,0 +1,67 @@ +/* istanbul ignore file */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Table, TableProps } from '@arco-design/web-react'; + +import { useUrlState } from 'hooks'; + +export interface Props<RecordType = any> extends TableProps<RecordType> { + total?: number; + onShowSizeChange?: (current: number, size: number) => void; + onPageChange?: (page: number, pageSize: number) => void; + isShowTotal?: boolean; +} + +function MyTable<RecordType = any>({ + isShowTotal = true, + className, + ...restProps +}: Props<RecordType>) { + const { t } = useTranslation(); + + const [paginationParam, setPaginationParam] = useUrlState({ + page: 1, + pageSize: 10, + }); + + return ( + <Table + className={`custom-table custom-table-left-side-filter ${className}`} + pagination={{ + showSizeChanger: true, + onPageSizeChange: onShowSizeChange, + onChange: onPageChange, + showTotal: isShowTotal + ? (total) => + t('hint_total_table', { + total: total || 0, + }) + : undefined, + current: Number(paginationParam.page), + pageSize: Number(paginationParam.pageSize), + }} + {...(restProps as any)} + /> + ); + + function onShowSizeChange(size: number, current: number) { + restProps.onShowSizeChange && restProps.onShowSizeChange(current, size); + + setPaginationParam({ + page: current, + pageSize: size, + }); + } + function onPageChange(page: number, pageSize: number) { + restProps.onPageChange && restProps.onPageChange(page, pageSize); + + setPaginationParam({ + page: page, + pageSize: pageSize, + }); + } +} + +export { MyTable as Table }; +export default MyTable; diff --git a/web_console_v2/client/src/components/TitleWithIcon/index.tsx b/web_console_v2/client/src/components/TitleWithIcon/index.tsx new file mode 100644 index 000000000..28f01f196 --- /dev/null +++ b/web_console_v2/client/src/components/TitleWithIcon/index.tsx @@ -0,0 +1,103 @@ +/* istanbul ignore file */ +import React, { FC, CSSProperties } from 'react'; +import styled from 'styled-components'; +import { Tooltip, Space } from '@arco-design/web-react'; +import { InfoCircleFill } from 'components/IconPark'; + +const Container = styled.div<{ + $isBlock?: boolean; +}>` + display: ${(props) => (props.$isBlock ? 'block' : 'inline-block')}; +`; +const Label = styled.span` + display: inline-block; + font-size: 12px; + color: var(--color, --textColor); +`; + +const IconContainer = styled.span` + display: inline-block; +`; +const LeftIconContainer = styled.span` + display: inline-block; + margin-right: 5px; +`; + +const DefaultIcon = styled(InfoCircleFill)` + color: #86909c; +`; + +export type Props = { + className?: string; + /** Display title */ + title: string | React.ReactNode; + /** Custom icon */ + icon?: any; + /** Tooptip tip */ + tip?: string; + /** Icon on the left of title */ + isLeftIcon?: boolean; + isShowIcon?: boolean; + textColor?: string; + /** Is container display: block, otherwise inline-block */ + isBlock?: boolean; +}; + +const TitleWithIcon: FC<Props> = ({ + className, + title, + tip, + icon = DefaultIcon, + isLeftIcon = false, + isShowIcon = false, + isBlock = true, + textColor, +}) => { + if (isLeftIcon) { + return ( + <Container + className={className} + $isBlock={isBlock} + style={ + textColor + ? ({ + '--color': textColor, + } as CSSProperties) + : {} + } + > + {isShowIcon && ( + <LeftIconContainer> + <Tooltip content={tip}>{React.createElement(icon)}</Tooltip> + </LeftIconContainer> + )} + <Label>{title}</Label> + </Container> + ); + } + + return ( + <Container + className={className} + $isBlock={isBlock} + style={ + textColor + ? ({ + '--color': textColor, + } as CSSProperties) + : {} + } + > + <Space> + <Label>{title}</Label> + {isShowIcon && ( + <IconContainer> + <Tooltip content={tip}>{React.createElement(icon)}</Tooltip> + </IconContainer> + )} + </Space> + </Container> + ); +}; + +export default TitleWithIcon; diff --git a/web_console_v2/client/src/components/TodoListContainer/index.tsx b/web_console_v2/client/src/components/TodoListContainer/index.tsx new file mode 100644 index 000000000..f164a8694 --- /dev/null +++ b/web_console_v2/client/src/components/TodoListContainer/index.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Button, Popover } from '@arco-design/web-react'; + +type TProps = { + btnText: string; + disabled: boolean; + loading?: boolean; + children: React.ReactNode; + containerStyle?: React.CSSProperties; +}; + +const TodoButton = styled(Button)<{ disabled: boolean }>` + background-color: ${(props) => + props.disabled ? 'var(--color-secondary)' : 'rgb(var(--arcoblue-1))'} !important; + color: ${(props) => + props.disabled ? 'var(--color-text-3)' : 'rgb(var(--arcoblue-6))'} !important; + z-index: 2; + + &[disabled] { + cursor: not-allowed; + } +`; + +const Icon: React.FC<{ className?: string }> = ({ className }) => ( + <span className={className}> + <svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"> + <path + clipRule="evenodd" + d="m6.33301 1.93342c0-.1841-.14924-.33334-.33334-.33334h-.8372c-.1841 0-.33334.14924-.33334.33334l-.00033.66666h-1.82913c-.36819 0-.66666.31506-.66666.70371v9.25921c0 .3887.29847.7038.66666.7038h10.00003c.3682 0 .6666-.3151.6666-.7038v-9.25921c0-.38865-.2984-.70371-.6666-.70371l-1.8226-.0001v-.66667c0-.1841-.1492-.33333-.3333-.33333l-.84413.0001c-.18409 0-.33333.14924-.33333.33334v.66666h-3.33333zm3.54908 3.92573c.13001-.12997.34071-.12997.47071 0l.4706.47067c.13.12997.13.3407 0 .47067l-2.82176 2.82176-.00222.00225-.47067.4707c-.04998.05-.11192.0807-.17661.0923l-.03907.0046h-.0393c-.07851-.0046-.1557-.0369-.21568-.0969l-1.88267-1.88271c-.12998-.12997-.12998-.34069 0-.47067l.47066-.47066c.12997-.12998.3407-.12998.47067 0l1.17672 1.17664z" + fill="currentColor" + fillRule="evenodd" + /> + </svg> + </span> +); + +const StyledIcon = styled(Icon)` + position: relative; + display: inline-block; + top: 1px; + margin-right: 10px; +`; + +const TodoListContainer: React.FC<TProps> = ({ + btnText, + loading, + disabled, + children, + containerStyle, +}) => { + const shouldDisabled = disabled && !loading; + const btn = ( + <TodoButton loading={loading} disabled={shouldDisabled} style={containerStyle}> + {!loading ? <StyledIcon /> : null} + {btnText} + </TodoButton> + ); + if (shouldDisabled) { + return btn; + } + + return ( + <Popover + getPopupContainer={() => window.document.body} + content={children} + position="br" + style={{ + maxWidth: 1000, + }} + > + {btn} + </Popover> + ); +}; + +export default TodoListContainer; diff --git a/web_console_v2/client/src/components/TodoPopover/index.module.less b/web_console_v2/client/src/components/TodoPopover/index.module.less new file mode 100644 index 000000000..fcb04f5ad --- /dev/null +++ b/web_console_v2/client/src/components/TodoPopover/index.module.less @@ -0,0 +1,70 @@ +@import '~styles/mixins.less'; +.todo_button{ + z-index: 2; + background-color: rgb(var(--arcoblue-1)) !important; + color: var(--primaryColor) !important; + .icon_todo{ + position: relative; + margin-right: 10px; + font-size: 18px; + } + &[disabled] { + cursor: not-allowed; + } +} +.overlay_header{ + padding: 10px 16px; + border-bottom: 1px solid #e5e8ee; +} +.overlay_item_header{ + display: flex; + padding-right: 16px; +} +.number_tag{ + display: inline-block; + width: 18px; + height: 18px; + background: #f6f7fb; + border-radius: 40px; + font-size: 12px; + color: var(--textColorSecondary); + font-weight: 600; + text-align: center; + line-height: 18px; +} +.overlay_item{ + position: relative; + width: 100%; + padding: 7px 16px; + border-bottom: 1px solid #e5e8ee; + cursor: pointer; + &:hover { + background-color: #f6f7fb; + } +} +.label{ + display: inline-block; + margin-right: 4px; + font-size: 12px; + color: var(--textColorSecondary); + font-weight: 500; +} +.label_tiny{ + font-size: 11px; + color: var(--textColorSecondary); +} +.label_strong{ + .MixinEllipsis(); + flex:1; + font-size: 12px; + color: var(--textColorStrong); + font-weight: 500; +} +.icon_right{ + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; +} + diff --git a/web_console_v2/client/src/components/TodoPopover/index.tsx b/web_console_v2/client/src/components/TodoPopover/index.tsx new file mode 100644 index 000000000..8095fc521 --- /dev/null +++ b/web_console_v2/client/src/components/TodoPopover/index.tsx @@ -0,0 +1,534 @@ +/* istanbul ignore file */ + +import React, { FC, ReactNode, useMemo } from 'react'; +import { generatePath, useHistory } from 'react-router-dom'; + +import { formatTimestamp } from 'shared/date'; +import { TIME_INTERVAL } from 'shared/constants'; + +import { useQuery } from 'react-query'; +import { fetchProjectPendingList } from 'services/algorithm'; +import { fetchModelServingList_new } from 'services/modelServing'; +import { + fetchModelJobGroupList, + fetchModelJobList_new, + fetchPeerModelJobGroupDetail, +} from 'services/modelCenter'; +import { fetchPendingProjectList } from 'services/project'; + +import { Message, Button, Popover, Tooltip } from '@arco-design/web-react'; +import { Todo, Right } from 'components/IconPark'; + +import { Workflow } from 'typings/workflow'; +import { ModelServing, ModelServingState } from 'typings/modelServing'; +import { ModelJob, ModelJobGroup, ModelJobState } from 'typings/modelCenter'; +import { Algorithm } from 'typings/algorithm'; + +import newModelCenterRoutes, { ModelEvaluationModuleType } from 'views/ModelCenter/routes'; +import { getCoordinateName, PENDING_PROJECT_FILTER_MAPPER } from 'views/Projects/shard'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { + useGetAppFlagValue, + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, +} from 'hooks'; +import { APIResponse } from 'typings/app'; +import { NotificationItem, NotificationType } from 'typings/trustedCenter'; +import { fetchTrustedNotifications } from 'services/trustedCenter'; +import { FilterOp } from 'typings/filter'; +import { Project, ProjectStateType } from 'typings/project'; +import { constructExpressionTree } from 'shared/filter'; +import { FlagKey } from 'typings/flag'; + +import styles from './index.module.less'; + +const CUSTOM_CLASS_NAME = 'custom-popover'; + +export type Props<T = any> = { + isLoading?: boolean; + disabled?: boolean; + list: T[]; + buttonText?: string; + renderContent: (list: T[], options?: any) => ReactNode; +}; + +export type ApprovalProps<T = any> = Omit<Props<T>, 'renderContent'> & { + title?: string; + onClick?: (item?: T) => void; + dateField?: string; + creatorField?: string; + contentField?: string; + contentVerb?: string; + contentSuffix?: string; + contentPrefix?: string; + renderContent?: (list: T[], options?: any) => ReactNode; +}; + +export type TrainModelProps<T = any> = Omit<ApprovalProps<T>, 'list'> & {}; + +export type EvaluationModelNewProps<T = any> = TrainModelProps<T> & { + module: ModelEvaluationModuleType; +}; + +export type TrustedCenterProps<T = any> = Omit<ApprovalProps<T>, 'list'> & {}; + +function TodoPopover<T = any>({ + isLoading = false, + disabled = false, + list = [], + buttonText = '', + renderContent, + ...restProps +}: Props<T>) { + return ( + <> + <Popover className={CUSTOM_CLASS_NAME} content={renderContent(list, restProps)} position="br"> + <Button + className={styles.todo_button} + loading={isLoading} + icon={<Todo className={styles.icon_todo} />} + disabled={disabled || list.length === 0} + type={list.length ? 'primary' : 'default'} + > + {buttonText} + </Button> + </Popover> + </> + ); +} + +const RenderItem: FC<{ item: any; options?: any }> = ({ item, options }) => { + const coordinatorName = getCoordinateName(item?.participants_info?.participants_map); + const participantList = useGetCurrentProjectParticipantList(); + const participant = participantList.filter((item_) => item_?.id === item.coordinator_id); + + return ( + <div + className={styles.overlay_item} + onClick={(e) => { + e.stopPropagation(); + options.onClick(item); + }} + > + <div className={styles.overlay_item_header}> + <span className={styles.label}> + {coordinatorName || participant?.[0]?.name || participantList?.[0]?.name} + {options.contentVerb ?? ' 发起了'} + </span> + <Tooltip content={`「${item[options.contentField]}」${options.contentSuffix || ''}`}> + <span className={styles.label_strong}> + {`「${item[options.contentField]}」`} + {options.contentSuffix || ''} + </span> + </Tooltip> + </div> + <div> + <span className={styles.label_tiny}>{formatTimestamp(item[options.dateField])}</span> + </div> + <Right className={styles.icon_right} /> + </div> + ); +}; + +function renderDefaultModelCenterContent(list: any, options?: any) { + return ( + <div> + <div className={styles.overlay_header}> + <span className={styles.label}>{options.title}</span> + <div className={styles.number_tag}>{list.length}</div> + </div> + {list.map((item: any) => ( + <RenderItem item={item} options={options} key={item.id} /> + ))} + </div> + ); +} + +function ModelCenter({ + renderContent = renderDefaultModelCenterContent, + ...restProps +}: ApprovalProps) { + return <TodoPopover renderContent={renderContent} {...restProps} />; +} + +function EvaluationModelNew({ + dateField = 'created_at', + contentField = 'name', + module, + ...restProps +}: EvaluationModelNewProps) { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + const isPrediction = module === 'offline-prediction'; + const copywriting = isPrediction ? '预测' : '评估'; + + const { isError, data: workflowListData, error, isFetching } = useQuery( + ['todoPopover_model_evaluation_notice_list', projectId, module], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return Promise.resolve({ data: [] as ModelJob[] }); + } + + return fetchModelJobList_new(projectId, { + states: [ModelJobState.PENDING_ACCEPT], + types: isPrediction ? 'PREDICTION' : 'EVALUATION', + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + }, + ); + + if (isError && error) { + Message.error((error as Error).message); + } + + return ( + <ModelCenter + isLoading={isFetching} + list={workflowListData?.data || []} + dateField={dateField} + contentField={contentField} + buttonText={`${workflowListData?.data.length || 0} 条待处理${copywriting}任务`} + title={`待处理${copywriting}任务`} + contentSuffix={`的${copywriting}任务`} + onClick={onClick} + {...restProps} + /> + ); + + function onClick(item: Workflow) { + history.push(`/model-center/${module}/receiver/edit/${item.id}`); + } +} + +function AlgorithmManagement({ + dateField = 'created_at', + creatorField = 'creator', + contentField = 'name', + ...restProps +}: TrainModelProps) { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + + const { isError, data: algorithmData, error, isFetching } = useQuery( + ['algorithmReceiveList', projectId], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return Promise.resolve({ data: [] }) as APIResponse<Algorithm[]>; + } + return fetchProjectPendingList(projectId ?? 0); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + }, + ); + + if (isError && error) { + Message.error((error as Error).message); + } + + const todoList = algorithmData?.data || []; + return ( + <ModelCenter + isLoading={isFetching} + list={todoList} + dateField={dateField} + contentField={contentField} + buttonText={`${todoList.length} 条待处理算法消息`} + title={'待处理算法任务'} + contentVerb={'发布了'} + contentSuffix={' 算法'} + onClick={onClick} + {...restProps} + /> + ); + + function onClick(item: Algorithm) { + history.push(`/algorithm-management/acceptance/${item.id}`); + } +} + +function ModelServingNotice({ + dateField = 'created_at', + creatorField = 'creator', + contentField = 'name', + ...restProps +}: TrainModelProps) { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + + const { isError, isFetching, data, error } = useQuery( + ['fetchModelServingList', projectId], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return Promise.resolve({ data: [] }) as APIResponse<ModelServing[]>; + } + + return fetchModelServingList_new(projectId); + }, + + { + refetchInterval: TIME_INTERVAL.LIST, + }, + ); + + if (isError && error) { + Message.error((error as Error).message); + } + + const todoList = useMemo(() => { + if (!data?.data) { + return []; + } + + return data.data.filter((item) => item.status === ModelServingState.WAITING_CONFIG); + }, [data?.data]); + + return ( + <ModelCenter + isLoading={isFetching} + list={todoList} + dateField={dateField} + contentField={contentField} + buttonText={`${todoList.length} 条待处理在线服务`} + title={'待处理在线服务'} + contentSuffix={' 的在线任务'} + onClick={onClick} + {...restProps} + /> + ); + + function onClick(item: ModelServing) { + history.push(`/model-serving/create/receiver/${item.id}`); + } +} + +function NewTrainModel({ + dateField = 'created_at', + contentField = 'name', + ...restProps +}: TrainModelProps) { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + + const model_job_global_config_enabled = useGetAppFlagValue( + FlagKey.MODEL_JOB_GLOBAL_CONFIG_ENABLED, + ); + + const { isError, data, error, isFetching } = useQuery<{ + data: ModelJobGroup[]; + }>( + ['fetchModelJobGroupList', projectId], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return Promise.resolve({ data: [] }); + } + return fetchModelJobGroupList(projectId!, { + filter: constructExpressionTree([ + { + field: 'configured', + op: FilterOp.EQUAL, + bool_value: false, + }, + ]), + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + }, + ); + + if (isError && error) { + Message.error((error as Error).message); + } + + const todoList = useMemo(() => { + if (!data) { + return []; + } + const list = data.data || []; + + return list; + }, [data]); + + return ( + <ModelCenter + isLoading={isFetching} + list={todoList} + dateField={dateField} + contentField={contentField} + buttonText={`${todoList.length} 条待处理模型训练`} + title={'待处理模型训练'} + contentSuffix={'的模型训练'} + onClick={onClick} + {...restProps} + /> + ); + + async function onClick(item: ModelJobGroup) { + let isOldModelGroup = true; + if (model_job_global_config_enabled) { + try { + const res = await fetchPeerModelJobGroupDetail(projectId!, item.id, item.coordinator_id!); + const modelGroupDetail = res.data; + isOldModelGroup = Boolean(modelGroupDetail?.config?.job_definitions?.length); + } catch (error) { + Message.error('获取模型训练作业详情失败!'); + return; + } + } + model_job_global_config_enabled && !isOldModelGroup + ? history.push( + generatePath(newModelCenterRoutes.ModelTrainCreateCentralization, { + role: 'receiver', + id: item.id, + }), + ) + : history.push( + generatePath(newModelCenterRoutes.ModelTrainCreate, { + role: 'receiver', + action: 'create', + id: item.id, + }), + ); + } +} + +function TrustedCenter({ + dateField = 'created_at', + creatorField = 'coordinator_id', + contentField = 'name', + ...restProps +}: TrustedCenterProps) { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + + const { isError, data, error, isFetching } = useQuery<{ data: NotificationItem[] }>( + ['fetchTrustedNotifications', projectId], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return Promise.resolve({ data: [] }); + } + return fetchTrustedNotifications(projectId!); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + }, + ); + + if (isError && error) { + Message.error((error as Error).message); + } + + const todoList = useMemo(() => { + if (!data) { + return []; + } + + const list = data.data || []; + + return list; + }, [data]); + + return ( + <ModelCenter + isLoading={isFetching} + list={todoList} + dateField={dateField} + contentField={contentField} + buttonText={`待处理任务 ${todoList.length}`} + title="待处理任务" + contentVerb="发起了" + contentSuffix="的任务" + onClick={onClick} + {...restProps} + /> + ); + + function onClick(item: NotificationItem) { + switch (item.type) { + case NotificationType.TRUSTED_JOB_GROUP_CREATE: + history.push(`/trusted-center/edit/${item.id}/receiver`); + break; + case NotificationType.TRUSTED_JOB_EXPORT: + history.push( + `/trusted-center/dataset-application/${item.id}/${item.coordinator_id}/${item.name}`, + ); + break; + default: + break; + } + } +} +function ProjectNotice({ + dateField = 'created_at', + contentField = 'name', + ...restProps +}: TrainModelProps) { + const history = useHistory(); + + const { isFetching, data: todoPendingProjectList } = useQuery( + ['fetchTodoPendingProjectList'], + () => + fetchPendingProjectList({ + filter: filterExpressionGenerator( + { + state: [ProjectStateType.PENDING], + }, + PENDING_PROJECT_FILTER_MAPPER, + ), + page: 1, + page_size: 0, + }), + + { + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + retry: 2, + onError: (error) => { + Message.error((error as Error).message); + }, + }, + ); + + const todoList = useMemo(() => { + return todoPendingProjectList?.data ?? []; + }, [todoPendingProjectList?.data]); + + return ( + <ModelCenter + isLoading={isFetching} + list={todoList} + dateField={dateField} + contentField={contentField} + buttonText={`${todoList.length}条待处理工作区`} + title="待处理工作区邀请" + contentSuffix="的工作区邀请" + onClick={onClick} + {...restProps} + /> + ); + + function onClick(item: Project) { + history.push(`/projects/receiver/${item.id}`); + } +} + +TodoPopover.ModelCenter = ModelCenter; +TodoPopover.NewTrainModel = NewTrainModel; +TodoPopover.EvaluationModelNew = EvaluationModelNew; +TodoPopover.AlgorithmManagement = AlgorithmManagement; +TodoPopover.ModelServing = ModelServingNotice; +TodoPopover.TrustedCenter = TrustedCenter; +TodoPopover.ProjectNotice = ProjectNotice; + +export default TodoPopover; diff --git a/web_console_v2/client/src/components/VariableLabel/index.module.less b/web_console_v2/client/src/components/VariableLabel/index.module.less new file mode 100644 index 000000000..e37df3911 --- /dev/null +++ b/web_console_v2/client/src/components/VariableLabel/index.module.less @@ -0,0 +1,6 @@ +.label_text{ + font-size: 13px; + line-height: 22px; + max-width: 125px; + overflow: hidden; +} diff --git a/web_console_v2/client/src/components/WhichAlgorithm/index.tsx b/web_console_v2/client/src/components/WhichAlgorithm/index.tsx new file mode 100644 index 000000000..aca3c8764 --- /dev/null +++ b/web_console_v2/client/src/components/WhichAlgorithm/index.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; +import { useQuery } from 'react-query'; +import { Spin } from '@arco-design/web-react'; +import { fetchPeerAlgorithmDetail, getAlgorithmDetail } from 'services/algorithm'; +import { Algorithm } from 'typings/algorithm'; +import CONSTANTS from 'shared/constants'; +import { useGetCurrentProjectId } from 'hooks'; + +type Props = { + id: ID; + /** Format display text */ + formatter?: (algorithm: Algorithm) => string; + uuid?: ID; + participantId?: ID; +}; + +function defaultFormatter(algorithm: Algorithm) { + return `${algorithm.name} (V${algorithm.version})`; +} + +const WhichAlgorithm: React.FC<Props> = ({ + id, + uuid, + formatter = defaultFormatter, + participantId, +}) => { + const projectId = useGetCurrentProjectId(); + const algorithmDetailQuery = useQuery(['getAlgorithmDetail', id], () => getAlgorithmDetail(id!), { + enabled: (Boolean(id) || id === 0) && !Boolean(participantId), + retry: 2, + }); + const peerAlgorithmDetailQuery = useQuery( + ['getPeerAlgorithmDetailQuery', projectId, participantId, uuid], + () => fetchPeerAlgorithmDetail(projectId, participantId, uuid), + { + enabled: + (id === null || participantId !== 0) && + Boolean(uuid) && + (Boolean(projectId) || projectId === 0) && + Boolean(participantId), + retry: 2, + }, + ); + const algorithmDetail = useMemo(() => { + return id === null || participantId !== 0 + ? peerAlgorithmDetailQuery.data?.data + : algorithmDetailQuery.data?.data; + }, [peerAlgorithmDetailQuery, algorithmDetailQuery, id, participantId]); + + if (algorithmDetailQuery.isFetching || peerAlgorithmDetailQuery.isFetching) { + return <Spin />; + } + + return <span>{algorithmDetail ? formatter(algorithmDetail) : CONSTANTS.EMPTY_PLACEHOLDER}</span>; +}; + +export default WhichAlgorithm; diff --git a/web_console_v2/client/src/components/WhichDataset/index.tsx b/web_console_v2/client/src/components/WhichDataset/index.tsx new file mode 100644 index 000000000..035b0e4f0 --- /dev/null +++ b/web_console_v2/client/src/components/WhichDataset/index.tsx @@ -0,0 +1,126 @@ +/* istanbul ignore file */ +import React, { FC } from 'react'; + +import { formatIntersectionDatasetName } from 'shared/modelCenter'; + +import { useRecoilQuery } from 'hooks/recoil'; +import { intersectionDatasetListQuery } from 'stores/dataset'; +import { CONSTANTS } from 'shared/constants'; + +import { Spin } from '@arco-design/web-react'; +import { Dataset, IntersectionDataset } from 'typings/dataset'; +import { useQuery } from 'react-query'; +import { fetchDatasetDetail, fetchDatasetList } from 'services/dataset'; +import { FILTER_OPERATOR_MAPPER, filterExpressionGenerator } from 'views/Datasets/shared'; + +type Props = { + id?: ID; + loading?: boolean; +}; + +const WhichDataset: FC<Props> & { + UUID: FC<UUIDProps>; + IntersectionDataset: FC<Props>; + DatasetDetail: FC<Props>; +} = ({ id, loading }) => { + const datasetListQuery = useQuery( + ['fetchDatasetList', 'WhichDataset', id], + () => { + return fetchDatasetList().then((res) => { + return (res?.data || []).filter((item) => String(item.id) === String(id)); + }); + }, + { + enabled: Boolean(id), + refetchOnWindowFocus: false, + retry: 2, + }, + ); + + if (loading || datasetListQuery.isFetching) { + return <Spin />; + } + + return <span>{datasetListQuery.data?.[0]?.name ?? CONSTANTS.EMPTY_PLACEHOLDER}</span>; +}; + +// TODO: add mode props,for search raw dataset or intersection dataset +const _IntersectionDataset: FC<Props> = ({ id, loading }) => { + const { isLoading, data } = useRecoilQuery(intersectionDatasetListQuery); + + if (loading || isLoading) { + return <Spin />; + } + + const item = + data?.find((innerItem: any) => Number(innerItem.id) === Number(id)) || + ({ + name: CONSTANTS.EMPTY_PLACEHOLDER, + } as IntersectionDataset); + + return <span>{formatIntersectionDatasetName(item)}</span>; +}; + +type UUIDProps = { + uuid: string; + loading?: boolean; + onAPISuccess?: (data?: Dataset) => void; + displayKey?: Partial<keyof Dataset>; +}; + +const _UUID: FC<UUIDProps> = ({ uuid, loading, onAPISuccess, displayKey = 'name' }) => { + const datasetListQuery = useQuery( + ['fetchDatasetList', uuid], + () => + fetchDatasetList({ + filter: filterExpressionGenerator( + { + uuid, + }, + FILTER_OPERATOR_MAPPER, + ), + }), + { + enabled: Boolean(uuid), + refetchOnWindowFocus: false, + retry: 2, + onSuccess(res) { + onAPISuccess?.(res.data?.[0] ?? undefined); + }, + }, + ); + + if (loading || datasetListQuery.isFetching) { + return <Spin />; + } + + return <span>{datasetListQuery.data?.data?.[0]?.[displayKey] ?? '数据集已删除'}</span>; +}; + +const _DatasetDetail: FC<Props> = ({ id, loading }) => { + const datasetDetailQuery = useQuery( + ['fetchDatasetDetail', 'WhichDataset', id], + () => { + return fetchDatasetDetail(id).then((res) => { + return res.data; + }); + }, + { + enabled: Boolean(id), + refetchOnWindowFocus: false, + retry: 2, + }, + ); + + if (loading || datasetDetailQuery.isFetching) { + return <Spin />; + } + + return <div>{datasetDetailQuery.data?.name ?? CONSTANTS.EMPTY_PLACEHOLDER}</div>; +}; + +WhichDataset.UUID = _UUID; +WhichDataset.IntersectionDataset = _IntersectionDataset; +WhichDataset.DatasetDetail = _DatasetDetail; + +export default WhichDataset; diff --git a/web_console_v2/client/src/components/WhichModel/index.tsx b/web_console_v2/client/src/components/WhichModel/index.tsx new file mode 100644 index 000000000..078ad955d --- /dev/null +++ b/web_console_v2/client/src/components/WhichModel/index.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { useQuery } from 'react-query'; +import { Model } from 'typings/modelCenter'; +import { fetchModelDetail_new } from 'services/modelCenter'; +import { useGetCurrentProjectId } from 'hooks'; +import { Spin, Space, Tag } from '@arco-design/web-react'; +import CONSTANTS from 'shared/constants'; + +type Props = { + id: ID; + /** Fortmat display rext */ + formatter?: (model: Model) => string; + isModelGroup?: Boolean; +}; + +function defaultFormatter(model: Model) { + return model.name; +} + +const WhichModel: FC<Props> = ({ id, formatter = defaultFormatter, isModelGroup = false }) => { + const projectId = useGetCurrentProjectId(); + const modelQuery = useQuery( + [`/v2/projects/${projectId}/models/${id}`], + () => { + return fetchModelDetail_new(projectId!, id).then((res) => res.data); + }, + { + enabled: Boolean(projectId) && (Boolean(id) || id === 0), + retry: 2, + }, + ); + + if (modelQuery.isFetching) { + return <Spin />; + } + + return ( + <Space align="start"> + {modelQuery.data ? formatter(modelQuery.data) : CONSTANTS.EMPTY_PLACEHOLDER} + {isModelGroup ? ( + <Tag size="small" color="blue"> + 自动更新 + </Tag> + ) : null} + </Space> + ); +}; + +export default WhichModel; diff --git a/web_console_v2/client/src/components/WhichParticipant/index.tsx b/web_console_v2/client/src/components/WhichParticipant/index.tsx new file mode 100644 index 000000000..51bc832c2 --- /dev/null +++ b/web_console_v2/client/src/components/WhichParticipant/index.tsx @@ -0,0 +1,29 @@ +/* istanbul ignore file */ +import React, { FC } from 'react'; + +import { useRecoilQuery } from 'hooks/recoil'; +import { participantListQuery } from 'stores/participant'; +import { CONSTANTS } from 'shared/constants'; + +import { Spin } from '@arco-design/web-react'; + +type Props = { + id?: ID; + loading?: boolean; +}; + +const WhichParticipant: FC<Props> = ({ id, loading }) => { + const { isLoading, data } = useRecoilQuery(participantListQuery); + + if (loading || isLoading) { + return <Spin />; + } + + const participant = data?.find((item) => Number(item.id) === Number(id)) || { + name: CONSTANTS.EMPTY_PLACEHOLDER, + }; + + return <span>{participant.name}</span>; +}; + +export default WhichParticipant; diff --git a/web_console_v2/client/src/components/WhichParticipantDataset/index.tsx b/web_console_v2/client/src/components/WhichParticipantDataset/index.tsx new file mode 100644 index 000000000..439a8a99e --- /dev/null +++ b/web_console_v2/client/src/components/WhichParticipantDataset/index.tsx @@ -0,0 +1,51 @@ +/* istanbul ignore file */ +import React, { FC } from 'react'; +import { useQuery } from 'react-query'; + +import { fetchParticipantDatasetList } from 'services/dataset'; +import { useGetCurrentProjectId } from 'hooks'; +import { CONSTANTS } from 'shared/constants'; + +import { Spin } from '@arco-design/web-react'; +import { ParticipantDataset } from 'typings/dataset'; + +type UUIDProps = { + uuid: string; + loading?: boolean; + /** It's useful to get participantDataset data */ + onAPISuccess?: (data?: ParticipantDataset) => void; + emptyText?: React.ReactNode; +}; + +const WhichParticipantDataset: FC<UUIDProps> = ({ + uuid, + loading, + emptyText = CONSTANTS.EMPTY_PLACEHOLDER, + onAPISuccess, +}) => { + const projectId = useGetCurrentProjectId(); + + const listQuery = useQuery( + ['fetchParticipantDatasetList', 'WhichParticipantDataset', uuid, projectId], + () => + fetchParticipantDatasetList(projectId!, { + uuid, + }), + { + enabled: Boolean(projectId) && Boolean(uuid), + refetchOnWindowFocus: false, + retry: 2, + onSuccess(res) { + onAPISuccess?.(res.data?.[0] ?? undefined); + }, + }, + ); + + if (loading || listQuery.isFetching) { + return <Spin />; + } + + return <span>{listQuery.data?.data?.[0]?.name ?? emptyText}</span>; +}; + +export default WhichParticipantDataset; diff --git a/web_console_v2/client/src/components/YAMLTemplateEditorButton/index.module.less b/web_console_v2/client/src/components/YAMLTemplateEditorButton/index.module.less new file mode 100644 index 000000000..4430a863d --- /dev/null +++ b/web_console_v2/client/src/components/YAMLTemplateEditorButton/index.module.less @@ -0,0 +1,9 @@ +.drawer_container{ + :global { + .arco-drawer-content{ + padding: 10px 0 0; + height: 100%; + background-color: #1e1e1e; + } + } +} diff --git a/web_console_v2/client/src/components/_base/BlockRadio/__snapshots__/index.test.tsx.snap b/web_console_v2/client/src/components/_base/BlockRadio/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000..9765e6143 --- /dev/null +++ b/web_console_v2/client/src/components/_base/BlockRadio/__snapshots__/index.test.tsx.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`<BlockRadio /> default props layout 1`] = ` +.c2 { + display: grid; + grid-gap: 12px; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: start; + -ms-flex-pack: start; + justify-content: start; + grid-auto-columns: auto; + grid-template-rows: auto; + grid-auto-flow: column; +} + +.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-align-items: stretch; + -webkit-box-align: stretch; + -ms-flex-align: stretch; + align-items: stretch; + margin-right: -77px; + margin-bottom: -77px; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; +} + +.c1 { + --border-color: transparent; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 1 0 auto; + -ms-flex: 1 0 auto; + flex: 1 0 auto; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-flex: var(--flex-grow,1); + -webkit-flex-grow: var(--flex-grow,1); + -ms-flex-positive: var(--flex-grow,1); + flex-grow: var(--flex-grow,1); + min-height: 32px; + padding: 0 12px; + width: auto; + margin-right: 77px; + margin-bottom: 77px; + border: 1.5px solid var(--border-color,var(--lineColor)); + border-radius: 4px; + cursor: pointer; + background-color: var(--bg-color,var(--componentBackgroundColorGray)); +} + +.c1:hover { + --label-color: var(--primaryColor); +} + +.c1[data-is-active='true'] { + --label-color: var(--primaryColor); + --border-color: var(--primaryColor); + --label-weight: 500; + --bg-color: #fff; +} + +.c1[data-is-disabled='true'] { + cursor: not-allowed; + --label-color: var(--textColorDisabled); +} + +.c1[data-is-active='true'][data-is-disabled='true'] { + --label-color: initial; + --border-color: initial; + --label-weight: initial; + --bg-color: #fff; +} + +.c3 { + font-size: 12px; + color: var(--label-color,var(--textColorStrong)); + font-weight: var(--label-weight,normal); + cursor: inherit; +} + +<div + class="c0" +> + <div + class="c1" + data-is-active="false" + role="radio" + style="--flex-grow: 1;" + > + <div + class="c2" + role="grid" + > + <label + class="c3" + > + option 0 + </label> + </div> + </div> + <div + class="c1" + data-is-active="false" + role="radio" + style="--flex-grow: 1;" + > + <div + class="c2" + role="grid" + > + <label + class="c3" + > + option 1 + </label> + </div> + </div> + <div + class="c1" + data-is-active="false" + role="radio" + style="--flex-grow: 1;" + > + <div + class="c2" + role="grid" + > + <label + class="c3" + > + option 2 + </label> + </div> + </div> +</div> +`; diff --git a/web_console_v2/client/src/components/_base/BlockRadio/index.test.tsx b/web_console_v2/client/src/components/_base/BlockRadio/index.test.tsx new file mode 100644 index 000000000..394593291 --- /dev/null +++ b/web_console_v2/client/src/components/_base/BlockRadio/index.test.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import { sleep, getRandomInt } from 'shared/helpers'; +import Component from './index'; + +describe('<BlockRadio />', () => { + test('default props layout', () => { + const options = Array(3) + .fill(0) + .map((_, index) => { + return { + value: index, + label: `option ${index}`, + }; + }); + const props = { + options, + isCenter: true, + gap: 77, + }; + const { container } = render(<Component {...props} />); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('beforeChange and onChange should be called', async () => { + const beforeChange = jest.fn(); + const onChange = jest.fn(); + const optionsLength = getRandomInt(10, 20); + + const props = { + options: generateRandomOptions(optionsLength), + beforeChange, + onChange, + }; + + render(<Component {...props} />); + const blockList = screen.getAllByRole('radio'); + const randomNumGenerator = getRandomIntWrapper(0, optionsLength - 1); + let clickIndex: number; + + // -------------- + beforeChange.mockReturnValue(Promise.resolve(false)); + clickIndex = randomNumGenerator.next(); + fireEvent.click(blockList[clickIndex]); + expect(beforeChange).toHaveBeenCalledWith(clickIndex); + expect(beforeChange).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(0); + }); + + // -------------- + beforeChange.mockReturnValue(Promise.resolve(true)); + clickIndex = randomNumGenerator.next(); + + fireEvent.click(blockList[clickIndex]); + // beforeChange 之前已经执行过一次,所以是 2 + expect(beforeChange).toHaveBeenCalledWith(clickIndex); + expect(beforeChange).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(clickIndex); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + // -------------- + beforeChange.mockReturnValue(sleep(1000).then(() => true)); + clickIndex = randomNumGenerator.next(); + fireEvent.click(blockList[clickIndex]); + expect(beforeChange).toHaveBeenCalledWith(clickIndex); + expect(beforeChange).toHaveBeenCalledTimes(3); + await waitFor( + () => { + expect(onChange).toHaveBeenCalledWith(clickIndex); + expect(onChange).toHaveBeenCalledTimes(2); + }, + { timeout: 2000 }, + ); + }); + + test('renderBlockInner should work', async () => { + const optionsLength = getRandomInt(10, 20); + const renderBlockInner = jest.fn((props, options) => { + return <h1 className={options.isActive ? 'active' : ''}>{props.value}</h1>; + }); + const props = { + options: generateRandomOptions(optionsLength), + renderBlockInner, + }; + + const { rerender } = render(<Component {...props} />); + expect(renderBlockInner).toHaveBeenCalledTimes(optionsLength); + expect(renderBlockInner).toHaveBeenCalledWith( + expect.objectContaining({ + label: expect.any(String), + value: expect.any(Number), + }), + expect.objectContaining({ + label: expect.anything(), + isActive: expect.any(Boolean), + }), + ); + + // 如果 value 改变,应该在相应下标传入 isActive + const selectedIndex = getRandomInt(0, optionsLength - 1); + rerender(<Component {...props} value={selectedIndex} />); + const blockList = screen.getAllByRole('radio'); + const activeBlock = blockList[selectedIndex]; + const innerContent = activeBlock.querySelector('.active'); + await waitFor(() => { + expect(innerContent).toBeTruthy(); + }); + }); + + test('set option to disabled should work', async () => { + const optionsLength = getRandomInt(10, 20); + const disabledIndex = getRandomInt(0, optionsLength - 1); + const options = generateRandomOptions(optionsLength); + const onChange = jest.fn(); + + options[disabledIndex].disabled = true; + const props = { + options, + onChange, + }; + + render(<Component {...props} />); + + const blockList = screen.getAllByRole('radio'); + const disabledBlock = blockList[disabledIndex]; + fireEvent.click(disabledBlock); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledTimes(0); + }); + }); +}); + +function generateRandomOptions(length = 4) { + return Array(length) + .fill(0) + .map((_, index) => { + return { + value: index, + label: `option ${index}`, + disabled: false, + }; + }); +} + +function getRandomIntWrapper(min = 1, max = 10) { + const record: number[] = []; + + return { + next() { + if (record.length >= max - min) { + throw new Error('record overflow'); + } + + let tmp = getRandomInt(min, max); + while (record.includes(tmp) === true) { + tmp = getRandomInt(min, max); + } + record.push(tmp); + return tmp; + }, + }; +} diff --git a/web_console_v2/client/src/components/shared.ts b/web_console_v2/client/src/components/shared.ts new file mode 100644 index 000000000..b2dc6b3cf --- /dev/null +++ b/web_console_v2/client/src/components/shared.ts @@ -0,0 +1,66 @@ +/* istanbul ignore file */ +import { + fetchAlgorithmProjectFileTreeList, + fetchAlgorithmProjectFileContentDetail, + fetchAlgorithmFileTreeList, + fetchAlgorithmFileContentDetail, + fetchPendingAlgorithmFileTreeList, + fetchPendingAlgorithmFileContentDetail, + fetchPeerAlgorithmFileTreeList, + fetchPeerAlgorithmProjectFileContentDetail, +} from 'services/algorithm'; + +export function getAlgorithmProjectProps(props: { id: ID }) { + const { id } = props; + + return { + id: id, + isAsyncMode: true, + getFileTreeList: () => fetchAlgorithmProjectFileTreeList(id!).then((res) => res.data || []), + getFile: (filePath: string) => + fetchAlgorithmProjectFileContentDetail(id!, { + path: filePath, + }).then((res) => res.data.content), + }; +} +export function getAlgorithmProps(props: { id: ID }) { + const { id } = props; + + return { + id: id, + isAsyncMode: true, + getFileTreeList: () => fetchAlgorithmFileTreeList(id!).then((res) => res.data || []), + getFile: (filePath: string) => + fetchAlgorithmFileContentDetail(id!, { + path: filePath, + }).then((res) => res.data.content), + }; +} + +export function getPendingAlgorithmProps(props: { projId: ID; id: ID }) { + const { id, projId } = props; + return { + id, + isAsyncMode: true, + getFileTreeList: () => + fetchPendingAlgorithmFileTreeList(projId!, id!).then((res) => res.data || []), + getFile: (filePath: string) => + fetchPendingAlgorithmFileContentDetail(projId!, id!, { path: filePath }).then( + (res) => res.data.content, + ), + }; +} + +export function getPeerAlgorithmProps(props: { projId: ID; participantId: ID; id: ID; uuid: ID }) { + const { id, participantId, projId, uuid } = props; + return { + id, + isAsyncMode: true, + getFileTreeList: () => + fetchPeerAlgorithmFileTreeList(projId!, participantId, uuid!).then((res) => res.data || []), + getFile: (filePath: string) => + fetchPeerAlgorithmProjectFileContentDetail(projId!, participantId, uuid!, { + path: filePath, + }).then((res) => res.data.content), + }; +} diff --git a/web_console_v2/client/src/hooks/index.test.tsx b/web_console_v2/client/src/hooks/index.test.tsx new file mode 100644 index 000000000..c993f99db --- /dev/null +++ b/web_console_v2/client/src/hooks/index.test.tsx @@ -0,0 +1,201 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import routeData from 'react-router'; + +import { useUrlState, useTablePaginationWithUrlState, useIsFormValueChange } from './index'; + +describe('useUrlState/useTablePaginationWithUrlState', () => { + it('should be defined', () => { + expect(useUrlState).toBeDefined(); + expect(useTablePaginationWithUrlState).toBeDefined(); + }); + + const replaceFn = jest.fn(); + + let mockLocation = { + pathname: '/', + hash: '', + search: '', + state: '', + }; + + const mockHistory: any = { + push: ({ search }: any) => { + replaceFn(); + mockLocation.search = search; + }, + }; + + beforeEach(() => { + jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + jest.spyOn(routeData, 'useHistory').mockReturnValue(mockHistory); + }); + + afterEach(() => { + replaceFn.mockClear(); + mockLocation = { + pathname: '/', + hash: '', + search: '', + state: '', + }; + }); + + describe('useUrlState', () => { + it('history replace should work', async () => { + const hook = renderHook(() => { + return useUrlState({ mock: '0' }); + }) as any; + + // If const [urlState, setUrlState] = hook.result.current, urlState is the oldest data after invoking setUrlState + // hook.result.current[0] meaning the lastest data + const [, setUrlState] = hook.result.current; + + expect(replaceFn).toBeCalledTimes(0); + expect(hook.result.current[0]).toEqual({ mock: '0' }); + expect(mockLocation.search).toEqual(''); + act(() => { + setUrlState({ mock: 1 }); + }); + expect(hook.result.current[0]).toEqual({ mock: '1' }); + + expect(replaceFn).toBeCalledTimes(1); + expect(mockLocation.search).toEqual('?mock=1'); + act(() => { + setUrlState({ mock: 2, test: 3 }); + }); + expect(hook.result.current[0]).toEqual({ mock: '2', test: '3' }); + expect(mockLocation.search).toEqual('?mock=2&test=3'); + }); + }); + + describe('useTablePaginationWithUrlState', () => { + it('default options', async () => { + const hook: RenderHookResult< + any, + ReturnType<typeof useTablePaginationWithUrlState> + > = renderHook(() => { + return useTablePaginationWithUrlState(); + }) as any; + + expect(hook.result.current).toMatchObject({ + urlState: expect.objectContaining({ + page: '1', + pageSize: '10', + }), + setUrlState: expect.any(Function), + reset: expect.any(Function), + paginationProps: expect.objectContaining({ + current: 1, + pageSize: 10, + onChange: expect.any(Function), + onShowSizeChange: expect.any(Function), + }), + }); + + act(() => { + hook.result.current.paginationProps.onChange(2, 10); + }); + + expect(hook.result.current.urlState).toEqual({ + page: '2', + pageSize: '10', + }); + expect(hook.result.current.paginationProps).toMatchObject({ + current: 2, + pageSize: 10, + }); + + act(() => { + hook.result.current.paginationProps.onShowSizeChange(3, 20); + }); + + expect(hook.result.current.urlState).toEqual({ + page: '3', + pageSize: '20', + }); + expect(hook.result.current.paginationProps).toMatchObject({ + current: 3, + pageSize: 20, + }); + + act(() => { + hook.result.current.reset(); + }); + expect(hook.result.current.urlState).toEqual({ + page: '1', + pageSize: '10', + }); + expect(hook.result.current.paginationProps).toMatchObject({ + current: 1, + pageSize: 10, + }); + }); + + it('custom options', async () => { + const hook: RenderHookResult< + any, + ReturnType<typeof useTablePaginationWithUrlState> + > = renderHook(() => { + return useTablePaginationWithUrlState({ + defaultPage: 2, + defaultPageSize: 15, + }); + }) as any; + + expect(hook.result.current.urlState).toEqual({ + page: '2', + pageSize: '15', + }); + + act(() => { + hook.result.current.paginationProps.onChange(2, 10); + }); + + expect(hook.result.current.urlState).toEqual({ + page: '2', + pageSize: '10', + }); + expect(hook.result.current.paginationProps).toMatchObject({ + current: 2, + pageSize: 10, + }); + + act(() => { + hook.result.current.reset(); + }); + expect(hook.result.current.urlState).toEqual({ + page: '2', + pageSize: '15', + }); + expect(hook.result.current.paginationProps).toMatchObject({ + current: 2, + pageSize: 15, + }); + }); + }); +}); +it('useIsFormValueChange', () => { + const mockFn = jest.fn(); + + const { result } = renderHook(() => { + return useIsFormValueChange(mockFn); + }); + + expect(result.current.isFormValueChanged).toBe(false); + expect(mockFn).toBeCalledTimes(0); + + act(() => { + result.current.onFormValueChange(); + }); + + expect(result.current.isFormValueChanged).toBe(true); + expect(mockFn).toBeCalledTimes(1); + + act(() => { + result.current.resetChangedState(); + }); + + expect(result.current.isFormValueChanged).toBe(false); + expect(mockFn).toBeCalledTimes(1); +}); diff --git a/web_console_v2/client/src/hooks/modelCenter.tsx b/web_console_v2/client/src/hooks/modelCenter.tsx new file mode 100644 index 000000000..57b6d5255 --- /dev/null +++ b/web_console_v2/client/src/hooks/modelCenter.tsx @@ -0,0 +1,159 @@ +import { useResetRecoilState, useSetRecoilState } from 'recoil'; +import { + trainModelForm, + evaluationModelForm, + offlinePredictionModelForm, +} from 'stores/modelCenter'; +import { treeTemplateIdQuery, nnTemplateIdQuery } from 'stores/modelCenter'; +import { useRecoilQuery } from 'hooks/recoil'; +import { useEffect, useMemo } from 'react'; +import { useQueries, useQuery } from 'react-query'; +import { useGetCurrentProjectId } from 'hooks'; +import { + fetchModelJobMetries_new, + fetchPeerModelJobMetrics_new, + fetchModelJob_new, +} from 'services/modelCenter'; +import { ModelJob } from 'typings/modelCenter'; +import { cloneDeep } from 'lodash-es'; +import { getAlgorithmDetail } from 'services/algorithm'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { Message } from '@arco-design/web-react'; + +export function useResetCreateForm() { + const resetTrainModelForm = useResetRecoilState(trainModelForm); + const resetEvaluationModelForm = useResetRecoilState(evaluationModelForm); + const resetOfflinePredictionModelForm = useResetRecoilState(offlinePredictionModelForm); + + return function () { + resetTrainModelForm(); + resetEvaluationModelForm(); + resetOfflinePredictionModelForm(); + }; +} + +export function useGetTrainModelTemplateId() { + const { data: treeTemplateId, error: treeTemplateError } = useRecoilQuery(treeTemplateIdQuery); + const { data: nnTemplateId, error: nnTemplateError } = useRecoilQuery(nnTemplateIdQuery); + + const setTreeTemplateId = useSetRecoilState(treeTemplateIdQuery); + const setNNTemplateId = useSetRecoilState(nnTemplateIdQuery); + + useEffect(() => { + if (treeTemplateId && !treeTemplateError) { + setTreeTemplateId(treeTemplateId); + } + }, [treeTemplateId, treeTemplateError, setTreeTemplateId]); + useEffect(() => { + if (nnTemplateId && !nnTemplateError) { + setNNTemplateId(nnTemplateId); + } + }, [nnTemplateId, nnTemplateError, setNNTemplateId]); + + return { + treeTemplateId, + nnTemplateId, + treeTemplateError, + nnTemplateError, + }; +} + +export function useModelMetriesResult(jobId: ID, jobParticipantId?: ID) { + const projectId = useGetCurrentProjectId(); + + return useQuery( + ['get_model_jobs_metries', jobId, projectId, jobParticipantId], + () => { + if (!jobParticipantId) { + return fetchModelJobMetries_new(projectId!, jobId).then( + (res: any) => res?.[0]?.data, + (error) => { + Message.error(error.message); + }, + ); + } else if (jobParticipantId !== jobId) { + return fetchPeerModelJobMetrics_new(projectId!, jobId, jobParticipantId!).then( + (res) => res.data, + (error) => { + Message.error(error.message); + }, + ); + } else { + return Promise.resolve({}); + } + }, + { + enabled: Boolean(projectId), + }, + ); +} + +export function useBatchModelJobMetricsAndConfig(jobList: ModelJob[], enable: boolean) { + const projectId = useGetCurrentProjectId(); + const metricsQuery = useQueries( + jobList.map((item) => ({ + enabled: Boolean(projectId && enable), + queryKey: ['batch_model_job_metrics', item.id], + queryFn() { + return fetchModelJobMetries_new(projectId!, item.id).then((res: any) => res?.[0]?.data); + }, + })), + ); + const configQuery = useQueries( + jobList.map((item) => ({ + enabled: Boolean(projectId && enable), + queryKey: ['batch_model_job_config', item.id], + async queryFn() { + const variables = await fetchModelJob_new(projectId!, item.id).then( + (res) => res.data.config.job_definitions?.[0].variables, + ); + let parameterKeyList: string[] = []; + + if ( + item.algorithm_type === EnumAlgorithmProjectType.NN_VERTICAL || + item.algorithm_type === EnumAlgorithmProjectType.NN_HORIZONTAL + ) { + const algorithm = await getAlgorithmDetail(item.algorithm_id).then((res) => res.data); + const parameters = algorithm.parameter?.variables ?? []; + parameterKeyList = ['epoch_num', 'verbosity', ...parameters.map((p) => p.name)]; + } else if (item.algorithm_type === EnumAlgorithmProjectType.TREE_VERTICAL) { + parameterKeyList = [ + 'learning_rate', + 'max_iters', + 'max_depth', + 'l2_regularization', + 'max_bins', + 'num_parallel', + ]; + } + + return variables.filter((v) => parameterKeyList.includes(v.name)); + }, + })), + ); + + return useMemo(() => { + const ret = []; + let isFetchedCount = 0; + for (let i = 0; i < metricsQuery.length; i++) { + const job = jobList[i]; + const mq = metricsQuery[i]; + const cq = configQuery[i]; + if (mq.isFetched || cq.isFetched) { + isFetchedCount++; + const mData = mq.data as any; + const cData = cq.data as any; + ret.push({ + id: job.name, + job: job, + config: cloneDeep(cData ?? []), + metric: cloneDeep(mData ?? {}), + }); + } + } + return { + dataList: ret, + isLoading: isFetchedCount < metricsQuery.length, + }; + }, [metricsQuery, configQuery, jobList]); +} diff --git a/web_console_v2/client/src/hooks/participant.ts b/web_console_v2/client/src/hooks/participant.ts new file mode 100644 index 000000000..d94b2069e --- /dev/null +++ b/web_console_v2/client/src/hooks/participant.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { useQuery, UseQueryOptions } from 'react-query'; +import { checkParticipantConnection } from 'services/participant'; +import { ConnectionStatus, ConnectionStatusType, Version } from 'typings/participant'; +import { forceReloadParticipantList } from 'stores/participant'; + +export function useReloadParticipantList() { + const setter = useSetRecoilState(forceReloadParticipantList); + + return function () { + setter(Math.random()); + }; +} + +export function useCheckConnection( + partnerId: ID, + options?: UseQueryOptions<{ + data: { success: boolean; message: string; application_version: Version }; + }>, +): [ConnectionStatus, Function] { + const checkQuery = useQuery( + [`checkConnection-participant-${partnerId}`, partnerId], + () => checkParticipantConnection(partnerId), + { + enabled: Boolean(partnerId), + cacheTime: 1, + retry: false, + ...options, + }, + ); + + const finalStatus = useMemo(() => { + const status = { + success: ConnectionStatusType.Processing, + message: '', + application_version: {}, + }; + if (!checkQuery.isFetching) { + if (checkQuery.isError) { + status.success = ConnectionStatusType.Fail; + status.message = (checkQuery.error as Error).message; + } else { + const queryData = checkQuery.data?.data; + if (queryData) { + status.message = queryData.message; + status.application_version = queryData.application_version; + status.success = queryData.success + ? ConnectionStatusType.Success + : ConnectionStatusType.Fail; + } + } + } + return status; + }, [checkQuery]); + + return [finalStatus, checkQuery.refetch]; +} diff --git a/web_console_v2/client/src/hooks/user.tsx b/web_console_v2/client/src/hooks/user.tsx new file mode 100644 index 000000000..e038bcdfb --- /dev/null +++ b/web_console_v2/client/src/hooks/user.tsx @@ -0,0 +1,15 @@ +import { userInfoQuery, userInfoGetters } from 'stores/user'; +import { useRecoilQuery } from 'hooks/recoil'; +import { FedRoles } from 'typings/auth'; + +export function useGetUserInfo() { + const { data } = useRecoilQuery(userInfoQuery); + // TODO: maybe undefined + return data; +} + +export function useIsAdminRole() { + const { isLoading, data } = useRecoilQuery(userInfoGetters); + + return !isLoading && data && data.role === FedRoles.Admin; +} diff --git a/web_console_v2/client/src/i18n/resources/modules/algorithmManagement.ts b/web_console_v2/client/src/i18n/resources/modules/algorithmManagement.ts new file mode 100644 index 000000000..d73bfe6e8 --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/algorithmManagement.ts @@ -0,0 +1,207 @@ +import { separateLng } from 'i18n/helpers'; + +const algorithm = { + label_tab_my_algorithm: { zh: '我的算法', en: 'My algorithm' }, + label_tab_my_built_in: { zh: '预置算法', en: 'Built-in algorithm' }, + btn_create_algorithm: { zh: '创建算法', en: 'Create algorithm' }, + btn_submit: { zh: '提交', en: 'Submit' }, + btn_submit_and_send: { zh: '提交并发版', en: 'Submit and send' }, + btn_update_preset_algorithm: { zh: '更新预置算法', en: 'Update preset algorithm' }, + col_name: { zh: '名称', en: 'Name' }, + col_type: { zh: '类型', en: 'Type' }, + col_kind: { zh: '来源', en: 'source' }, + col_state: { zh: '状态', en: 'State' }, + col_operate: { zh: '操作', en: 'operate' }, + col_latest_version: { zh: '最新版本', en: 'latest version' }, + title_create_algorithm: { zh: '创建算法', en: 'Create algorithm' }, + title_edit_algorithm: { zh: '编辑算法', en: 'Edit algorithm' }, + title_base_info: { zh: '基本信息', en: 'Base info' }, + title_todo_acceptance_tasks: { zh: '待接收算法', en: 'waiting prediction model task' }, + + label_algorithm_name: { zh: '算法名称', en: 'Algorithm name' }, + label_algorithm_name_placeholder: { + zh: '请输入算法名称', + en: 'Please input the name of algorithm', + }, + label_algorithm_type: { zh: '算法类型', en: 'Algorithm type' }, + label_algorithm_version: { zh: '版本', en: 'Version' }, + label_algorithm_comment: { zh: '算法描述', en: 'algorithm desc' }, + label_algorithm_comment_placeholder: { + zh: '最多为 200 个字符', + en: 'Up to 200 characters', + }, + label_algorithm_files: { zh: '算法文件', en: 'Algorithm files' }, + label_algorithm_files_upload_tip: { + zh: '仅支持上传1个 .tar 或 .gz 格式文件,大小不超过 100 MiB', + en: 'Only support .tar or .gz format file, size less than 100 MiB', + }, + label_algorithm_files_oversize: { + zh: '大小超过限制', + en: 'File size exceeds the limit', + }, + label_algorithm_files_unknown_format: { + zh: '格式不支持', + en: 'File format is not supported', + }, + label_algorithm_detail: { zh: '算法', en: 'detail' }, + label_algorithm_project: { zh: '算法项目', en: 'Algorithm project' }, + label_algorithm: { zh: '算法', en: 'Algorithm' }, + + label_model_type_unspecified: { zh: '自定义算法', en: 'Customize algorithm' }, + label_model_type_tree: { zh: '树模型', en: 'Tree model' }, + label_model_type_nn: { zh: 'NN模型', en: 'NN model' }, + label_model_type_tree_vertical: { zh: '纵向联邦-树模型', en: 'Tree model(vertical)' }, + label_model_type_tree_horizontal: { zh: '横向联邦-树模型', en: 'Tree model(horizontal)' }, + label_model_type_nn_vertical: { zh: '纵向联邦-NN模型', en: 'NN model(vertical)' }, + label_model_type_trusted_computing: { zh: '可信计算', en: 'Trusted computing' }, + label_model_type_nn_horizontal: { zh: '横向联邦-NN模型', en: 'NN model(horizontal)' }, + label_model_type_nn_local: { zh: '本地-NN模型', en: 'NN model(local)' }, + label_algorithm_params: { zh: '超参数', en: 'algorithm params' }, + label_algorithm_params_name: { zh: '名称', en: 'name' }, + label_algorithm_params_value: { zh: '默认值', en: 'default value' }, + label_algorithm_params_required: { zh: '是否必填', en: 'required' }, + label_algorithm_params_comment: { zh: '提示语', en: 'comment' }, + label_algorithm_add_params: { zh: '新增超参数', en: 'add new param' }, + placeholder_algorithm_params_name: { + zh: '请输入参数名称', + en: 'Please input the name of params', + }, + placeholder_algorithm_params_value: { zh: '请输入默认值', en: 'Please input the default value' }, + placeholder_algorithm_params_comment: { zh: '请输入提示语', en: 'Please input comment' }, + placeholder_searchbox_algorithm: { zh: '输入算法名称' }, + + kind_algorithm_user: { zh: '我方', en: 'user' }, + kind_algorithm_preset: { zh: '系统预置', en: 'preset' }, + kind_algorithm_third_party: { zh: '第三方', en: 'third party' }, + publish_modal_title: { zh: '发版「{{name}}」', en: 'confirm to publish {{name}} ' }, + publish_modal_title_comment: { zh: '版本描述', en: 'publish note' }, + send_modal_title: { zh: '发布「{{name}}」', en: 'confirm to send {{name}} ' }, + send_modal_title_algorithm: { zh: '算法', en: 'algorithm' }, + send_modal_title_comment: { zh: '算法描述', en: 'sending note' }, + send_modal_btn_send: { zh: '发布', en: 'send' }, + send_modal_btn_publish: { zh: '发版', en: 'publish' }, + send_modal_btn_cancel: { zh: '取消', en: 'cancel' }, + send_modal_property_name: { zh: '名称', en: 'name' }, + send_modal_property_type: { zh: '类型', en: 'type' }, + send_modal_property_version: { zh: '版本', en: 'version' }, + send_modal_property_source: { zh: '来源', en: 'version' }, + send_modal_property_description: { zh: '描述', en: 'description' }, + + type_algorithm_unspecified: { zh: '未指定', en: 'unspecified' }, + type_algorithm_tree: { zh: '树模型', en: 'Tree Model' }, + type_algorithm_nn: { zh: 'NN模型', en: 'NN Model' }, + type_algorithm_horizontal: { zh: '横向联邦', en: 'Horizontal Federal' }, + type_algorithm_vertical: { zh: '纵向联邦', en: 'Vertical Federal' }, + + action_algorithm_edit: { zh: '编辑', en: 'Edit' }, + action_algorithm_send: { zh: '发布最新版本', en: 'Send the latest version' }, + action_algorithm_download: { zh: '下载', en: 'Download' }, + action_algorithm_delete: { zh: '删除', en: 'Delete' }, + + col_inner_algorithm_publish: { zh: '发版', en: 'publish' }, + + state_text_published: { zh: '已发版', en: 'sent' }, + state_text_draft: { zh: '未发版', en: 'draft' }, + + title_create_algorithm_back_btn: { zh: '算法仓库', en: 'Algorithm Repository' }, + + msg_create_success: { zh: '创建成功', en: 'Create successfully' }, + + msg_update_success: { zh: '编辑成功', en: 'Edit successfully' }, + msg_publish_success: { zh: '发版成功', en: 'Publish successfully' }, + msg_send_success: { zh: '发布成功', en: 'Send successfully' }, + msg_create_and_publish_success: { zh: '创建并发版成功', en: 'Create and publish successfully' }, + msg_update_and_publish_success: { zh: '编辑并发版成功', en: 'Create and publish successfully' }, + msg_todo_algorithm_tasks: { zh: '{{count}} 条待处理算法消息' }, + msg_prefix_algorithm_tasks: { zh: '发布了', en: 'sent' }, + msg_suffix_algorithm_tasks: { zh: '的算法', en: "'s algorithm" }, + msg_update_preset_algorithm_success: { + zh: '更新预置算法成功', + en: 'Update preset algorithm successfully', + }, + msg_update_preset_algorithm_fail: { + zh: '更新预置算法失败', + en: 'Update preset algorithm fail', + }, + + title_todo_algorithm_tasks: { zh: '待处理算法任务', en: 'waiting algorithm' }, + suffix_algorithm_tasks: { zh: ' 算法', en: 'algorithm' }, + + detail_back_btn_text: { zh: '算法仓库', en: 'algorithm repository' }, + detail_prop_type: { zh: '类型', en: 'type' }, + detail_prop_latest_version: { zh: '最新版本', en: 'latest version' }, + detail_prop_update_time: { zh: '更新时间', en: 'update time' }, + detail_publish_btn_text: { zh: '发版', en: 'publish algorithm' }, + detail_publish_latest_version_algorithm: { zh: '发布最新版本', en: 'publish latest version' }, + detail_tab_title_files: { zh: '算法文件', en: 'Files' }, + detail_tab_version_list: { zh: '版本列表', en: 'Version List' }, + detail_section_title_algorithm_parameter: { zh: '超参数', en: 'algorithm parameter' }, + detail_section_title_code_files: { zh: '算法代码', en: 'algorithm code' }, + detail_param_table_col_name: { zh: '名称', en: 'name' }, + detail_param_table_col_config: { zh: '版本配置', en: 'detail' }, + detail_param_table_col_value: { zh: '默认值', en: 'value' }, + detail_param_table_col_required: { zh: '是否必填', en: 'required' }, + detail_param_table_col_comment: { zh: '提示语', en: 'comment' }, + detail_version_table_version: { zh: '版本号', en: 'version' }, + detail_version_table_creator: { zh: '创建者', en: 'creator' }, + detail_version_table_comment: { zh: '描述', en: 'desc' }, + detail_version_table_create_time: { zh: '发版时间', en: 'publish time' }, + detail_version_table_operate: { zh: '操作', en: 'operate' }, + detail_version_table_send_btn: { zh: '发布', en: 'send' }, + detail_version_drawer_title: { zh: '算法版本 V{{version}}', en: 'algorithm V{{version}}' }, + detail_version_drawer_full_title: { + zh: '算法 {{algorithmName}} - 版本 V{{version}}', + en: 'Algorithm {{algorithmName}} - V{{version}}', + }, + detail_version_table_empty_msg: { + zh: '暂无已发版的算法版本', + en: 'no published algorithm version', + }, + detail_version_table_empty_send_btn: { zh: '去发版', en: 'publish' }, + detail_version_table_detail_btn: { zh: '点击查看', en: 'detail' }, + + form_code_changed: { zh: '已保存', en: 'saved' }, + form_code_unchanged: { zh: '未编辑', en: 'not edited' }, + form_code_entry_tip: { zh: '点击进入代码编辑器', en: 'click to enter code editor' }, + form_rule_msg_name: { zh: '算法名称不能为空', en: 'algorithm name cannot be empty' }, + form_msg_name_duplicate: { zh: '算法名称已存在', en: 'this name already exist' }, + + delete_algorithm_confirm_title: { + zh: '确认要删除「{{name}}」?', + en: 'Are you sure to delete algorithm "{{name}}" ?', + }, + delete_algorithm_confirm_content: { + zh: '删除后,正在运行的任务可能受影响,历史任务无法运行,请谨慎删除', + en: + 'After deletion, the running task may be affected, the history task cannot be run, please be careful to delete', + }, + + empty_algorithm_list_text: { zh: '暂无算法,去创建', en: 'no algorithm, go to create' }, + empty_algorithm_preset_list_text: { zh: '暂无算法', en: 'no algorithm' }, + + acceptance_btn_detail: { zh: '查看算法配置', en: 'view algorithm config' }, + acceptance_btn_confirm: { zh: '同意并保存', en: 'accept' }, + acceptance_btn_cancel: { zh: '取消', en: 'reject' }, + acceptance_status_success: { + zh: '已同意并保存『{{name}}』', + en: 'accepted and saved algorithm "{{name}}"', + }, + acceptance_status_success_tip: { + zh: '{{second}}S 后自动前往算法列表', + en: '{{second}} seconds later, automatically go to algorithm list', + }, + acceptance_btn_go: { zh: '前往算法列表', en: 'go to algorithm list' }, + acceptance_form_name: { zh: '名称', en: 'name' }, + acceptance_form_type: { zh: '类型', en: 'type' }, + acceptance_form_version: { zh: '版本', en: 'version' }, + acceptance_form_comment: { zh: '描述', en: 'desc' }, + acceptance_form_detail: { zh: '算法', en: 'detail' }, + acceptance_msg_not_found: { zh: '算法不存在', en: 'algorithm is not found' }, + + tip_update_preset_algorithm: { + zh: '只有管理员才能更新预置算法', + en: 'Only admin can update preset algorithm', + }, +}; + +export default separateLng(algorithm); diff --git a/web_console_v2/client/src/i18n/resources/modules/audit.ts b/web_console_v2/client/src/i18n/resources/modules/audit.ts new file mode 100644 index 000000000..5778088f9 --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/audit.ts @@ -0,0 +1,53 @@ +import { separateLng } from 'i18n/helpers'; + +const audit = { + title_event_record: { zh: '事件记录', en: 'Event record' }, + title_event_detail: { zh: '事件详情', en: 'Event detail' }, + title_base_info: { zh: '基础信息', en: 'Base info' }, + title_request_info: { zh: '请求信息', en: 'Request info' }, + + tip_event_record: { + zh: '以下列表最长展示过去9个月的事件记录', + en: 'The following list shows up to the past 9 months of event records', + }, + + col_event_time: { zh: '事件时间', en: 'Event time' }, + col_user_name: { zh: '用户名', en: 'User name' }, + col_event_name: { zh: '事件名称', en: 'Event name' }, + col_resource_type: { zh: '资源类型', en: 'Resource type' }, + col_resource_name: { zh: '资源名称', en: 'Resource name' }, + + placeholder_search: { zh: '搜索关键词', en: 'Search keyword' }, + + radio_label_all: { zh: '全部', en: 'All' }, + radio_label_one_week: { zh: '近7天', en: 'Nearly 7 days' }, + radio_label_one_month: { zh: '近1月', en: 'Nearly 1 month' }, + radio_label_three_months: { zh: '近3月', en: 'Nearly 3 months' }, + + btn_delete: { zh: '删除6个月前的记录', en: 'Delete records from 6 months ago' }, + + label_event_id: { zh: '事件ID', en: 'Event ID' }, + label_event_time: { zh: '事件时间', en: 'Event time' }, + label_event_name: { zh: '事件名称', en: 'Event name' }, + label_user_name: { zh: '用户名', en: 'User name' }, + label_operation_name: { zh: '操作名称', en: 'Operation name' }, + + label_request_id: { zh: '请求ID', en: 'Request ID' }, + label_access_key_id: { zh: 'AccessKey ID', en: 'AccessKey ID' }, + label_event_result: { zh: '事件结果', en: 'Event result' }, + label_error_code: { zh: '错误码', en: 'Error code' }, + label_resource_type: { zh: '资源类型', en: 'Resource type' }, + label_resource_name: { zh: '资源名称', en: 'Resource name' }, + label_original_ip_address: { zh: '源IP地址', en: 'Source ip address' }, + label_extra_info: { zh: '额外信息', en: 'Extra info' }, + + msg_title_confirm_delete: { zh: '确定要删除吗?', en: 'You sure you want to delete it?' }, + msg_content_confirm_delete: { + zh: '基于安全审核的原因,平台仅支持清理6个月前的事件记录', + en: + 'Due to security audit reasons, the platform only supports cleaning up the event records 6 months ago', + }, + msg_delete_success: { zh: '删除成功', en: 'Delete success' }, +}; + +export default separateLng(audit); diff --git a/web_console_v2/client/src/i18n/resources/modules/dashboard.ts b/web_console_v2/client/src/i18n/resources/modules/dashboard.ts new file mode 100644 index 000000000..81828c7dc --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/dashboard.ts @@ -0,0 +1,7 @@ +import { separateLng } from 'i18n/helpers'; + +const dashboard = { + dashboard: { zh: '仪表盘', en: 'dashboard' }, +}; + +export default separateLng(dashboard); diff --git a/web_console_v2/client/src/i18n/resources/modules/intersection_dataset.ts b/web_console_v2/client/src/i18n/resources/modules/intersection_dataset.ts new file mode 100644 index 000000000..32c3e24ec --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/intersection_dataset.ts @@ -0,0 +1,17 @@ +import { separateLng } from 'i18n/helpers'; + +const intersection_dataset = { + status: { zh: '状态' }, + no_result: { zh: '暂无数据集' }, + + col_name: { zh: '名称' }, + col_status: { zh: '求交状态' }, + col_job_name: { zh: '求交任务' }, + col_peer_name: { zh: '求交参与方' }, + col_sample_num: { zh: '数据集样本量' }, + col_sample_filesize: { zh: '数据集大小' }, + col_files_size: { zh: '文件大小' }, + col_num_example: { zh: '数据集样本量' }, +}; + +export default separateLng(intersection_dataset); diff --git a/web_console_v2/client/src/i18n/resources/modules/modelCenter.ts b/web_console_v2/client/src/i18n/resources/modules/modelCenter.ts new file mode 100644 index 000000000..172ac1dc7 --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/modelCenter.ts @@ -0,0 +1,906 @@ +import { separateLng } from 'i18n/helpers'; + +const modelCenter = { + menu_label_model_management: { zh: '模型管理', en: 'Model management' }, + menu_label_model_evaluation: { zh: '模型评估', en: 'Model evaluation' }, + menu_label_offline_prediction: { zh: '离线预测', en: 'Offline prediction' }, + menu_label_algorithm_management: { zh: '算法管理', en: 'Algorithm management' }, + + label_tab_model_set: { zh: '模型集', en: 'Model Set' }, + label_tab_model_favourit: { zh: '我的收藏', en: 'My favourit model' }, + label_tab_my_algorithm: { zh: '我的算法', en: 'My algorithm' }, + label_tab_my_built_in: { zh: '预置算法', en: 'Built-in algorithm' }, + label_tab_model_evaluation: { zh: '模型评估', en: 'Model evaluation' }, + label_tab_model_compare: { zh: '模型对比', en: 'Model compare' }, + + label_tab_algorithm_preview: { zh: '算法预览', en: 'Algorithm preview' }, + label_tab_change_log: { zh: '变更记录', en: 'Change log' }, + + label_algorithm_params: { zh: '超参数', en: 'algorithm params' }, + label_algorithm_params_name: { zh: '名称', en: 'name' }, + label_algorithm_params_value: { zh: '默认值', en: 'default value' }, + label_algorithm_params_required: { zh: '是否必填', en: 'required' }, + label_algorithm_params_comment: { zh: '提示语(必填)', en: 'comment' }, + label_algorithm_add_params: { zh: '新增超参数', en: 'add new param' }, + placeholder_algorithm_params_name: { + zh: '请输入参数名称', + en: 'Please input the name of params', + }, + placeholder_algorithm_params_value: { zh: '请输入默认值', en: 'Please input the default value' }, + placeholder_algorithm_params_comment: { zh: '请输入提示语', en: 'Please input comment' }, + + msg_todo_train_tasks: { zh: '{{count}} 条待处理训练任务' }, + msg_todo_model_job_tasks: { zh: '{{count}} 条待处理模型训练' }, + msg_todo_evaluation_tasks: { zh: '{{count}} 条待处理评估任务' }, + msg_todo_prediction_tasks: { zh: '{{count}} 条待处理预测任务' }, + msg_todo_algorithm_tasks: { zh: '{{count}} 条待处理算法任务' }, + msg_before_revoke_authorize: { + zh: '撤销授权后,发起方不可运行模型训练,正在运行的任务不受影响', + en: + 'After revoking authorization, the initiator cannot run the model training, and the running task will not be affected', + }, + msg_before_authorize: { + zh: '授权后,发起方可以运行模型训练', + en: 'After authorization, the initiator can run the model training', + }, + + btn_create_model_set: { zh: '创建模型集', en: 'Create model set' }, + btn_create_model_job: { zh: '创建训练', en: 'Create model job' }, + btn_train_model: { zh: '发起训练', en: 'Start train' }, + btn_evaluation: { zh: '创建评估', en: 'Start evaluation' }, + btn_inspect_logs: { zh: '查看日志', en: 'Inspect log' }, + btn_restart: { zh: '重新发起', en: 'Restart' }, + btn_parameter_tuning: { zh: '调参', en: 'Parameter tuning' }, + btn_prediction: { zh: '创建预测', en: 'Start prediction' }, + btn_create_algorithm: { zh: '创建算法', en: 'Create algorithm' }, + btn_create_type: { zh: '新增类型', en: 'Create type' }, + btn_start_model: { zh: '开始训练', en: 'Start train' }, + btn_submit: { zh: '提交', en: 'Submit' }, + btn_compare: { zh: '发起对比', en: 'Start compare' }, + btn_create_compare_report: { zh: '创建对比报告', en: 'Create compare report' }, + btn_go_back_to_index_page: { zh: '回到首页', en: 'Go back to index page' }, + btn_start_new_job: { zh: '发起新任务', en: 'Start new job' }, + btn_submit_and_send_request: { zh: '提交并发送', en: 'Submit and send request' }, + btn_confirm_authorized: { zh: '确认授权', en: 'Confirm authorized' }, + btn_save_edit: { zh: '保存编辑', en: 'Save' }, + btn_next_step: { zh: '下一步', en: 'Next step' }, + btn_stop: { + zh: '终止', + en: 'Stop', + }, + btn_cancel: { + zh: '取消', + en: 'cancel', + }, + btn_terminal: { + zh: '终止', + en: 'terminal', + }, + + placeholder_searchbox_model: { zh: '输入模型名称', en: 'Please input model name' }, + placeholder_searchbox_model_set: { zh: '输入模型集名称', en: 'Please input model set name' }, + placeholder_searchbox_model_job: { + zh: '输入模型训练名称', + en: 'Please input model job name', + }, + placeholder_searchbox_evaluation_report: { + zh: '输入评估任务名称', + en: 'Please input evaluation task name', + }, + placeholder_searchbox_prediction_report: { + zh: '输入预测任务名称', + en: 'Please input prediction task name', + }, + placeholder_searchbox_algorithm: { zh: '输入算法名称', en: 'Please input algorithm name' }, + + placeholder_input_algorithm_type: { zh: '请输入算法类型', en: 'Please input algorithm type' }, + placeholder_searchbox_model_file: { + zh: '输入目标地址,搜索模型文件', + en: 'Input the target address, search for model files', + }, + placeholder_searchbox_compare_report: { + zh: '输入对比报告名称', + en: 'Please input report name', + }, + placeholder_searchbox_evaluation_model: { + zh: '输入关键词', + en: 'Please input name', + }, + + col_model_set_name: { zh: '模型集名称', en: 'Model set name' }, + col_algorithm: { zh: '算法', en: 'algorithm' }, + col_new_version: { zh: '最新版本', en: 'version' }, + col_comment: { zh: '{{name}}描述', en: '{{name}} comment' }, + col_model_name: { zh: '模型名称', en: 'Model name' }, + col_model_id: { zh: '模型ID', en: 'Model ID' }, + col_data_set: { zh: '数据集', en: 'dataset' }, + col_evaluation_task_name: { zh: '评估任务名称', en: 'Evaluation task name' }, + col_state: { zh: '运行状态', en: 'State' }, + col_evaluation_target: { zh: '评估对象', en: 'Evaluation target' }, + col_prediction_target: { zh: '预测对象', en: 'Prediction target' }, + col_prediction_task_name: { zh: '预测任务名称', en: 'Prediction task name' }, + col_name: { zh: '名称', en: 'Name' }, + col_type: { zh: '类型', en: 'Type' }, + col_compare_report_name: { zh: '对比报告名称', en: 'Compare report name' }, + col_compare_number: { zh: '对比项', en: 'Compare number' }, + col_model_type: { zh: '模型类型', en: 'Model type' }, + col_model_source: { zh: '模型来源', en: 'Model source' }, + col_creator: { zh: '创建者', en: 'creator' }, + col_initiator: { zh: '发起方', en: 'initiator' }, + col_authorized: { zh: '授权状态', en: 'authorized' }, + col_loca_authorized: { zh: '本侧授权状态', en: 'local authorized' }, + col_federal_type: { zh: '联邦类型', en: 'federal type' }, + col_total_jobs: { zh: '任务总数', en: 'total jobs' }, + col_latest_job_state: { zh: '最新任务状态', en: 'latest job state' }, + col_job_state: { zh: '任务状态', en: 'job state' }, + col_running_time: { zh: '运行时长', en: 'runtime' }, + col_start_time: { zh: '开始时间', en: 'start time' }, + col_stop_time: { zh: '结束时间', en: 'end time' }, + col_running_param: { zh: '运行参数', en: 'running param' }, + + title_create_model_set: { zh: '创建模型集', en: 'Create model set' }, + title_edit_model_set: { zh: '编辑模型集', en: 'Edit model set' }, + title_create_model_job: { zh: '创建训练', en: 'Create model job' }, + title_edit_model_train: { zh: '编辑训练', en: 'Edit model train' }, + title_start_new_job: { zh: '发起新任务', en: 'Start new job' }, + title_auth_model_train: { zh: '授权模型训练', en: '授权模型训练' }, + title_create_valuation: { zh: '创建评估', en: 'Create Evaluation' }, + title_create_prediction: { zh: '创建预测', en: 'Create Prediction' }, + + title_edit_model: { zh: '编辑模型', en: 'Edit model' }, + title_image_version: { zh: '镜像参数', en: 'Image params' }, + title_algorithm_config: { zh: '算法配置', en: 'Algorithm config' }, + title_train_info: { zh: '训练信息', en: 'Train information' }, + title_resource_config_detail: { zh: '资源配置参数详情', en: 'Resource config detail' }, + title_resource_config: { zh: '资源配置', en: 'Resource config' }, + title_advanced_config: { zh: '高级配置', en: 'Advanced config' }, + title_model_favourit: { zh: '我的收藏', en: 'My favourit model' }, + title_revision_history: { zh: '历史版本', en: 'Revision history' }, + title_base_info: { zh: '基本信息', en: 'Base info' }, + title_train_config: { zh: '训练配置', en: 'Train config' }, + title_train_report: { zh: '训练报告', en: 'Train report' }, + title_instance_info: { zh: '实例信息', en: 'Instance info' }, + title_version: { zh: '版本', en: 'Version' }, + title_model_train_dataset: { zh: '训练数据集', en: 'Train dataset' }, + title_train_duration: { zh: '训练时间', en: 'Train duration' }, + title_log: { zh: '日志', en: 'Log' }, + title_check: { zh: '查看', en: 'Check' }, + title_model_comment: { zh: '模型描述', en: 'Model comment' }, + title_indicator_auc_roc: { zh: 'AUC ROC', en: 'AUC ROC' }, + title_indicator_accuracy: { zh: 'Accuracy', en: 'Accuracy' }, + title_indicator_precision: { zh: 'Precision', en: 'Precision' }, + title_indicator_recall: { zh: 'Recall', en: 'Recall' }, + title_indicator_f1_score: { zh: 'F1 score', en: 'F1 score' }, + title_indicator_log_loss: { zh: 'log loss', en: 'log loss' }, + title_confusion_matrix: { zh: 'Confusion Matrix', en: 'Confusion Matrix' }, + title_confusion_matrix_normalization: { zh: '归一化', en: 'Normalization' }, + title_feature_importance: { + zh: 'Feature Importance(Top 15)', + en: 'Feature Importance(Top 15)', + }, + title_export_model: { + zh: '导出模型', + en: 'export model', + }, + title_export_evaluation_report: { + zh: '导出评估报告', + en: 'export evaluation report', + }, + title_export_prediction_report: { + zh: '导出预测报告', + en: 'export prediction report', + }, + title_export_compare_report: { + zh: '导出对比报告', + en: 'export compare report', + }, + title_export_dataset: { + zh: '导出数据集', + en: 'export dataset', + }, + title_evaluation_target: { zh: '评估对象', en: 'Evaluation target' }, + title_evaluation_report: { zh: '评估报告', en: 'Evaluation report' }, + title_prediction_report: { zh: '预测报告', en: 'Prediction report' }, + title_prediction_result: { zh: '预测结果', en: 'Prediction result' }, + title_evaluation_result: { zh: '评估结果', en: 'Evaluation result' }, + title_compare_report: { zh: '对比报告', en: 'Compare report' }, + title_result_dataset: { zh: '结果数据集', en: 'Result dataset' }, + + title_create_algorithm: { zh: '创建我的算法', en: 'Create algorithm' }, + title_edit_algorithm: { zh: '编辑算法', en: 'Edit algorithm' }, + title_algorithm_file: { zh: '算法文件', en: 'Algorithm file' }, + title_algorithm_label_owner: { zh: '算法文件 - 标签所有者', en: 'Algorithm file - label owner' }, + title_algorithm_no_label_owner: { + zh: '算法文件 - 非标签所有者', + en: 'Algorithm file - no label owner', + }, + title_todo_train_tasks: { zh: '待处理训练任务', en: 'waiting train model task' }, + title_todo_model_job_tasks: { zh: '待处理模型训练', en: 'waiting model job task' }, + title_todo_prediction_tasks: { zh: '待处理预测任务', en: 'waiting prediction model task' }, + title_todo_evaluation_tasks: { zh: '待处理评估任务', en: 'waiting evaluation model task' }, + title_todo_algorithm_tasks: { zh: '待处理算法任务', en: 'waiting algorithm model task' }, + title_create_compare_report: { zh: '创建对比报告', en: 'Create compare report' }, + + title_reject_application: { zh: '拒绝申请', en: 'Reject application' }, + + title_label_owner: { zh: '标签所有者', en: 'Label owner' }, + title_no_label_owner: { + zh: '非标签所有者', + en: 'No label owner', + }, + title_train_model: { + zh: '模型训练', + en: 'Train Model', + }, + title_algorithm_audit: { + zh: '算法审核', + en: 'Algorithm audit', + }, + title_authorization_request: { + zh: '{{peerName}}向您发起「{{name}}」训练授权申请', + en: '{{peerName}} initiates a training authorization application for "{{name}}"', + }, + title_train_model_job: { + zh: '训练任务', + en: 'Train model job', + }, + title_train_job_compare: { + zh: '训练任务对比', + en: 'Train job compare', + }, + + label_model_set: { zh: '模型集', en: 'Model Set' }, + label_model: { zh: '模型', en: 'Model' }, + title_model_set_info: { zh: '模型集信息', en: 'Model set info' }, + title_model_info: { zh: '模型信息', en: 'Model info' }, + label_data_set: { zh: '数据集', en: 'dataset' }, + label_intersection_set: { zh: '数据集', en: 'dataset' }, + label_data_source: { zh: '数据源', en: 'data source' }, + label_data_path: { zh: '数据源', en: 'data source' }, + label_manual_datasource: { zh: '手动输入数据源', en: 'Manually entering the data source' }, + + label_model_set_name: { zh: '模型集名称', en: 'Model set name' }, + label_model_set_comment: { zh: '模型集描述', en: 'Model set comment' }, + label_model_name: { zh: '模型名称', en: 'Model name' }, + label_model_comment: { zh: '模型描述', en: 'Model comment' }, + label_model_train_dataset: { zh: '训练数据集', en: 'Train dataset' }, + label_model_train_comment: { zh: '模型描述', en: 'Train comment' }, + label_resource_template: { zh: '资源模板', en: 'Resource template' }, + + label_image: { zh: '镜像', en: 'Image' }, + + label_file_ext: { zh: '文件扩展名', en: 'File extension' }, + label_file_type: { zh: '文件类型', en: 'File type' }, + label_enable_packing: { zh: '是否优化', en: 'Enable packing' }, + label_ignore_fields: { zh: '忽略字段', en: 'Ignore fields' }, + label_cat_fields: { zh: '类型变量字段', en: 'Categorical fields' }, + label_send_metrics_to_follower: { + zh: '是否将指标发送至 follower', + en: 'send_metrics_to_follower', + }, + label_send_scores_to_follower: { + zh: '是否将预测值发送至 follower', + en: 'send_scores_to_follower', + }, + label_verbosity: { zh: '日志输出等级', en: 'Verbosity' }, + label_label_field: { zh: '标签字段', en: 'Label field' }, + label_load_model_path: { zh: '加载模型路径', en: 'Load model path' }, + label_load_model_name: { zh: '加载模型名称', en: 'Load model name' }, + label_load_checkpoint_filename: { zh: '加载文件名', en: 'Load checkpoint filename' }, + label_load_checkpoint_filename_with_path: { + zh: '加载文件路径', + en: 'Load checkpoint filename with path', + }, + label_verify_example_ids: { zh: '是否检验 example_ids', en: 'verify_example_ids' }, + label_no_data: { zh: '标签方是否无特征', en: 'no_data' }, + label_role: { zh: '训练角色', en: 'Training role' }, + label_steps_per_sync: { zh: '参数同步 step 间隔', en: 'Steps per sync' }, + label_image_version: { zh: '镜像版本号', en: 'image_version' }, + label_num_partitions: { zh: 'num_partitions', en: 'num_partitions' }, + label_shuffle_data_block: { zh: '是否打乱顺序', en: 'shuffle_data_block' }, + + label_train_name: { zh: '训练名称', en: 'Train name' }, + label_federal_type: { zh: '联邦类型', en: 'Federal type' }, + label_train_role_type: { zh: '训练角色', en: 'Train role type' }, + label_radio_logistic: { zh: 'logistic', en: 'logistic' }, + label_radio_mse: { zh: 'mse', en: 'mse' }, + label_param_config: { zh: '参数配置', en: 'Params config' }, + + label_choose_algorithm: { zh: '选择算法', en: 'Choose algorithm' }, + label_algorithm: { zh: '算法', en: 'Algorithm' }, + label_role_type: { zh: '角色', en: 'Role type' }, + label_loss_type: { zh: '损失函数类型', en: 'Loss type' }, + label_radio_label: { zh: '标签方', en: 'Label' }, + label_radio_feature: { zh: '特征方', en: 'Feature' }, + label_learning_rate: { zh: '学习率', en: 'learning_rate' }, + label_max_iters: { zh: '迭代数', en: 'max_iters' }, + label_max_depth: { zh: '最大深度', en: 'max_depth' }, + label_l2_regularization: { zh: 'L2惩罚系数', en: 'l2_regularization' }, + label_max_bins: { zh: '最大分箱数量', en: 'max_bins' }, + label_code_tar: { zh: '代码', en: 'code_tar' }, + label_num_parallel: { zh: '线程池大小', en: 'num_parallel' }, + label_validation_data_path: { zh: '验证数据集地址', en: 'validation_data_path' }, + + label_epoch_num: { zh: 'epoch_num', en: 'epoch_num' }, + label_sparse_estimator: { zh: 'sparse_estimator', en: 'sparse_estimator' }, + label_save_checkpoint_steps: { zh: '保存备份间隔步数', en: 'save_checkpoint_steps' }, + label_save_checkpoint_secs: { zh: '保存备份间隔秒数', en: 'save_checkpoint_secs' }, + label_optimize_target: { zh: '训练目标', en: 'Train target' }, + label_model_feature: { zh: '不入模特征', en: 'Model feature' }, + + label_resource_template_type: { zh: '资源模板', en: 'Resource template' }, + label_radio_high: { zh: '大', en: 'High' }, + label_radio_medium: { zh: '中', en: 'Medium' }, + label_radio_low: { zh: '小', en: 'Low' }, + label_radio_custom: { zh: '自定义', en: 'Custom' }, + + label_master_replicas: { zh: 'Master 实例数', en: 'master_replicas' }, + label_master_cpu: { zh: 'Master CPU数量', en: 'master_cpu' }, + label_master_mem: { zh: 'Master内存大小', en: 'master_mem' }, + label_ps_replicas: { zh: 'PS 实例数', en: 'ps_replicas' }, + label_ps_cpu: { zh: 'PS CPU数量', en: 'ps_cpu' }, + label_ps_mem: { zh: 'PS内存大小', en: 'ps_mem' }, + label_ps_num: { zh: 'PS数量', en: 'ps_num' }, + label_worker_replicas: { zh: 'Worker 实例数', en: 'worker_replicas' }, + label_worker_cpu: { zh: 'Worker CPU数量', en: 'worker_cpu' }, + label_worker_mem: { zh: 'Worker内存大小', en: 'worker_mem' }, + label_worker_num: { zh: 'Worker数量', en: 'worker_num' }, + + label_is_share_model_evaluation_index: { + zh: '共享模型评价指标', + en: 'Share model evaluation index', + }, + label_is_share_model_evaluation_report: { + zh: '共享模型评价报告', + en: 'Share model evaluation report', + }, + label_is_share_offline_prediction_result: { + zh: '共享离线预测结果', + en: 'Share offline prediction result', + }, + label_is_allow_coordinator_parameter_tuning: { + zh: '允许合作伙伴自行发起调参任务', + en: 'Allow coordinator to initiate tuning tasks on their own', + }, + label_is_auto_create_compare_report: { + zh: '自动生成对比报告', + en: 'Auto create compare report', + }, + + label_model_version: { zh: '版本列表', en: 'Model version' }, + label_model_version_count: { zh: '共{{count}}个', en: '{{count}} item' }, + + label_score_threshold: { zh: 'Score threshold = {{number}}', en: 'Score threshold = {{number}}' }, + + label_download_model_package: { + zh: '下载模型包', + en: 'Download model package', + }, + label_download_evaluation_report: { + zh: '下载评估任务', + en: 'Download evaluation report', + }, + label_download_prediction_report: { + zh: '下载预测报告', + en: 'Download prediction report', + }, + label_download_compare_report: { + zh: '下载对比报告', + en: 'Download compare report', + }, + + label_evaluation_task_name: { zh: '评估任务名称', en: 'Evaluation task name' }, + label_evaluation_dataset: { zh: '评估数据集', en: 'Evaluation dataset' }, + label_evaluation_comment: { zh: '评估任务描述', en: 'Comment' }, + label_prediction_task_name: { zh: '预测任务名称', en: 'Prediction task name' }, + label_prediction_dataset: { zh: '预测数据集', en: 'Prediction dataset' }, + label_prediction_comment: { zh: '预测任务描述', en: 'Comment' }, + + label_algorithm_name: { zh: '算法名称', en: 'Algorithm name' }, + label_algorithm_type: { zh: '算法类型', en: 'Algorithm type' }, + label_type: { zh: '类型', en: 'Type' }, + label_federation_type: { zh: '联邦方式', en: 'Federation type' }, + label_import_type: { zh: '导入方式', en: 'Import type' }, + label_algorithm_file_path: { zh: '算法文件路径', en: 'Algorithm file path' }, + label_select_from_local_file: { zh: '从本地文件中选择', en: 'Select from local file' }, + label_radio_cross_sample: { zh: '跨样本', en: 'Cross sample' }, + label_radio_cross_feature: { zh: '跨特征', en: 'Cross feature' }, + label_radio_path_import: { zh: '路径导入', en: 'Path import' }, + label_radio_local_import: { zh: '本地导入', en: 'local import' }, + label_algorithm_type_tree_model: { zh: '树模型', en: 'Tree model' }, + label_algorithm_type_nn_model: { zh: 'NN模型', en: 'NN model' }, + + label_role_type_leader: { zh: 'leader', en: 'Leader' }, + label_role_type_follower: { zh: 'follower', en: 'Follower' }, + + label_name: { zh: '名称', en: 'name' }, + label_comment: { zh: '描述', en: 'desc' }, + label_reject_reason: { zh: '拒绝申请', en: 'reject reaso' }, + label_pass: { zh: '已通过申请', en: 'Application passed' }, + label_reject: { zh: '已拒绝申请', en: 'Application rejected' }, + + label_start_task: { zh: ' 发起了', en: ' start' }, + label_stopped_at: { zh: '结束时间', en: 'Stopped at' }, + label_started_at: { zh: '开始时间', en: 'Started at' }, + label_output_model: { zh: '输出模型', en: 'Exported model' }, + + suffix_train_tasks: { zh: '的训练任务', en: 'train task' }, + suffix_model_job_tasks: { zh: '的模型训练', en: 'model job task' }, + suffix_prediction_tasks: { zh: '的预测任务', en: 'prediction task' }, + suffix_evaluation_tasks: { zh: '的评估任务', en: 'evaluation task' }, + suffix_algorithm_tasks: { zh: '的算法任务', en: 'algorithm task' }, + + suffix_go_back_to_index: { + zh: '{{time}}S 后自动回到首页', + en: 'After {{time}}S,go back to index', + }, + + placeholder_model_set_name: { zh: '请输入模型集名称', en: 'Please input' }, + placeholder_model_set_comment: { + zh: '支持1~100位可见字符,且只包含大小写字母、中文、数字、中划线、下划线', + en: + 'Supports 1-100 visible characters, and only contains uppercase and lowercase letters, Chinese characters, numbers, underscores, and underscores', + }, + placeholder_model_name: { zh: '请填写', en: 'Please input' }, + placeholder_model_train_dataset: { zh: '请选择', en: 'Please select' }, + placeholder_model_train_comment: { + zh: '支持1~100位可见字符,且只包含大小写字母、中文、数字、中划线、下划线', + en: + 'Supports 1-100 visible characters, and only contains uppercase and lowercase letters, Chinese characters, numbers, underscores, and underscores', + }, + placeholder_input: { zh: '请填写', en: 'Please input' }, + placeholder_select: { zh: '请选择', en: 'Please select' }, + placeholder_comment: { + zh: '最多为 200 个字符', + en: 'Up to 200 characters', + }, + placeholder_data_source: { zh: '请输入数据源', en: 'Please input dataSource' }, + + msg_model_set_name_required: { zh: '模型集名称为必填项', en: 'Model set name is required' }, + msg_model_name_required: { zh: '模型名称为必填项', en: 'Model name is required' }, + msg_model_train_dataset: { zh: '训练数据集为必填项', en: 'Train dataset is required' }, + msg_required: { zh: '必填项', en: 'Required' }, + msg_modify_model_name_success: { zh: '修改模型名称成功', en: 'Modify model name success' }, + msg_modify_model_comment_success: { zh: '修改模型描述成功', en: 'Modify model comment success' }, + msg_delete_model_success: { zh: '删除模型成功', en: 'Delete model success' }, + msg_file_required: { zh: '请上传文件', en: 'Please upload file' }, + msg_quit_train_model_title: { + zh: '确认要退出「发起模型训练流程」?', + en: 'Are you sure you want to exit the "Initiate Model Training Process"?', + }, + msg_quit_train_model_content: { + zh: '退出后,当前所填写的信息将被清空。', + en: 'After logging out, the information currently filled in will be cleared.', + }, + msg_quit_evaluation_model_title: { + zh: '确认要退出「{{name}}」?', + en: 'Are you sure you want to exit the "{{name}}"?', + }, + msg_quit_prediction_model_title: { + zh: '确认要退出「{{name}}」?', + en: 'Are you sure you want to exit the "{{name}}"?', + }, + msg_quit_form_create: { + zh: '确认要退出?', + en: 'Are you sure you want to exit the "{{name}}"?', + }, + msg_quit_form_edit: { + zh: '确认要退出编辑「{{name}}」?', + en: 'Are you sure you want to exit the "{{name}}" editing?', + }, + + msg_please_select_evaluation_target: { + zh: '请选择评估对象', + en: 'Please select evaluation target', + }, + msg_model_compare_count_limit: { + zh: '至少{{min}}个,至多{{max}}个评估对象', + en: 'At least {{min}} and at most {{max}} evaluation target for compare', + }, + msg_title_confirm_delete_model: { + zh: '确认要删除该模型吗?', + en: 'Are you sure you want to delete the model?', + }, + msg_content_confirm_delete_model: { + zh: '删除后,不影响正在使用该模型的任务,使用该模型的历史任务不能再正常运行,请谨慎删除', + en: + 'After deleting, it will not affect the tasks that are using the model, and the historical tasks using the model can no longer run normally, please delete with caution', + }, + msg_create_model_job_success: { + zh: '创建成功,等待合作伙伴授权', + en: 'Created successfully, waiting for partner authorization', + }, + msg_create_model_job_success_peer: { + zh: '授权完成,等待合作伙伴运行', + en: 'Authorized successfully, waiting for partner run', + }, + msg_create_evaluation_job_success_peer: { + zh: '已授权模型评估,任务开始运行', + en: 'Model evaluation has been authorized, task starts to run', + }, + msg_edit_model_job_success: { + zh: '保存成功', + en: 'Save success', + }, + msg_launch_model_job_success: { + zh: '发起成功', + en: 'Launch success', + }, + msg_launch_model_job_no_peer_auth: { + zh: '合作伙伴未授权,不能发起新任务', + en: 'The partner is not authorized to launch a new task', + }, + msg_stop_model_job_success: { + zh: '终止成功', + en: 'Stop model job success', + }, + msg_title_confirm_delete_model_job_group: { + zh: '确认要删除「{{name}}」?', + en: 'Are you sure you want to delete "{{name}}"?', + }, + msg_content_confirm_delete_model_job_group: { + zh: '删除后,该模型训练下的所有信息无法复原,请谨慎操作', + en: + 'After deletion, all information under the model training cannot be recovered, please operate with caution', + }, + msg_can_not_edit_peer_config: { + zh: '合作伙伴未授权,不能编辑合作伙伴配置', + en: 'The partner is not authorized to edit the partner configuration', + }, + msg_stop_warnning: { + zh: '确认要终止「{{name}}」?', + en: 'Are you sure to stop {{name}}', + }, + msg_stop_warning_text: { + zh: '终止后,该评估任务将无法重新运行,请谨慎操作', + en: 'After stopping, the evaluation task will not be able to run again. Please be careful.', + }, + msg_stop_successful: { + zh: '终止成功', + en: 'Stop Successful', + }, + msg_delete_warnning: { + zh: '确认要删除「{{name}}」?', + en: 'Are you sure to delete {{name}}', + }, + msg_delete_evaluation_warning_text: { + zh: '删除后,该评估任务及信息将无法恢复,请谨慎操作', + en: + 'After deleting, the evaluation task and information will not be able to recover. Please be careful.', + }, + msg_delete_prediction_warning_text: { + zh: '删除后,该预测任务及信息将无法恢复,请谨慎操作', + en: + 'After deleting, the prediction task and information will not be able to recover. Please be careful.', + }, + msg_evaluation_invitation_text: { + zh: '向您发起「{{name}}」的模型评估授权申请', + en: 'invites you to evaluate the model "{{name}}"', + }, + msg_prediction_invitation_text: { + zh: '向您发起「{{name}}」的离线预测授权申请', + en: 'invites you to predict the model "{{name}}"', + }, + msg_target_model_not_found: { + zh: '目标模型不存在,请联系合作伙伴重新选择', + en: 'Target model does not exist, please contact the partner to re-select', + }, + msg_participant_tip_text: { + zh: '合作方均同意授权时,{{module}}任务将自动运行', + en: 'All partners agree to authorize, {{module}} task will automatically run', + }, + msg_time_required: { zh: '请选择时间', en: 'Set time please' }, + msg_model_job_edit_success: { + zh: '编辑成功', + en: 'Edit success', + }, + + hint_model_set_form_modal: { + zh: '模型集名称和描述将同步至所有合作伙伴。', + en: 'The model set name and description will be synchronized to all partners.', + }, + hint_no_share_offline_prediction_result: { + zh: '对方已选择不将预测结果分享给你', + en: 'The other party has chosen not to share the prediction result with you', + }, + hint_algorithm_audit: { + zh: '请注意检查算法代码的细节,这涉及到您的信息隐私等安全问题。', + en: + 'Please pay attention to check the details of the algorithm code, which involves security issues such as your information privacy.', + }, + + no_result: { + zh: '暂无模型版本,请 ', + en: 'No model , please ', + }, + + step_global: { zh: '全局配置', en: 'Global config' }, + step_param: { zh: '参数配置', en: 'Params config' }, + + step_coordinator: { zh: '本侧配置', en: 'Coordinator config' }, + step_participant: { zh: '合作伙伴配置', en: 'Participant config' }, + + tip_radio_logistic: { zh: '用于分类任务', en: 'For classification tasks' }, + tip_radio_mse: { zh: '用于回归任务', en: 'For regression tasks' }, + tip_choose_algorithm: { + zh: '后续模型训练将沿用该算法,只可调整算法版本和算法参数', + en: + 'Subsequent model training will continue to use this algorithm, and only the algorithm version and algorithm parameters can be adjusted', + }, + tip_share_model_evaluation_index: { + zh: '共享后合作伙伴能够获得模型训练任务相关的数据指标', + en: 'After sharing, partners can obtain data indicators related to model training tasks', + }, + tip_share_model_evaluation_report: { + zh: '共享后合作伙伴能够获得模型评估任务相关的数据指标', + en: 'After sharing, partners can obtain data indicators related to model evaluation tasks', + }, + tip_share_offline_prediction_result: { + zh: '确认要共享吗?合作伙伴将获得离线预测结果,请注意风险。', + en: + 'Are you sure you want to share? Partners will get offline prediction results, please be aware of risks.', + }, + tip_allow_coordinator_parameter_tuning: { + zh: + '确认要允许吗?合作伙伴可在不改变算法和数据的情况下,自行发起调参任务,不需要获得您的授权。', + en: + 'Are you sure you want to allow it? Partners can initiate adjustment tasks on their own without changing the algorithm and data, without your authorization.', + }, + tip_model_evaluation: { + zh: '可对单个模型进行评估', + en: 'Evaluate single model', + }, + tip_no_share_model_evaluation_report: { + zh: '对方已选择不与您共享报告结果,如果相关诉求,请联系对方进行协商', + en: + 'The other party has chosen not to share the results of the report with you. If you have relevant claims, please contact the other party for negotiation', + }, + tip_no_tip_share_offline_prediction_result: { + zh: '对方已选择不与您共享离线预测结果,如果相关诉求,请联系对方进行协商', + en: + 'The other party has chosen not to share the offline prediction results with you. If you have a request, please contact the other party for negotiation', + }, + tip_only_show_read_model: { + zh: '仅展示所有参与方完成配置的模型', + en: 'Only show models that have been configured by all participants', + }, + tip_if_data_error_please_check_template: { + zh: '如数据显示异常,请检查自定义模板是否符合编写规范', + en: + 'If the data is abnormal, please check whether the custom template complies with the writing specifications', + }, + tip_please_check_template: { + zh: '请检查自定义模板是否符合编写规范', + en: 'Please check whether the custom template complies with the writing specifications', + }, + tip_model_compare_count_limit: { + zh: '可对至少{{min}}个,至多{{max}}个评估对象进行对比', + en: 'You can select at least {{min}} and at most {{max}} evaluation target for compare', + }, + tip_confusion_matrix_normalization: { + zh: '归一化后将展示样本被预测成各类别的百分比', + en: 'After normalization, it will show the percentage of samples predicted into each category', + }, + tip_training_metrics_visibility: { + zh: '训练报告仅自己可见,如需共享报告,请前往训练详情页开启', + en: + 'The training report is only visible to you. If you want to share the report, please go to the training details page', + }, + tip_agree_authorization: { + zh: '授权后,发起方可以运行模型训练并修改参与方的训练参数,训练指标将对所有参与方可见', + en: + 'After agreeing to the authorization, the applicant can modify its own parameter configuration and resources on the opposite side and automatically run the model training, and the training indicators will be visible to all participants', + }, + tip_learning_rate: { + zh: '使用损失函数的梯度调整网络权重的超参数,​ 推荐区间(0.01-1]', + en: 'The hyperparameter of the learning rate, recommended range (0.01-1]', + }, + tip_max_iters: { + zh: '该模型包含树的数量,推荐区间(5-20)', + en: 'The number of trees in the model, recommended range (5-20)', + }, + tip_max_depth: { + zh: '树模型的最大深度,用来控制过拟合,推荐区间(4-7)', + en: 'The maximum depth of the tree model, used to control overfitting, recommended range (4-7)', + }, + tip_l2_regularization: { + zh: '对节点预测值的惩罚系数,推荐区间(0.01-10)', + en: 'The penalty coefficient of the prediction value of the node, recommended range (0.01-10)', + }, + tip_max_bins: { + zh: '离散化连续变量,可以减少数据稀疏度,一般不需要调整', + en: + 'Discretization of continuous variables, can reduce the data sparsity, generally do not need to adjust', + }, + tip_num_parallel: { + zh: '建议与CPU核数接近', + en: 'Recommended to be close to the number of CPU cores', + }, + tip_epoch_num: { + zh: '指一次完整模型训练需要多少次Epoch,一次Epoch是指将全部训练样本训练一遍', + en: + 'The number of Epochs required for a complete model training, one Epoch is one time training all samples', + }, + tip_verbosity: { + zh: '有 0、1、2、3 四种等级,等级越大日志输出的信息越多', + en: + 'There are four levels of verbosity, the level of which increases the amount of information output', + }, + tip_image: { + zh: '用于训练的镜像', + en: 'Image used for training', + }, + tip_file_ext: { + zh: '目前支持.data, .csv or .tfrecord', + en: 'Currently supports .data, .csv or .tfrecord', + }, + tip_file_type: { + zh: '目前支持csv or tfrecord', + en: 'Currently supports csv or tfrecord', + }, + tip_enable_packing: { + zh: '提高计算效率,true 为优化,false 为不优化。', + en: 'Increase the efficiency of computation, true is open, false is close.', + }, + tip_ignore_fields: { + zh: '不参与训练的字段', + en: 'Fields not included in training', + }, + tip_cat_fields: { + zh: '类别变量字段,训练中会特别处理', + en: 'Category fields, training will be specially processed', + }, + tip_send_scores_to_follower: { + zh: '是否将预测值发送至follower侧,fasle代表否,ture代表是', + en: 'Whether to send the predicted value to the follower side, false is no, true is yes', + }, + tip_send_metrics_to_follower: { + zh: '是否将指标发送至follower侧,fasle代表否,ture代表是', + en: 'Whether to send the indicators to the follower side, false is no, true is yes', + }, + tip_verify_example_ids: { + zh: '是否检验example_ids,一般情况下训练数据有example_ids,fasle代表否,ture代表是', + en: + 'Whether to verify example_ids, generally training data has example_ids, false is no, true is yes', + }, + tip_no_data: { + zh: '针对标签方没有特征的预测场景,fasle代表有特征,ture代表无特征。', + en: + 'For the scenario where the label does not have features, false is features, true is no features.', + }, + tip_label_field: { + zh: '用于指定label', + en: 'Label field', + }, + tip_load_model_name: { + zh: '评估和预测时,根据用户选择的模型,确定该字段的值。', + en: + 'When evaluating and predicting, determine the value of this field according to the user selection of the model.', + }, + tip_shuffle_data_block: { + zh: '打乱数据顺序,增加随机性,提高模型泛化能力', + en: 'Shuffle the data order, increase the randomness, improve the model generality', + }, + tip_save_checkpoint_secs: { + zh: '模型多少秒保存一次', + en: 'The model is saved every n seconds', + }, + tip_save_checkpoint_steps: { + zh: '模型多少step保存一次', + en: 'The model is saved every n steps', + }, + tip_load_checkpoint_filename: { + zh: '加载文件名,用于评估和预测时选择模型', + en: 'Load file name, used to select the model when evaluating and predicting', + }, + tip_load_checkpoint_filename_with_path: { + zh: '加载文件路径,用于更细粒度的控制到底选择哪个时间点的模型', + en: 'Load file path, used to control the selection of the model at a fine-grained level', + }, + tip_sparse_estimator: { + zh: '是否使用火山引擎的SparseEstimator,由火山引擎侧工程师判定,客户侧默认都为false', + en: + 'Whether to use the SparseEstimator of the engine, determined by the engine side engineer, the default is false', + }, + tip_steps_per_sync: { + zh: '用于指定参数同步的频率,比如step间隔为10,也就是训练10个batch同步一次参数。', + en: 'Frequency at which parameters are synchronized, for example, 10 steps per batch', + }, + tip_feature_importance: { + zh: '数值越高,表示该特征对模型的影响越大', + en: 'The higher the value, the greater the influence of this feature on the model', + }, + tip_metric_is_publish: { + zh: '开启后,将与合作伙伴共享本次训练指标', + en: 'After opening, the training indicators will be shared with participant', + }, + + state_success: { zh: '成功', en: 'Success' }, + state_failed: { zh: '失败', en: 'Fail' }, + state_ready_to_run: { zh: '待运行', en: 'Ready to run' }, + state_paused: { zh: '暂停', en: 'Pause' }, + state_running: { zh: '运行中', en: 'Running' }, + state_invalid: { zh: '已禁用', en: 'Invalid' }, + state_unknown: { zh: '状态未知', en: 'Unknown' }, + + label_model_type_unspecified: { zh: '模型集', en: 'Unspecified' }, + label_model_type_tree: { zh: '树模型', en: 'Tree model' }, + label_model_type_nn: { zh: 'NN模型', en: 'NN model' }, + label_model_source_from_model_job: { + zh: '{{modelJobName}}训练任务', + en: 'train job {{modelJobName}}', + }, + label_model_source_from_workflow: { + zh: '{{workflowName}}工作流-{{jobName}}任务', + en: '{{workflowName}} workflow-{{jobName}} job', + }, + label_enable_schedule_train: { zh: '启用定时重训', en: 'Enable schedule train' }, + label_metric_is_publish: { zh: '共享训练报告', en: 'Share the training report' }, + + name_model: { zh: '模型', en: 'model' }, + name_model_set: { zh: '模型集', en: 'model set' }, + name_algorithm: { zh: '算法', en: 'algorithm' }, + name_evaluation_job: { zh: '评估任务', en: 'evaluation job' }, + name_prediction_job: { zh: '预测任务', en: 'prediction job' }, + name_compare_report: { zh: '对比报告', en: 'compare report' }, + + form_field_name: { + zh: '名称', + en: 'name', + }, + form_field_name_placeholder: { + zh: '请输入名称', + en: 'Please enter the name of the evaluation', + }, + form_field_comment: { + zh: '描述', + en: 'description', + }, + form_field_comment_placeholder: { + zh: '最多为 200 个字符', + en: 'Up to 200 characters', + }, + form_field_job_type: { + zh: '联邦配置', + en: 'Federal type', + }, + form_field_model_id: { + zh: '模型', + en: 'model', + }, + form_field_dataset: { + zh: '数据集', + en: 'dataset', + }, + form_field_config: { + zh: '资源模板', + en: 'resource template', + }, + form_section_evaluation_config: { + zh: '评估配置', + en: 'Evaluation configuration', + }, + form_section_prediction_config: { + zh: '预测配置', + en: 'Prediction configuration', + }, + form_section_resource: { + zh: '资源配置', + en: 'Resource configuration', + }, + form_btn_submit: { + zh: '提交并发送', + en: 'Submit and send', + }, + form_section_resource_tip: { + zh: 'NN模型的资源配置', + en: 'Resource configuration of NN model', + }, + form_schedule_train_tip: { + zh: '启用该功能将间隔性地重跑训练任务,且每次训练都将从最新的可用版本开始', + en: + 'Enabling this feature reruns training tasks at intervals, with each session starting with the latest available version', + }, +}; + +export default separateLng(modelCenter); diff --git a/web_console_v2/client/src/i18n/resources/modules/modelServing.ts b/web_console_v2/client/src/i18n/resources/modules/modelServing.ts new file mode 100644 index 000000000..b32683669 --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/modelServing.ts @@ -0,0 +1,130 @@ +import { separateLng } from 'i18n/helpers'; + +const modelServing = { + menu_label_model_serving: { zh: '在线服务', en: 'Online serving' }, + + btn_inspect_logs: { zh: '查看日志', en: 'Inspect log' }, + btn_create_model_serving: { zh: '创建服务', en: 'Create model serving' }, + btn_check: { zh: '点击校验', en: 'Check' }, + btn_send_to_peer_side: { zh: '发送至对侧', en: 'Send to peer side' }, + + service_id: { zh: 'ID', en: 'ID' }, + col_state: { zh: '状态', en: 'State' }, + col_name: { zh: '名称', en: 'Name' }, + col_instance_amount: { zh: '实例数量', en: 'Instance amount' }, + col_invoke_privilege: { zh: '调用权限', en: 'Invoke privilege' }, + col_model_type: { zh: '模型类型', en: 'Model type' }, + col_model_id: { zh: '模型ID', en: 'Model ID' }, + col_model_name: { zh: '模型名称', en: 'Model name' }, + col_cpu: { zh: 'CPU', en: 'CPU' }, + col_men: { zh: '内存', en: 'Memory' }, + col_instance_id: { zh: '实例ID', en: 'Instance ID' }, + + title_create_model_serving: { zh: '创建服务', en: 'Create model set' }, + title_edit_model_serving: { zh: '编辑服务', en: 'Create model set' }, + info_receiver_create_model_serving: { + zh: '纵向模型服务仅发起方可查看调用地址和 Signature', + en: 'Only the sender can view the call address and signature', + }, + + label_name: { zh: '在线服务名称', en: 'Name' }, + label_comment: { zh: '在线服务描述', en: 'Desc' }, + label_type: { zh: '类型', en: 'type' }, + label_model_type: { zh: '联邦类型', en: 'Create model set' }, + label_model_type_vertical: { zh: '纵向联邦', en: 'Create model set' }, + label_model_type_horizontal: { zh: '横向联邦', en: 'Create model set' }, + label_model_inference_available: { zh: '可调用', en: 'Callable' }, + label_model_inference_unavailable: { zh: '不可调用', en: 'Uncallable' }, + label_instance_spec: { zh: '实例规格', en: 'Instance Specifications' }, + label_instance_amount: { zh: '实例数', en: 'Instance amount' }, + label_local_model_feature: { zh: '本测入模特征', en: 'Local model feature' }, + label_local_center_result: { zh: '本侧中间结果', en: 'Local center result' }, + label_peer_center_result: { zh: '对侧中间结果', en: 'Local center result' }, + label_feature_dataset: { zh: '特征数据集', en: 'Feature dataset' }, + label_model_set: { zh: '模型集', en: 'Model Set' }, + label_model: { zh: '模型', en: 'Model' }, + label_tab_user_guide: { zh: '调用指南', en: 'User guide' }, + label_tab_instance_list: { zh: '实例列表', en: 'Instance list' }, + label_input_params: { zh: '输入参数(仅本侧)', en: 'Input params' }, + label_output_params: { zh: '输出参数', en: 'Output params' }, + label_api_url: { zh: '访问地址', en: 'API URL' }, + label_local_feature: { zh: '本侧特征', en: 'Local feature' }, + label_signature: { zh: 'Signature', en: 'Signature' }, + + placeholder_searchbox_name: { zh: '请输入名称查询', en: 'Please input name' }, + placeholder_name: { zh: '请输入在线服务名称', en: 'Please input online serving name' }, + placeholder_input: { zh: '请输入', en: 'Please input' }, + placeholder_select: { zh: '请选择', en: 'Please select' }, + placeholder_select_model: { zh: '请选择模型', en: 'Please select model' }, + placeholder_comment: { + zh: '最多为 200 个字符', + en: 'Up to 200 characters', + }, + + msg_required: { zh: '必填项', en: 'Required' }, + msg_check_fail: { + zh: '请输入正确的特征、中间结果或特征数据集', + en: 'Please enter the correct feature, intermediate result or feature data set', + }, + + msg_title_confirm_delete: { zh: '确认要删除「{{name}}」?', en: 'Confirm to delete <{{name}}>?' }, + msg_content_confirm_delete: { + zh: '一旦删除,在线服务相关数据将无法复原,请谨慎操作', + en: 'The delete operation cannot be recovered, please operate with caution', + }, + msg_edit_service_desc: { zh: '在线服务信息', en: 'Service Info' }, + + tip_local_feature: { + zh: '请正确选择本侧特征,特征选择会影响推理结果', + en: + 'Please select the local feature correctly, the feature selection will affect the inference result', + }, + tip_instance_range: { + zh: '实例数范围1~100', + en: 'The number of instances ranges from 1 to 100', + }, + + state_loading: { zh: '部署中', en: 'Loading' }, + state_unloading: { zh: '删除中', en: 'Unloading' }, + state_running: { zh: '运行中', en: 'Running' }, + state_unknown: { zh: '异常', en: 'Unknown' }, + state_pending_accept: { zh: '待合作伙伴配置', en: 'Wait for accepting' }, + state_waiting_config: { zh: '待合作伙伴配置', en: 'Wait for config' }, + state_deleted: { zh: '异常', en: 'deleted' }, + tip_deleted: { zh: '对侧已经删除', en: 'Another side has been deleted' }, + + state_check_waiting: { zh: '待校验', en: 'Waiting' }, + state_check_success: { zh: '校验成功', en: 'Success' }, + state_check_fail: { zh: '校验不通过', en: 'Fail' }, + + cannot_create_service_without_models: { + zh: '因对应模型不存在,请选择两侧均存在的纵向联邦模型进行部署', + en: + 'Because there is no deployment of the corresponding model, please reselect the longitudinal federal model that exists on both sides.', + }, + + msg_todo_model_serving_tasks: { + zh: '{{count}} 条待处理在线服务', + en: '{{count}} tasks to be processed', + }, + + msg_title_todo_model_serving_tasks: { + zh: '待处理在线服务', + en: 'Tasks to be processed', + }, + + msg_suffix_model_serving_tasks: { + zh: ' 的在线任务', + en: "'s service", + }, + msg_duplicate_service_name: { + zh: '在线服务名称已存在', + en: 'Service name already exists', + }, + msg_duplicate_participant_service_name: { + zh: '合作伙伴侧在线服务名称已存在', + en: 'service name already exists on the participant side', + }, +}; + +export default separateLng(modelServing); diff --git a/web_console_v2/client/src/i18n/resources/modules/operation_maintenance.ts b/web_console_v2/client/src/i18n/resources/modules/operation_maintenance.ts new file mode 100644 index 000000000..f9e18677c --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/operation_maintenance.ts @@ -0,0 +1,18 @@ +import { separateLng } from 'i18n/helpers'; + +const operation_maintenance = { + btn_submit: { zh: '提交', en: 'Submit' }, + btn_reset: { zh: '重置', en: 'Reset' }, + + col_job_name: { zh: 'K8s Job名称', en: 'job_name' }, + col_job_type: { zh: '测试类型', en: 'job_type' }, + col_operation: { zh: '测试状态', en: 'Operation' }, + + job_detail: { zh: '工作详情', en: 'job_detail' }, + + state_check_success: { zh: '校验成功', en: 'Success' }, + state_check_fail: { zh: '校验不通过', en: 'Fail' }, + state_check_repeat: { zh: '工作已存在,请更改name_prefix字段', en: 'Repeat' }, +}; + +export default separateLng(operation_maintenance); diff --git a/web_console_v2/client/src/i18n/resources/modules/trustedCenter.ts b/web_console_v2/client/src/i18n/resources/modules/trustedCenter.ts new file mode 100644 index 000000000..810f8e27e --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/trustedCenter.ts @@ -0,0 +1,147 @@ +import { separateLng } from 'i18n/helpers'; + +const trusted_center = { + btn_create_trusted_computing: { zh: '创建可信计算', en: 'Create trusted computing' }, + btn_authorized: { zh: '授权', en: 'Authorized' }, + btn_unauthorized: { zh: '撤销', en: 'Unauthorized' }, + btn_submit_apply: { zh: '提交并申请', en: 'Submit and apply' }, + btn_confirm_authorization: { zh: '确认授权', en: 'Confirm authorization' }, + btn_submit_and_run: { zh: '提交并执行', en: 'Submit and run' }, + btn_cancel: { zh: '取消', en: 'Cancel' }, + btn_post_task: { zh: '发起任务', en: 'Post task' }, + btn_termination: { zh: '终止', en: 'termination' }, + btn_export: { zh: '导出', en: 'Export' }, + btn_pass: { zh: '通过', en: 'Pass' }, + btn_reject: { zh: '拒绝', en: 'Reject' }, + btn_go_back: { zh: '返回', en: 'Go back' }, + btn_inspect_logs: { zh: '查看日志', en: 'Inspect log' }, + + label_trusted_center: { zh: '可信中心', en: 'Trusted Center' }, + label_coordinator_self: { zh: '本方', en: 'this party' }, + label_computing_name: { zh: '计算名称', en: 'Computing name' }, + label_description: { zh: '描述', en: 'Description' }, + label_algorithm_type: { zh: '算法类型', en: 'Algorithm Type' }, + label_algorithm_select: { zh: '选择算法', en: 'Algorithm select' }, + label_our_dataset: { zh: '我方数据集', en: 'Our dataset' }, + label_partner_one_dataset: { zh: '合作伙伴 1 数据集', en: 'Partner 1 dataset' }, + label_partner_two_dataset: { zh: '合作伙伴 2 数据集', en: 'Partner 2 dataset' }, + label_resource_template: { zh: '资源模板', en: 'Resource Template' }, + label_resource_config_params_detail: { + zh: '资源配置参数详情', + en: 'Resource config params detail', + }, + label_trusted_job_comment: { zh: '任务备注', en: 'Trusted job comment' }, + + placeholder_search_task: { zh: '输入任务名称', en: 'Enter task name' }, + placeholder_input: { zh: '请输入', en: 'Please Input' }, + placeholder_select: { zh: '请选择', en: 'Please select' }, + placeholder_select_algo_type: { zh: '请选择算法类型', en: 'Please select algorithm type' }, + placeholder_input_comment: { zh: '最多为200个字符', en: 'Maxsize 200 words' }, + placeholder_select_algo: { zh: '请选择算法', en: 'Please select algorithm' }, + placeholder_select_algo_version: { zh: '请选择算法版本', en: 'Please select algorithm version' }, + placeholder_select_dataset: { + zh: '请选择一发布的原始/结果数据集', + en: 'Please select released original/resulting dataset', + }, + placeholder_trusted_job_set_comment: { + zh: '支持1~100位可见字符,且只包含大小写字母、中文、数字、中划线、下划线', + en: + 'Supports 1-100 visible characters, and only contains uppercase and lowercase letters, Chinese characters, numbers, underscores, and underscores', + }, + + title_trusted_job_create: { zh: '创建可信计算', en: 'Create trusted computing' }, + title_trusted_job_edit: { zh: '编辑可信计算', en: 'Edit trusted computing' }, + title_authorization_request: { + zh: '{{peerName}}向您发起「{{name}}」可信计算申请', + en: '{{peerName}} initiates a trusted computing authorization application for "{{name}}"', + }, + title_base_info: { zh: '基本信息', en: 'Base info' }, + title_resource_config: { zh: '资源配置', en: 'Resource config' }, + title_computing_config: { zh: '计算配置', en: 'Computing config' }, + title_computing_task_list: { zh: '计算任务列表', en: 'Computing task list' }, + title_trusted_job_detail: { zh: '{{name}} 详情', en: '{{name}} Detail' }, + title_instance_info: { zh: '实例信息', en: 'Instance information' }, + title_todo_computing_tasks: { zh: '待处理计算任务', en: 'Pending computing job' }, + title_initiate_trusted_job: { zh: '发起任务 {{name}}', en: 'Initiate trusted job {{name}}' }, + title_edit_trusted_job: { zh: '编辑任务 {{name}}', en: 'Edit trusted job {{name}}' }, + title_dataset_export_application: { + zh: '「{{name}}」 的导出申请', + en: "「{{name}}」's export application ", + }, + title_export_application: { + zh: '数据集导出申请', + en: 'Dataset export application', + }, + title_passed: { + zh: '已通过申请', + en: 'Application passed', + }, + title_rejected: { + zh: '已拒绝申请', + en: 'Application rejected', + }, + title_status_tip: { + zh: '{{second}}S 后自动返回', + en: '{{second}} seconds later, automatically go back', + }, + + tip_agree_authorization: { + zh: '授权后,发起方可以运行可信计算任务', + en: 'After agreeing to the authorization, the applicant can run trusted computing job', + }, + + msg_required: { zh: '必填项', en: 'Required' }, + msg_trusted_computing_create: { + zh: '合作伙伴均同意后,任务将自动运行,计算完成后的计算结果授权后才可以导出到本地', + en: + 'After the partners agree, the task will run automatically, and the calculation results after the calculation is completed can be exported to the local machine after authorization.', + }, + unauthorized_confirm_title: { + zh: '确认撤销对「{{name}}」的授权?', + en: 'Are you sure to unauthorized trusted computing "{{name}}" ?', + }, + msg_todo_computing_tasks: { zh: '待处理计算任务 {{count}}' }, + msg_prefix_computing_tasks: { zh: '发起了', en: 'sent' }, + msg_suffix_computing_tasks: { zh: '的计算任务', en: "'s computing job" }, + msg_dataset_export_comment: { + zh: '该数据集为可信中心安全计算生成的计算结果,导出时需各合作伙伴审批通过', + en: + "The dataset is the calculation result generated by the trusted center's secure calculation, and it needs the approval of each partner when exporting", + }, + msg_create_success: { zh: '创建成功', en: 'Create success' }, + msg_auth_success: { zh: '授权成功', en: 'Authorize success' }, + msg_publish_success: { zh: '发布成功', en: 'Publish success' }, + msg_delete_success: { zh: '删除成功', en: 'Delete success' }, + msg_edit_success: { zh: '编辑成功', en: 'Edit success' }, + + col_trusted_job_name: { zh: '名称', en: 'Name' }, + col_trusted_job_coordinator: { zh: '发起方', en: 'Coordinator' }, + col_trusted_job_status: { zh: '状态', en: 'Status' }, + col_job_status: { zh: '任务状态', en: 'Job status' }, + col_trusted_job_runtime: { zh: '运行时长', en: 'Runtime' }, + col_trusted_job_start_time: { zh: '开始时间', en: 'Start time' }, + col_trusted_job_end_time: { zh: '结束时间', en: 'End time' }, + col_trusted_job_create_at: { zh: '创建时间', en: 'Create time' }, + col_trusted_job_update_at: { zh: '更新时间', en: 'Update time' }, + col_trusted_job_creator: { zh: '创建人', en: 'Creator' }, + col_trusted_job_dataset: { zh: '数据集', en: 'Dataset' }, + col_trusted_job_operation: { zh: '操作', en: 'Operation' }, + col_trusted_job_comment: { zh: '备注', en: 'Comment' }, + col_instance_id: { zh: '实例 ID', en: 'Instance ID' }, + col_instance_status: { zh: '状态', en: 'Status' }, + col_instance_cpu: { zh: 'CPU', en: 'CPU' }, + col_instance_memory: { zh: 'MEM', en: 'MEM' }, + col_instance_start_at: { zh: '开始时间', en: 'Start time' }, + + state_trusted_job_unknown: { zh: '未知', en: 'Unknown' }, + state_trusted_job_pending: { zh: '待执行', en: 'Pending' }, + state_trusted_job_running: { zh: '执行中', en: 'Running' }, + state_trusted_job_succeeded: { zh: '已成功', en: 'Succeeded' }, + state_trusted_job_failed: { zh: '已失败', en: 'Failed' }, + state_trusted_job_stopped: { zh: '已终止', en: 'Stopped' }, + + state_auth_status_authorized: { zh: '已授权', en: 'Authorized' }, + state_auth_status_unauthorized: { zh: '未授权', en: 'Unathorized' }, +}; + +export default separateLng(trusted_center); diff --git a/web_console_v2/client/src/i18n/resources/modules/validError.ts b/web_console_v2/client/src/i18n/resources/modules/validError.ts new file mode 100644 index 000000000..f68571969 --- /dev/null +++ b/web_console_v2/client/src/i18n/resources/modules/validError.ts @@ -0,0 +1,67 @@ +import { separateLng } from 'i18n/helpers'; + +const error = { + name_invalid: { + zh: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + en: + 'Only support uppercase and lowercase letters, numbers, the beginning or end of Chinese, can contain "_" and "-", no more than 63 characters', + }, + comment_invalid: { + zh: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 100 个字符', + en: + 'Only support uppercase and lowercase letters, numbers, the beginning or end of Chinese, can contain "_" and "-", no more than 100 characters', + }, + comment_length_invalid: { + zh: '最多为 200 个字符', + en: 'Up to 200 characters', + }, + job_name_invalid: { + zh: '只支持小写字母,数字开头或结尾,可包含“-”,不超过 24 个字符', + en: + 'Only lowercase letters are supported, numbers start or end, may contain "-", and no more than 24 characters', + }, + cpu_invalid: { + zh: '请输入正确的格式,正确格式为 xxxm,例如: 4000m', + en: 'Please enter the correct format, the correct format is xxxm, for example: 4000m', + }, + memory_invalid: { + zh: '请输入正确的格式,正确格式为 xxxGi,xxxMi,例如: 16Gi,16Mi', + en: + 'Please enter the correct format, the correct format is xxxGi, xxxMi, for example: 16Gi,16Mi', + }, + email_invalid: { + zh: '请输入正确的邮箱格式', + en: 'Please enter the correct email format', + }, + password_invalid: { + zh: '请输入正确的密码格式,至少包含一个字母、一个数字、一个特殊字符,且长度在8到20之间', + en: + 'Please enter the correct password format,contain at least one letter, one number, one special character, and the length is between 8 and 20', + }, + missing_domain_name: { + zh: '获取本系统 domain_name 失败', + en: 'Failed to get domain_name of this system', + }, + empty_node_name_invalid: { + zh: '名称不能为空', + en: 'Name is required', + }, + same_node_name_invalid: { + zh: '已有重名元素', + en: 'There is a node with the same name', + }, + missing_dataset_id: { + zh: '请选择数据集', + en: 'Please select dataset, datasetId is required', + }, + missing_model_id: { + zh: '请选择模型', + en: 'Please select model, modelId is required', + }, + missing_name: { + zh: '请输入名称', + en: 'Please enter name', + }, +}; + +export default separateLng(error); diff --git a/web_console_v2/client/src/jobMetaDatas/BUILD.bazel b/web_console_v2/client/src/jobMetaDatas/BUILD.bazel new file mode 100644 index 000000000..70ff958ed --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/BUILD.bazel @@ -0,0 +1,5 @@ +filegroup( + name = "srcs", + srcs = glob(["**"]), + visibility = ["//visibility:public"], +) diff --git a/web_console_v2/client/src/jobMetaDatas/analyzer.json b/web_console_v2/client/src/jobMetaDatas/analyzer.json new file mode 100644 index 000000000..498c45a4f --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/analyzer.json @@ -0,0 +1,188 @@ + +{ + "Slot_image": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "镜像地址,建议不填写,默认会使用system.variables.image_repo + '/pp_data_inspection:' + system.version", + "reference_type": "DEFAULT", + "label": "镜像" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT", + "default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "额外元信息" + }, + "Slot_spark_main_file": { + "reference": "", + "value_type": "STRING", + "default_value": "/opt/spark/work-dir/analyzer_v2.py", + "help": "spark入口脚本", + "reference_type": "DEFAULT", + "label": "入口脚本文件" + }, + "Slot_storage_root_path": { + "reference": "project.variables.storage_root_path", + "value_type": "STRING", + "default_value": "/data", + "help": "联邦学习中任务存储根目录", + "reference_type": "PROJECT", + "label": "存储根目录" + }, + "Slot_input_job_name": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "必须修改,求交任务名或数据集名称", + "reference_type": "WORKFLOW", + "label": "数据集名" + }, + "Slot_inner_folder_name": { + "reference": "", + "value_type": "STRING", + "default_value": "dataset", + "help": "为了兼容老的路径的临时Slot,['dataset', 'datasource']", + "reference_type": "DEFAULT", + "label": "中间文件夹名" + }, + "Slot_wildcard": { + "reference": "", + "value_type": "STRING", + "default_value": "batch/**/*.data", + "help": "文件通配符", + "reference_type": "DEFAULT", + "label": "文件通配符" + }, + "Slot_drvier_envs":{ + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "driver环境变量", + "reference_type": "DEFAULT", + "label": "driver环境变量" +}, + "Slot_executor_envs":{ + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "executor环境变量", + "reference_type": "DEFAULT", + "label": "executor环境变量" +}, + "Slot_driver_cores": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "driver核心数", + "reference_type": "DEFAULT", + "label": "driver核心数" + }, + "Slot_driver_core_limit": { + "reference": "", + "value_type": "STRING", + "default_value": "1200m", + "help": "driver核心数限制", + "reference_type": "DEFAULT", + "label": "driver核心数限制" + }, + "Slot_driver_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "4g", + "help": "driver内存", + "reference_type": "DEFAULT", + "label": "driver内存" + }, + "Slot_executor_cores": { + "reference": "", + "value_type": "INT", + "default_value": 2, + "help": "executor核心数", + "reference_type": "DEFAULT", + "label": "executor核心数" + }, + "Slot_executor_instances": { + "reference": "", + "value_type": "INT", + "default_value": 2, + "help": "executor实例数", + "reference_type": "DEFAULT", + "label": "executor实例数" + }, + "Slot_executor_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "4g", + "help": "executor内存", + "reference_type": "DEFAULT", + "label": "executor内存" + }, + "Slot_initial_executors":{ + "reference": "", + "value_type": "INT", + "default_value": 2, + "help": "初始化executor数量", + "reference_type": "DEFAULT", + "label": "初始化executor数量" +}, + "Slot_max_executors":{ + "reference": "", + "value_type": "INT", + "default_value": 64, + "help": "初始化executor数量", + "reference_type": "DEFAULT", + "label": "最大executor数量" + }, + "Slot_min_executors":{ + "reference": "", + "value_type": "INT", + "default_value": 2, + "help": "初始化executor数量", + "reference_type": "DEFAULT", + "label": "最小executor数量" + }, + + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST", + "default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data"}], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST", + "default_value": [{"mountPath": "/data","name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + }, + "Slot_buckets_num": { + "reference": "", + "value_type": "INT", + "default_value": 10, + "help": "用于数据探查时统计直方图的分通数", + "reference_type": "WORKFLOW", + "label": "直方图分桶数" + }, + "Slot_thumbnail_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "用于存放预览图像的位置", + "reference_type": "WORKFLOW", + "label": "预览图像位置" + }, + "Slot_dataset_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "用于数据集存储的路径", + "reference_type": "WORKFLOW", + "label": "数据集存储路径" + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/analyzer.metayml b/web_console_v2/client/src/jobMetaDatas/analyzer.metayml new file mode 100644 index 000000000..a2257d02f --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/analyzer.metayml @@ -0,0 +1,63 @@ +{ + "apiVersion": "sparkoperator.k8s.io/v1beta2", + "kind": "SparkApplication", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "labels": ${Slot_labels}, + "annotations": { + "queue": "fedlearner-spark", + "schedulerName": "batch", + }, + }, + "spec": { + "type": "Python", + "pythonVersion": "3", + "mode": "cluster", + "image": ${Slot_image} or system.variables.image_repo + "/pp_data_inspection:" + system.version, + "imagePullPolicy": "IfNotPresent", + "volumes": ${Slot_volumes}, + "mainApplicationFile": ${Slot_spark_main_file}, + "arguments": [ + "--data_path="+ (${Slot_dataset_path} or ${Slot_storage_root_path} + "/" + ${Slot_inner_folder_name} + "/" + ${Slot_input_job_name}), + "--file_wildcard=" + ${Slot_wildcard}, + "--buckets_num=" + str(${Slot_buckets_num}), + "--thumbnail_path=" + ${Slot_thumbnail_path}, + ], + "sparkVersion": "3.0.0", + "restartPolicy": { + "type": "OnFailure", + "onFailureRetries": 3, + "onFailureRetryInterval": 10, + "onSubmissionFailureRetries": 5, + "onSubmissionFailureRetryInterval": 20 + }, + "driver": { + "cores": ${Slot_driver_cores}, + "coreLimit": ${Slot_driver_core_limit}, + "memory": ${Slot_driver_memory}, + "labels": { + "version": "3.0.0" + }, + "serviceAccount": "spark", + "volumeMounts": ${Slot_volume_mounts}, + "env": system.basic_envs_list + system.variables.envs_list + ${Slot_drvier_envs} + }, + "executor": { + "cores": ${Slot_executor_cores}, + "instances": ${Slot_executor_instances}, + "memory": ${Slot_executor_memory}, + "labels": { + "version": "3.0.0" + }, + "volumeMounts": ${Slot_volume_mounts}, + "env": system.basic_envs_list + system.variables.envs_list + ${Slot_executor_envs} + }, + "dynamicAllocation": { + "enabled": True, + "initialExecutors": ${Slot_initial_executors}, + "maxExecutors": ${Slot_max_executors}, + "minExecutors": ${Slot_min_executors}, + } + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/data_join.json b/web_console_v2/client/src/jobMetaDatas/data_join.json new file mode 100644 index 000000000..50105c36f --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/data_join.json @@ -0,0 +1,213 @@ +{ + "Slot_role": { + "reference": "", + "value_type": "STRING", + "default_value": "Leader", + "help": "Flapp 通讯时的角色 Leader 或 Follower", + "reference_type": "DEFAULT", + "label": "Flapp通讯时角色" + }, + "Slot_storage_root_path": { + "reference": "project.variables.storage_root_path", + "value_type": "STRING", + "default_value": "/data", + "help": "联邦学习中任务存储根目录", + "reference_type": "PROJECT", + "label": "存储根目录" + }, + "Slot_image_version": { + "reference": "", + "value_type": "STRING", + "default_value": "882310f", + "help": "建议不修改,指定Pod中运行的容器镜像版本,前缀为system.variables.image_repo + '/fedlearner:'", + "reference_type": "DEFAULT", + "label": "容器镜像版本" + }, + "Slot_master_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Master Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的CPU" + }, + "Slot_master_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Master Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的内存" + }, + "Slot_worker_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Worker Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的CPU" + }, + "Slot_worker_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Worker Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的内存" + }, + "Slot_master_replicas": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "同时运行的完全相同的Master Pods数量", + "reference_type": "DEFAULT", + "label": "Master的Pod个数" + }, + "Slot_batch_mode": { + "reference": "", + "value_type": "STRING", + "default_value": "--batch_mode", + "help": "如果为空则为常驻求交", + "reference_type": "DEFAULT", + "label": "是否为批处理模式" + }, + "Slot_partition_num": { + "reference": "", + "value_type": "INT", + "default_value": 4, + "help": "建议修改,求交后数据分区的数量,建议和raw_data一致", + "reference_type": "DEFAULT", + "label": "数据分区的数量" + }, + "Slot_start_time": { + "reference": "", + "value_type": "INT", + "default_value": 0, + "help": "建议不修改,使用自这个时间起的数据,仅从文件名筛选所以格式依据文件名(yyyymmdd或timestamp)", + "reference_type": "DEFAULT", + "label": "数据起始时间" + }, + "Slot_end_time": { + "reference": "", + "value_type": "INT", + "default_value": 999999999999, + "help": "建议不修改,使用自这个时间以前的数据,仅从文件名筛选所以格式依据文件名(yyyymmdd或timestamp)", + "reference_type": "DEFAULT", + "label": "数据末尾时间" + }, + "Slot_enable_negative_example_generator": { + "reference": "", + "value_type": "BOOL", + "default_value": false, + "help": "建议不修改,是否开启负采样,当follower求交时遇到无法匹配上的leader的example id,会以negative_sampling_rate为概率生成一个新的样本。", + "reference_type": "DEFAULT", + "label": "负采样比例" + }, + "Slot_negative_sampling_rate": { + "reference": "", + "value_type": "NUMBER", + "default_value": 0, + "help": "建议不修改,负采样比例,当follower求交时遇到无法匹配上的leader的example id,会以此概率生成一个新的样本。", + "reference_type": "DEFAULT", + "label": "负采样比例" + }, + "Slot_raw_data_name": { + "reference": "", + "value_type": "STRING","default_value": "", + "help": "必须修改,原始数据的发布地址,根据参数内容在portal_publish_dir地址下寻找", + "reference_type": "JOB_PROPERTY", + "label": "raw_data名字" + }, + "Slot_data_block_dump_interval": { + "reference": "", + "value_type": "INT","default_value": -1, + "help": "建议不修改,最多每隔多少时间(实际时间,非样本时间)就dump一次data block,小于0则无此限制", + "reference_type": "DEFAULT", + "label": "数据dump时间间隔" + }, + "Slot_data_block_dump_threshold": { + "reference": "", + "value_type": "INT","default_value": 4096, + "help": "建议不修改,最多多少个样本就dump为一个data block,小于等于0则无此限制", + "reference_type": "DEFAULT", + "label": "数据dump临界点" + }, + "Slot_example_id_dump_interval": { + "reference": "", + "value_type": "INT","default_value": -1, + "help": "建议不修改,最多每隔多少时间(实际时间,非样本时间)就dump一次example id,小于0则无此限制", + "reference_type": "DEFAULT", + "label": "数据id dump时间间隔" + }, + "Slot_example_id_dump_threshold": { + "reference": "", + "value_type": "INT","default_value": 4096, + "help": "建议不修改,最多每隔多少时间(实际时间,非样本时间)就dump一次example id,小于0则无此限制", + "reference_type": "DEFAULT", + "label": "数据id dump临界点" + }, + "Slot_min_matching_window": { + "reference": "", + "value_type": "INT","default_value": 1024, + "help": "建议不修改,the min matching window for example join ,<=0 means window size is infinite", + "reference_type": "DEFAULT", + "label": "最小匹配滑窗" + }, + "Slot_max_matching_window": { + "reference": "", + "value_type": "INT","default_value": 4096, + "help": "建议不修改,the max matching window for example join. <=0 means window size is infinite", + "reference_type": "DEFAULT", + "label": "最大匹配滑窗" + }, + "Slot_raw_data_iter": { + "reference": "", + "value_type": "STRING","default_value": "TF_RECORD", + "help": "建议不修改,choices=['TF_RECORD', 'CSV_DICT']", + "reference_type": "DEFAULT", + "label": "raw_data文件类型" + }, + "Slot_data_block_builder": { + "reference": "", + "value_type": "STRING", + "default_value": "TF_RECORD", + "help": "建议不修改,choices=['TF_RECORD', 'CSV_DICT']", + "reference_type": "DEFAULT", + "label": "data block output数据类型" + }, + "Slot_master_envs": { + "reference": "", + "value_type": "LIST","default_value": [], + "help": "数组类型,master pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Master额外环境变量" + }, + "Slot_worker_envs": { + "reference": "", + "value_type": "LIST","default_value": [], + "help": "数组类型,worker pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Worker额外环境变量" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT","default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "FLAPP额外元信息" + }, + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST","default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data"}], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST","default_value": [{ "mountPath": "/data", "name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/data_join.metayml b/web_console_v2/client/src/jobMetaDatas/data_join.metayml new file mode 100644 index 000000000..add4ae44d --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/data_join.metayml @@ -0,0 +1,261 @@ +{ + "apiVersion": "fedlearner.k8s.io/v1alpha1", + "kind": "FLApp", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "annotations":{ + "queue": "fedlearner", + "schedulerName": "batch" + }, + "labels": ${Slot_labels} + }, + "spec": { + "role": ${Slot_role}, + "cleanPodPolicy": "All", + "peerSpecs": { + "Leader" if ${Slot_role}=="Follower" else "Follower": { + "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc:80", + "authority": project.participants[0].egress_host, + "extraHeaders": { + "x-host": "fedlearner-operator." + project.participants[0].egress_domain + } + } + }, + "flReplicaSpecs": { + "Master": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/data_source/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "BATCH_MODE", + "value": ${Slot_batch_mode} + }, + { + "name": "PARTITION_NUM", + "value": str(${Slot_partition_num}) + }, + { + "name": "START_TIME", + "value": str(${Slot_start_time}) + }, + { + "name": "END_TIME", + "value": str(${Slot_end_time}) + }, + { + "name": "RAW_DATA_SUB_DIR", + "value": "portal_publish_dir/" + ${Slot_raw_data_name} + }, + { + # not work, remove it after prepare_launch_data_join_cli been removed + "name": "NEGATIVE_SAMPLING_RATE", + "value": str(${Slot_negative_sampling_rate}) + } + ] + ${Slot_master_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": + ${Slot_volume_mounts} + , + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/wait4pair_wrapper.sh" + ], + "args": [ + "/app/deploy/scripts/data_join/run_data_join_master.sh" + ], + "resources": { + "limits": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + }, + "requests": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": + ${Slot_volumes} + + } + }, + "pair": true, + "replicas": ${Slot_master_replicas} + }, + "Worker": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/data_source/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "PARTITION_NUM", + "value": str(${Slot_partition_num}) + }, + { + "name": "RAW_DATA_SUB_DIR", + "value": "portal_publish_dir/" + ${Slot_raw_data_name} + }, + { + "name": "DATA_BLOCK_DUMP_INTERVAL", + "value": str(${Slot_data_block_dump_interval}) + }, + { + "name": "DATA_BLOCK_DUMP_THRESHOLD", + "value": str(${Slot_data_block_dump_threshold}) + }, + { + "name": "EXAMPLE_ID_DUMP_INTERVAL", + "value": str(${Slot_example_id_dump_interval}) + }, + { + "name": "EXAMPLE_ID_DUMP_THRESHOLD", + "value": str(${Slot_example_id_dump_threshold}) + }, + { + "name": "MIN_MATCHING_WINDOW", + "value": str(${Slot_min_matching_window}) + }, + { + "name": "MAX_MATCHING_WINDOW", + "value": str(${Slot_max_matching_window}) + }, + { + "name": "RAW_DATA_ITER", + "value": ${Slot_raw_data_iter} + }, + { + "name": "DATA_BLOCK_BUILDER", + "value": ${Slot_data_block_builder} + }, + { + "name": "ENABLE_NEGATIVE_EXAMPLE_GENERATOR", + "value": str(${Slot_enable_negative_example_generator}) + }, + { + "name": "NEGATIVE_SAMPLING_RATE", + "value": str(${Slot_negative_sampling_rate}) + }, + ] + ${Slot_worker_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": + ${Slot_volume_mounts} + , + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/wait4pair_wrapper.sh" + ], + "args": [ + "/app/deploy/scripts/data_join/run_data_join_worker.sh" + ], + "resources": { + "limits": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + }, + "requests": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": + ${Slot_volumes} + + } + }, + "pair": true, + "replicas": ${Slot_partition_num} + } + } + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/index.ts b/web_console_v2/client/src/jobMetaDatas/index.ts new file mode 100644 index 000000000..fbcb33816 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/index.ts @@ -0,0 +1,61 @@ +/* istanbul ignore file */ + +import { JobSlot } from 'typings/workflow'; +import { JobType } from 'typings/job'; + +export type JobMetaData = { + metaYamlString: string; + slots: { [k: string]: JobSlot }; +}; + +const DataJoin: JobMetaData = { + metaYamlString: require('./data_join.metayml').default, + slots: require('./data_join.json'), +}; +const PSIDataJoin: JobMetaData = { + metaYamlString: require('./psi_data_join.metayml').default, + slots: require('./psi_data_join.json'), +}; +const TreeModelEvaluation: JobMetaData = { + metaYamlString: require('./tree_model_evaluation.metayml').default, + slots: require('./tree_model_evaluation.json'), +}; +const TreeModelTraining: JobMetaData = { + metaYamlString: require('./tree_model_training.metayml').default, + slots: require('./tree_model_training.json'), +}; +const RawData: JobMetaData = { + metaYamlString: require('./raw_data.metayml').default, + slots: require('./raw_data.json'), +}; +const NNModelTraining: JobMetaData = { + metaYamlString: require('./nn_model_training.metayml').default, + slots: require('./nn_model_training.json'), +}; +const NNModelEvaluation: JobMetaData = { + metaYamlString: require('./nn_model_evaluation.metayml').default, + slots: require('./nn_model_evaluation.json'), +}; +const Transformer: JobMetaData = { + metaYamlString: require('./transformer.metayml').default, + slots: require('./transformer.json'), +}; + +const Analyzer: JobMetaData = { + metaYamlString: require('./analyzer.metayml').default, + slots: require('./analyzer.json'), +}; + +const jobTypeToMetaDatasMap: Map<JobType, JobMetaData> = new Map(); + +jobTypeToMetaDatasMap.set(JobType.DATA_JOIN, DataJoin); +jobTypeToMetaDatasMap.set(JobType.PSI_DATA_JOIN, PSIDataJoin); +jobTypeToMetaDatasMap.set(JobType.TREE_MODEL_EVALUATION, TreeModelEvaluation); +jobTypeToMetaDatasMap.set(JobType.TREE_MODEL_TRAINING, TreeModelTraining); +jobTypeToMetaDatasMap.set(JobType.RAW_DATA, RawData); +jobTypeToMetaDatasMap.set(JobType.NN_MODEL_EVALUATION, NNModelEvaluation); +jobTypeToMetaDatasMap.set(JobType.NN_MODEL_TRANINING, NNModelTraining); +jobTypeToMetaDatasMap.set(JobType.TRANSFORMER, Transformer); +jobTypeToMetaDatasMap.set(JobType.ANALYZER, Analyzer); + +export default jobTypeToMetaDatasMap; diff --git a/web_console_v2/client/src/jobMetaDatas/nn_model_evaluation.json b/web_console_v2/client/src/jobMetaDatas/nn_model_evaluation.json new file mode 100644 index 000000000..9a6d3ca9e --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/nn_model_evaluation.json @@ -0,0 +1,298 @@ +{ + "Slot_role": { + "reference": "", + "value_type": "STRING", + "default_value": "Leader", + "help": "Flapp 通讯时的角色 Leader 或 Follower", + "reference_type": "WORKFLOW", + "label": "Flapp通讯时角色" + }, + "Slot_storage_root_path": { + "reference": "project.variables.storage_root_path", + "value_type": "STRING", + "default_value": "/data", + "help": "联邦学习中任务存储根目录", + "reference_type": "PROJECT", + "label": "存储根目录" + }, + "Slot_image_version": { + "reference": "", + "value_type": "STRING", + "default_value": "882310f", + "help": "建议不修改,指定Pod中运行的容器镜像版本,前缀为system.variables.image_repo + '/fedlearner:'", + "reference_type": "DEFAULT", + "label": "容器镜像版本" + }, + "Slot_master_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Master Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的CPU" + }, + "Slot_master_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Master Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的内存" + }, + "Slot_master_replicas": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "同时运行的完全相同的Master Pods数量", + "reference_type": "DEFAULT", + "label": "Master的Pod个数" + }, + "Slot_worker_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Worker Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的CPU" + }, + "Slot_worker_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Worker Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的内存" + }, + "Slot_worker_replicas": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "同时运行的完全相同的Worker Pods数量", + "reference_type": "DEFAULT", + "label": "Worker的Pod个数" + }, + "Slot_ps_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Master Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "PS的CPU" + }, + "Slot_ps_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Master Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "PS的内存" + }, + "Slot_ps_replicas": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "同时运行的完全相同的PS Pods数量", + "reference_type": "DEFAULT", + "label": "PS的Pod个数" + }, + "Slot_data_source": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "必须修改,求交任务的名字", + "reference_type": "JOB_PROPERTY", + "label": "数据源" + }, + "Slot_epoch_num": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "number of epoch for training, not support in online training", + "reference_type": "DEFAULT", + "label": "epoch数量" + }, + "Slot_start_date": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "training data start date", + "reference_type": "DEFAULT", + "label": "开始时间" + }, + "Slot_end_date": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "training data end date", + "reference_type": "DEFAULT", + "label": "结束时间" + }, + "Slot_online_training": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "['','--online_training'] 否 是,the train master run for online training", + "reference_type": "DEFAULT", + "label": "是否在线训练" + }, + "Slot_suffle_data_block": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "['','--shuffle_data_block'] 否 是,shuffle the data block or not", + "reference_type": "DEFAULT", + "label": "是否shuffle数据块" + }, + "Slot_mode": { + "reference": "", + "value_type": "STRING", + "default_value": "eval", + "help": "choices:['train','eval'] 训练还是验证", + "reference_type": "DEFAULT", + "label": "模式" + }, + "Slot_verbosity": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "int, Logging level", + "reference_type": "DEFAULT", + "label": "日志等级" + }, + "Slot_code_key": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "代码tar包地址,如果为空则使用code tar", + "reference_type": "WORKFLOW", + "label": "模型代码路径" + }, + "Slot_code_tar": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "代码包,variable中请使用代码类型", + "reference_type": "DEFAULT", + "label": "代码" + }, + "Slot_save_checkpoint_steps": { + "reference": "", + "value_type": "INT", + "default_value": 1000, + "help": "int, Number of steps between checkpoints.", + "reference_type": "DEFAULT", + "label": "SAVE_CHECKPOINT_STEPS" + }, + "Slot_save_checkpoint_secs": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "int,Number of secs between checkpoints.", + "reference_type": "DEFAULT", + "label": "SAVE_CHECKPOINT_SECS" + }, + "Slot_sparse_estimator": { + "reference": "", + "value_type": "BOOL", + "default_value": false, + "help": "bool,default False Whether using sparse estimator.", + "reference_type": "DEFAULT", + "label": "SPARSE_ESTIMATOR" + }, + "Slot_summary_save_steps": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "int, Number of steps to save summary files.", + "reference_type": "DEFAULT", + "label": "SUMMARY_SAVE_STEPS" + }, + "Slot_checkpoint_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "不建议修改,checkpoint输出路径,建议为空,会默认使用{storage_root_path}/job_output/{job_name}/checkpoints,强烈建议保持空值", + "reference_type": "DEFAULT", + "label": "CHECKPOINT_PATH" + }, + "Slot_load_checkpoint_filename": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "加载checkpoint_path下的相对路径的checkpoint, 默认会加载checkpoint_path下的latest checkpoint", + "reference_type": "DEFAULT", + "label": "LOAD_CHECKPOINT_FILENAME" + }, + "Slot_load_checkpoint_filename_with_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "加载绝对路径下的checkpoint,需要细致到文件名", + "reference_type": "DEFAULT", + "label": "从绝对路径加载checkpoint" + }, + "Slot_load_checkpoint_from_job": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "指定任务名job_output下的latest checkpoint", + "reference_type": "DEFAULT", + "label": "以任务名加载checkpoint" + }, + "Slot_export_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "使用默认空值,将把models保存到$OUTPUT_BASE_DIR/exported_models 路径下。", + "reference_type": "DEFAULT", + "label": "EXPORT_PATH" + }, + "Slot_master_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,master pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Master额外环境变量" + }, + "Slot_ps_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,ps pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "PS额外环境变量" + }, + "Slot_worker_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,worker pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Worker额外环境变量" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT", + "default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "FLAPP额外元信息" + }, + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST", + "default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data"}], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST", + "default_value": [{"mountPath": "/data","name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + } + } diff --git a/web_console_v2/client/src/jobMetaDatas/nn_model_evaluation.metayml b/web_console_v2/client/src/jobMetaDatas/nn_model_evaluation.metayml new file mode 100644 index 000000000..a159d440b --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/nn_model_evaluation.metayml @@ -0,0 +1,328 @@ +{ + "apiVersion": "fedlearner.k8s.io/v1alpha1", + "kind": "FLApp", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "annotations":{ + "queue": "fedlearner", + "schedulerName": "batch" + }, + "labels": ${Slot_labels} + }, + "spec": { + "role": ${Slot_role}, + "cleanPodPolicy": "All", + "peerSpecs": { + "Leader" if ${Slot_role}=="Follower" else "Follower": { + "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc:80", + "authority": project.participants[0].egress_host, + "extraHeaders": { + "x-host": "fedlearner-operator." + project.participants[0].egress_domain + } + } + }, + "flReplicaSpecs": { + "Master": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/job_output/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "EPOCH_NUM", + "value": str(${Slot_epoch_num}) + }, + { + "name": "START_DATE", + "value": str(${Slot_start_date}) + }, + { + "name": "END_DATE", + "value": str(${Slot_end_date}) + }, + { + "name": "DATA_SOURCE", + "value": ${Slot_data_source} + }, + { + "name": "ONLINE_TRAINING", + "value": ${Slot_online_training} + }, + { + "name": "SPARSE_ESTIMATOR", + "value": str(${Slot_sparse_estimator}) + }, + { + "name": "CODE_KEY", + "value": ${Slot_code_key} + }, + { + "name": "CODE_TAR", + "value": ${Slot_code_tar} + }, + { + "name": "CHECKPOINT_PATH", + "value": ${Slot_checkpoint_path} + }, + { + "name": "LOAD_CHECKPOINT_FILENAME", + "value": ${Slot_load_checkpoint_filename} + }, + { + "name": "LOAD_CHECKPOINT_FILENAME_WITH_PATH", + "value": ${Slot_load_checkpoint_filename_with_path} + }, + { + "name": "LOAD_CHECKPOINT_PATH", + "value": ${Slot_load_checkpoint_from_job} and ${Slot_storage_root_path} + "/job_output/" + ${Slot_load_checkpoint_from_job} + "/checkpoints" + }, + { + "name": "EXPORT_PATH", + "value": ${Slot_export_path} + } + ] + ${Slot_master_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/trainer/run_trainer_master.sh" + ], + "args": [ + ], + "resources": { + "limits": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + }, + "requests": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + + } + }, + "pair": False, + "replicas": int(${Slot_master_replicas}) + }, + "PS": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + } + + ] + ${Slot_ps_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/trainer/run_trainer_ps.sh" + ], + "args": [ + ], + "resources": { + "limits": { + "cpu": ${Slot_ps_cpu}, + "memory": ${Slot_ps_memory} + }, + "requests": { + "cpu": ${Slot_ps_cpu}, + "memory": ${Slot_ps_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": False, + "replicas": int(${Slot_ps_replicas}) + }, + "Worker": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/job_output/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "MODE", + "value": ${Slot_mode} + }, + { + "name": "VERBOSITY", + "value": str(${Slot_verbosity}) + }, + { + "name": "CODE_KEY", + "value": ${Slot_code_key} + }, + { + "name": "CODE_TAR", + "value": ${Slot_code_tar} + }, + { + "name": "SAVE_CHECKPOINT_STEPS", + "value": str(${Slot_save_checkpoint_steps}) + }, + { + "name": "SAVE_CHECKPOINT_SECS", + "value": str(${Slot_save_checkpoint_secs}) + }, + { + "name": "SPARSE_ESTIMATOR", + "value": str(${Slot_sparse_estimator}) + }, + { + "name": "SUMMARY_SAVE_STEPS", + "value": str(${Slot_summary_save_steps}) + } + ] + ${Slot_worker_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/wait4pair_wrapper.sh" + ], + "args": [ + "/app/deploy/scripts/trainer/run_trainer_worker.sh" + ], + "resources": { + "limits": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + }, + "requests": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": True, + "replicas": int(${Slot_worker_replicas}) + } + } + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/nn_model_training.json b/web_console_v2/client/src/jobMetaDatas/nn_model_training.json new file mode 100644 index 000000000..2d84ed5b7 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/nn_model_training.json @@ -0,0 +1,298 @@ +{ + "Slot_role": { + "reference": "", + "value_type": "STRING", + "default_value": "Leader", + "help": "Flapp 通讯时的角色 Leader 或 Follower", + "reference_type": "WORKFLOW", + "label": "Flapp通讯时角色" + }, + "Slot_storage_root_path": { + "reference": "project.variables.storage_root_path", + "value_type": "STRING", + "default_value": "/data", + "help": "联邦学习中任务存储根目录", + "reference_type": "PROJECT", + "label": "存储根目录" + }, + "Slot_image_version": { + "reference": "", + "value_type": "STRING", + "default_value": "882310f", + "help": "建议不修改,指定Pod中运行的容器镜像版本,前缀为system.variables.image_repo + '/fedlearner:'", + "reference_type": "DEFAULT", + "label": "容器镜像版本" + }, + "Slot_master_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Master Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的CPU" + }, + "Slot_master_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Master Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的内存" + }, + "Slot_master_replicas": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "同时运行的完全相同的Master Pods数量", + "reference_type": "DEFAULT", + "label": "Master的Pod个数" + }, + "Slot_worker_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Worker Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的CPU" + }, + "Slot_worker_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Worker Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的内存" + }, + "Slot_worker_replicas": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "同时运行的完全相同的Worker Pods数量", + "reference_type": "DEFAULT", + "label": "Worker的Pod个数" + }, + "Slot_ps_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Master Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "PS的CPU" + }, + "Slot_ps_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Master Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "PS的内存" + }, + "Slot_ps_replicas": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "同时运行的完全相同的PS Pods数量", + "reference_type": "DEFAULT", + "label": "PS的Pod个数" + }, + "Slot_data_source": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "必须修改,求交任务的名字", + "reference_type": "JOB_PROPERTY", + "label": "数据源" + }, + "Slot_epoch_num": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "number of epoch for training, not support in online training", + "reference_type": "DEFAULT", + "label": "epoch数量" + }, + "Slot_start_date": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "training data start date", + "reference_type": "DEFAULT", + "label": "开始时间" + }, + "Slot_end_date": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "training data end date", + "reference_type": "DEFAULT", + "label": "结束时间" + }, + "Slot_online_training": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "['','--online_training'] 否 是,the train master run for online training", + "reference_type": "DEFAULT", + "label": "是否在线训练" + }, + "Slot_suffle_data_block": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "['','--shuffle_data_block'] 否 是,shuffle the data block or not", + "reference_type": "DEFAULT", + "label": "是否shuffle数据块" + }, + "Slot_mode": { + "reference": "", + "value_type": "STRING", + "default_value": "train", + "help": "choices:['train','eval'] 训练还是验证", + "reference_type": "DEFAULT", + "label": "模式" + }, + "Slot_verbosity": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "int, Logging level", + "reference_type": "DEFAULT", + "label": "日志等级" + }, + "Slot_code_key": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "代码tar包地址,如果为空则使用code tar", + "reference_type": "WORKFLOW", + "label": "模型代码路径" + }, + "Slot_code_tar": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "代码包,variable中请使用代码类型", + "reference_type": "DEFAULT", + "label": "代码" + }, + "Slot_save_checkpoint_steps": { + "reference": "", + "value_type": "INT", + "default_value": 1000, + "help": "int, Number of steps between checkpoints.", + "reference_type": "DEFAULT", + "label": "SAVE_CHECKPOINT_STEPS" + }, + "Slot_save_checkpoint_secs": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "int,Number of secs between checkpoints.", + "reference_type": "DEFAULT", + "label": "SAVE_CHECKPOINT_SECS" + }, + "Slot_sparse_estimator": { + "reference": "", + "value_type": "BOOL", + "default_value": false, + "help": "bool,default False Whether using sparse estimator.", + "reference_type": "DEFAULT", + "label": "SPARSE_ESTIMATOR" + }, + "Slot_summary_save_steps": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "int, Number of steps to save summary files.", + "reference_type": "DEFAULT", + "label": "SUMMARY_SAVE_STEPS" + }, + "Slot_checkpoint_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "不建议修改,checkpoint输出路径,建议为空,会默认使用{storage_root_path}/job_output/{job_name}/checkpoints,强烈建议保持空值", + "reference_type": "DEFAULT", + "label": "CHECKPOINT_PATH" + }, + "Slot_load_checkpoint_filename": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "加载checkpoint_path下的相对路径的checkpoint, 默认会加载checkpoint_path下的latest checkpoint", + "reference_type": "DEFAULT", + "label": "LOAD_CHECKPOINT_FILENAME" + }, + "Slot_load_checkpoint_filename_with_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "加载绝对路径下的checkpoint,需要细致到文件名", + "reference_type": "DEFAULT", + "label": "从绝对路径加载checkpoint" + }, + "Slot_load_checkpoint_from_job": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "指定任务名job_output下的latest checkpoint", + "reference_type": "DEFAULT", + "label": "以任务名加载checkpoint" + }, + "Slot_export_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "使用默认空值,将把models保存到$OUTPUT_BASE_DIR/exported_models 路径下。", + "reference_type": "DEFAULT", + "label": "EXPORT_PATH" + }, + "Slot_master_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,master pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Master额外环境变量" + }, + "Slot_ps_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,ps pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "PS额外环境变量" + }, + "Slot_worker_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,worker pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Worker额外环境变量" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT", + "default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "FLAPP额外元信息" + }, + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST", + "default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data"}], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST", + "default_value": [{"mountPath": "/data","name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/nn_model_training.metayml b/web_console_v2/client/src/jobMetaDatas/nn_model_training.metayml new file mode 100644 index 000000000..11a9ef161 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/nn_model_training.metayml @@ -0,0 +1,328 @@ +{ + "apiVersion": "fedlearner.k8s.io/v1alpha1", + "kind": "FLApp", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "annotations":{ + "queue": "fedlearner", + "schedulerName": "batch" + }, + "labels": ${Slot_labels} + }, + "spec": { + "role": ${Slot_role}, + "cleanPodPolicy": "All", + "peerSpecs": { + "Leader" if ${Slot_role}=="Follower" else "Follower": { + "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc:80", + "authority": project.participants[0].egress_host, + "extraHeaders": { + "x-host": "fedlearner-operator." + project.participants[0].egress_domain + } + } + }, + "flReplicaSpecs": { + "Master": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/job_output/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "EPOCH_NUM", + "value": str(${Slot_epoch_num}) + }, + { + "name": "START_DATE", + "value": str(${Slot_start_date}) + }, + { + "name": "END_DATE", + "value": str(${Slot_end_date}) + }, + { + "name": "DATA_SOURCE", + "value": ${Slot_data_source} + }, + { + "name": "ONLINE_TRAINING", + "value": ${Slot_online_training} + }, + { + "name": "SPARSE_ESTIMATOR", + "value": str(${Slot_sparse_estimator}) + }, + { + "name": "CODE_KEY", + "value": ${Slot_code_key} + }, + { + "name": "CODE_TAR", + "value": ${Slot_code_tar} + }, + { + "name": "CHECKPOINT_PATH", + "value": ${Slot_checkpoint_path} + }, + { + "name": "LOAD_CHECKPOINT_FILENAME", + "value": ${Slot_load_checkpoint_filename} + }, + { + "name": "LOAD_CHECKPOINT_FILENAME_WITH_PATH", + "value": ${Slot_load_checkpoint_filename_with_path} + }, + { + "name": "LOAD_CHECKPOINT_PATH", + "value": ${Slot_load_checkpoint_from_job} and ${Slot_storage_root_path} + "/job_output/" + ${Slot_load_checkpoint_from_job} + "/checkpoints" + }, + { + "name": "EXPORT_PATH", + "value": ${Slot_export_path} + } + ] + ${Slot_master_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/trainer/run_trainer_master.sh" + ], + "args": [ + ], + "resources": { + "limits": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + }, + "requests": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + + } + }, + "pair": False, + "replicas": int(${Slot_master_replicas}) + }, + "PS": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + } + + ] + ${Slot_ps_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/trainer/run_trainer_ps.sh" + ], + "args": [ + ], + "resources": { + "limits": { + "cpu": ${Slot_ps_cpu}, + "memory": ${Slot_ps_memory} + }, + "requests": { + "cpu": ${Slot_ps_cpu}, + "memory": ${Slot_ps_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": False, + "replicas": int(${Slot_ps_replicas}) + }, + "Worker": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/job_output/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "MODE", + "value": ${Slot_mode} + }, + { + "name": "VERBOSITY", + "value": str(${Slot_verbosity}) + }, + { + "name": "CODE_KEY", + "value": ${Slot_code_key} + }, + { + "name": "CODE_TAR", + "value": ${Slot_code_tar} + }, + { + "name": "SAVE_CHECKPOINT_STEPS", + "value": str(${Slot_save_checkpoint_steps}) + }, + { + "name": "SAVE_CHECKPOINT_SECS", + "value": str(${Slot_save_checkpoint_secs}) + }, + { + "name": "SPARSE_ESTIMATOR", + "value": str(${Slot_sparse_estimator}) + }, + { + "name": "SUMMARY_SAVE_STEPS", + "value": str(${Slot_summary_save_steps}) + } + ] + ${Slot_worker_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/wait4pair_wrapper.sh" + ], + "args": [ + "/app/deploy/scripts/trainer/run_trainer_worker.sh" + ], + "resources": { + "limits": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + }, + "requests": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": True, + "replicas": int(${Slot_worker_replicas}) + } + } + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/psi_data_join.json b/web_console_v2/client/src/jobMetaDatas/psi_data_join.json new file mode 100644 index 000000000..06ae31f65 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/psi_data_join.json @@ -0,0 +1,269 @@ +{ + + "Slot_role": { + "reference": "", + "value_type": "STRING", + "default_value": "Leader", + "help": "Flapp 通讯时的角色 Leader 或 Follower", + "reference_type": "WORKFLOW", + "label": "Flapp通讯时角色" + }, + "Slot_storage_root_path": { + "reference": "project.variables.storage_root_path", + "value_type": "STRING", + "default_value": "/data", + "help": "联邦学习中任务存储根目录", + "reference_type": "PROJECT", + "label": "存储根目录" + }, + "Slot_image_version": { + "reference": "", + "value_type": "STRING", + "default_value": "882310f", + "help": "建议不修改,指定Pod中运行的容器镜像版本,前缀为system.variables.image_repo + '/fedlearner:'", + "reference_type": "DEFAULT", + "label": "容器镜像版本" + }, + "Slot_master_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Master Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的CPU" + }, + "Slot_master_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Master Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的内存" + }, + "Slot_worker_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Worker Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的CPU" + }, + "Slot_worker_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Worker Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的内存" + }, + "Slot_batch_mode": { + "reference": "", + "value_type": "STRING", + "default_value": "--batch_mode", + "help": "如果为空则为常驻求交", + "reference_type": "DEFAULT", + "label": "是否为批处理模式" + }, + "Slot_partition_num": { + "reference": "", + "value_type": "INT", + "default_value": 4, + "help": "建议修改,求交后数据分区的数量,建议和raw_data一致", + "reference_type": "WORKFLOW", + "label": "数据分区的数量" + }, + "Slot_rsa_key_pem": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "直接输入RSA公钥和私钥,请使用Textarea,Leader会从中读取私钥,Follower会从中读取公钥。如果为空会使用path读取。", + "reference_type": "WORKFLOW", + "label": "RSA钥匙" + }, + "Slot_rsa_key_path": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "RSA公钥或私钥的地址,在无RSA_KEY_PEM时必填", + "reference_type": "WORKFLOW", + "label": "RSA钥匙地址" + }, + "Slot_kms_key_name": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "kms中的密钥名称,站内镜像需使用KMS", + "reference_type": "DEFAULT", + "label": "密钥名称" + }, + "Slot_kms_client": { + "reference": "", + "value_type": "STRING", + "default_value": "data.aml.fl", + "help": "kms client", + "reference_type": "DEFAULT", + "label": "kms client" + }, + "Slot_psi_raw_data_iter": { + "reference": "", + "value_type": "STRING", + "default_value": "TF_RECORD", + "help": "建议不修改,choices=['TF_RECORD', 'CSV_DICT']", + "reference_type": "DEFAULT", + "label": "raw data数据类型" + }, + "Slot_data_block_builder": { + "reference": "", + "value_type": "STRING", + "default_value": "TF_RECORD", + "help": "建议不修改,choices=['TF_RECORD', 'CSV_DICT']", + "reference_type": "DEFAULT", + "label": "data block output数据类型" + }, + "Slot_psi_output_builder": { + "reference": "", + "value_type": "STRING", + "default_value": "TF_RECORD", + "help": "建议不修改,choices=['TF_RECORD', 'CSV_DICT']", + "reference_type": "DEFAULT", + "label": "PSI output数据类型" + }, + "Slot_start_time": { + "reference": "", + "value_type": "INT", + "default_value": 0, + "help": "建议不修改,使用自这个时间起的数据,仅从文件名筛选所以格式依据文件名(yyyymmdd或timestamp)", + "reference_type": "DEFAULT", + "label": "数据起始时间" + }, + "Slot_end_time": { + "reference": "", + "value_type": "INT", + "default_value": 999999999999, + "help": "建议不修改,使用自这个时间以前的数据,仅从文件名筛选所以格式依据文件名(yyyymmdd或timestamp)", + "reference_type": "DEFAULT", + "label": "数据末尾时间" + }, + "Slot_enable_negative_example_generator": { + "reference": "", + "value_type": "BOOL", + "default_value": false, + "help": "建议不修改,是否开启负采样,当follower求交时遇到无法匹配上的leader的example id,会以negative_sampling_rate为概率生成一个新的样本。", + "reference_type": "DEFAULT", + "label": "负采样比例" + }, + "Slot_negative_sampling_rate": { + "reference": "", + "value_type": "NUMBER", + "default_value": 0, + "help": "建议不修改,负采样比例,当follower求交时遇到无法匹配上的leader的example id,会以此概率生成一个新的样本。", + "reference_type": "DEFAULT", + "label": "负采样比例" + }, + "Slot_raw_data_name": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "必须修改,原始数据的发布地址,根据参数内容在portal_publish_dir地址下寻找", + "reference_type": "JOB_PROPERTY", + "label": "raw_data名字" + }, + "Slot_data_block_dump_interval": { + "reference": "", + "value_type": "INT", + "default_value": -1, + "help": "建议不修改,最多每隔多少时间(实际时间,非样本时间)就dump一次data block,小于0则无此限制", + "reference_type": "DEFAULT", + "label": "数据dump时间间隔" + }, + "Slot_data_block_dump_threshold": { + "reference": "", + "value_type": "INT", + "default_value": 4096, + "help": "建议不修改,最多多少个样本就dump为一个data block,小于等于0则无此限制", + "reference_type": "DEFAULT", + "label": "数据dump临界点" + }, + "Slot_example_id_dump_interval": { + "reference": "", + "value_type": "INT", + "default_value": -1, + "help": "建议不修改,最多每隔多少时间(实际时间,非样本时间)就dump一次example id,小于0则无此限制", + "reference_type": "DEFAULT", + "label": "数据id dump时间间隔" + }, + "Slot_example_id_dump_threshold": { + "reference": "", + "value_type": "INT", + "default_value": 4096, + "help": "建议不修改,最多每隔多少时间(实际时间,非样本时间)就dump一次example id,小于0则无此限制", + "reference_type": "DEFAULT", + "label": "数据id dump临界点" + }, + "Slot_psi_read_ahead_size": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "建议不填, the read ahead size for raw data", + "reference_type": "DEFAULT", + "label": "psi_read_ahead_size" + }, + "Slot_run_merger_read_ahead_buffer": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "建议不填, sort run merger read ahead buffer", + "reference_type": "DEFAULT", + "label": "run_merger_read_ahead_buffer" + }, + + "Slot_master_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,master pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Master额外环境变量" + }, + + "Slot_worker_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,worker pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Worker额外环境变量" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT", + "default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "FLAPP额外元信息" + }, + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST", + "default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data" }], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST", + "default_value": [{ "mountPath": "/data", "name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + }, + "Slot_data_join_metrics_sample_rate":{ + "reference": "", + "value_type": "STRING", + "default_value": "0", + "help": "建议不修改,es metrics 取样比例", + "reference_type": "DEFAULT", + "label": "metrics_sample_rate" + } + } diff --git a/web_console_v2/client/src/jobMetaDatas/psi_data_join.metayml b/web_console_v2/client/src/jobMetaDatas/psi_data_join.metayml new file mode 100644 index 000000000..f7ab2808d --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/psi_data_join.metayml @@ -0,0 +1,289 @@ +{ + "apiVersion": "fedlearner.k8s.io/v1alpha1", + "kind": "FLApp", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "annotations":{ + "queue": "fedlearner", + "schedulerName": "batch" + }, + "labels": ${Slot_labels} + }, + "spec": { + "role": ${Slot_role}, + "cleanPodPolicy": "All", + "peerSpecs": { + "Leader" if ${Slot_role}=="Follower" else "Follower": { + "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc:80", + "authority": project.participants[0].egress_host, + "extraHeaders": { + "x-host": "fedlearner-operator." + project.participants[0].egress_domain + } + } + }, + "flReplicaSpecs": { + "Master": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/data_source/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "PARTITION_NUM", + "value": str(${Slot_partition_num}) + }, + { + "name": "START_TIME", + "value": str(${Slot_start_time}) + }, + { + "name": "END_TIME", + "value": str(${Slot_end_time}) + }, + { + "name": "RAW_DATA_SUB_DIR", + "value": "portal_publish_dir/" + ${Slot_raw_data_name} + }, + { + # not work, remove it after prepare_launch_data_join_cli been removed + "name": "NEGATIVE_SAMPLING_RATE", + "value": str(${Slot_negative_sampling_rate}) + }, + { + "name": "DATA_JOIN_METRICS_SAMPLE_RATE", + "value": str(${Slot_data_join_metrics_sample_rate}) + } + ] + ${Slot_master_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/wait4pair_wrapper.sh" + ], + "args": [ + "/app/deploy/scripts/rsa_psi/run_psi_data_join_master.sh" + ], + "resources": { + "limits": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + }, + "requests": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": True, + "replicas": 1 + }, + "Worker": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "BATCH_MODE", + "value": ${Slot_batch_mode} + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/data_source/" + self.name + }, + { + "name": "PARTITION_NUM", + "value": str(${Slot_partition_num}) + }, + { + "name": "RAW_DATA_SUB_DIR", + "value": "portal_publish_dir/" + ${Slot_raw_data_name} + }, + { + "name": "RSA_KEY_PEM", + "value": ${Slot_rsa_key_pem} + }, + { + "name": "RSA_KEY_PATH", + "value": ${Slot_rsa_key_path} + }, + { + "name": "RSA_PRIVATE_KEY_PATH", + "value": ${Slot_rsa_key_path} + }, + { + "name": "KMS_KEY_NAME", + "value": ${Slot_kms_key_name} + }, + { + "name": "KMS_CLIENT", + "value": ${Slot_kms_client} + }, + { + "name": "PSI_RAW_DATA_ITER", + "value": ${Slot_psi_raw_data_iter} + }, + { + "name": "DATA_BLOCK_BUILDER", + "value": ${Slot_data_block_builder} + }, + { + "name": "PSI_OUTPUT_BUILDER", + "value": ${Slot_psi_output_builder} + }, + { + "name": "DATA_BLOCK_DUMP_INTERVAL", + "value": str(${Slot_data_block_dump_interval}) + }, + { + "name": "DATA_BLOCK_DUMP_THRESHOLD", + "value": str(${Slot_data_block_dump_threshold}) + }, + { + "name": "EXAMPLE_ID_DUMP_INTERVAL", + "value": str(${Slot_example_id_dump_interval}) + }, + { + "name": "EXAMPLE_ID_DUMP_THRESHOLD", + "value": str(${Slot_example_id_dump_threshold}) + }, + { + "name": "EXAMPLE_JOINER", + "value": "SORT_RUN_JOINER" + }, + { + "name": "PSI_READ_AHEAD_SIZE", + "value": str(${Slot_psi_read_ahead_size}) + }, + { + "name": "SORT_RUN_MERGER_READ_AHEAD_BUFFER", + "value": str(${Slot_run_merger_read_ahead_buffer}) + }, + { + "name": "NEGATIVE_SAMPLING_RATE", + "value": str(${Slot_negative_sampling_rate}) + }, + { + "name": "ENABLE_NEGATIVE_EXAMPLE_GENERATOR", + "value": str(${Slot_enable_negative_example_generator}) + }, + { + "name": "DATA_JOIN_METRICS_SAMPLE_RATE", + "value": str(${Slot_data_join_metrics_sample_rate}) + } + ] + ${Slot_worker_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/wait4pair_wrapper.sh" + ], + "args": [ + "/app/deploy/scripts/rsa_psi/run_psi_data_join_worker.sh" + ], + "resources": { + "limits": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + }, + "requests": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": True, + "replicas": int(${Slot_partition_num}) + } + } + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/raw_data.json b/web_console_v2/client/src/jobMetaDatas/raw_data.json new file mode 100644 index 000000000..88f843519 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/raw_data.json @@ -0,0 +1,220 @@ +{ + "Slot_storage_root_path": { + "reference": "project.variables.storage_root_path", + "value_type": "STRING", + "default_value": "/data", + "help": "联邦学习中任务存储根目录", + "reference_type": "PROJECT", + "label": "存储根目录" + }, + "Slot_image_version": { + "reference": "", + "value_type": "STRING", + "default_value": "882310f", + "help": "建议不修改,指定Pod中运行的容器镜像版本,前缀为system.variables.image_repo + '/fedlearner:'", + "reference_type": "DEFAULT", + "label": "容器镜像版本" + }, + "Slot_master_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Master Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的CPU" + }, + "Slot_master_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Master Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Master的内存" + }, + "Slot_worker_cpu": { + "reference": "", + "value_type": "STRING", + "default_value": "2000m", + "help": "Worker Pod 所分配的CPU资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的CPU" + }, + "Slot_worker_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "3Gi", + "help": "Worker Pod 所分配的内存资源(request和limit一致)", + "reference_type": "DEFAULT", + "label": "Worker的内存" + }, + "Slot_data_portal_type": { + "reference": "", + "value_type": "STRING", + "default_value": "Streaming", + "help": "运行过一次后修改无效!! the type of data portal type ,choices=['PSI', 'Streaming']", + "reference_type": "DEFAULT", + "label": "数据入口类型" + }, + "Slot_output_partition_num": { + "reference": "", + "value_type": "INT", + "default_value": 4, + "help": "运行过一次后修改无效!!输出数据的文件数量,对应Worker数量", + "reference_type": "WORKFLOW", + "label": "数据分区的数量" + }, + "Slot_input_base_dir": { + "reference": "", + "value_type": "STRING", + "default_value": "/app/deploy/integrated_test/tfrecord_raw_data", + "help": "必须修改,运行过一次后修改无效!!the base dir of input directory", + "reference_type": "WORKFLOW", + "label": "输入路径" + }, + "Slot_long_running": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "choices: ['','--long_running']否,是。是否为常驻上传原始数据", + "reference_type": "DEFAULT", + "label": "是否常驻" + }, + "Slot_check_success_tag": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "choices:['','--check_success_tag'] means false and true, Check that a _SUCCESS file exists before processing files in a subfolder", + "reference_type": "DEFAULT", + "label": "是否检查成功标志" + }, + "Slot_files_per_job_limit": { + "reference": "", + "value_type": "INT", + "default_value": null, + "help": "空即不设限制,Max number of files in a job", + "reference_type": "DEFAULT", + "label": "每个任务最多文件数" + }, + "Slot_single_subfolder": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "choices:['','--single_subfolder'] 否 是,Only process one subfolder at a time", + "reference_type": "DEFAULT", + "label": "是否单一子文件夹" + }, + "Slot_file_wildcard": { + "reference": "", + "value_type": "STRING", + "default_value": "*.rd", + "help": "文件名称的通配符, 将会读取input_base_dir下所以满足条件的文件,如\n1. *.csv,意为读取所有csv格式文件\n2. *.tfrecord,意为读取所有tfrecord格式文件\n3. xxx.txt,意为读取文件名为xxx.txt的文件", + "reference_type": "DEFAULT", + "label": "文件名称的通配符" + }, + "Slot_batch_size": { + "reference": "", + "value_type": "INT", + "default_value": 1024, + "help": "原始数据是一批一批的从文件系统中读出来,batch_size为batch的大小", + "reference_type": "DEFAULT", + "label": "Batch大小" + }, + "Slot_input_data_format": { + "reference": "", + "value_type": "STRING", + "default_value": "TF_RECORD", + "help": "choices=['TF_RECORD', 'CSV_DICT'] the type for input data iterator", + "reference_type": "DEFAULT", + "label": "输入数据格式" + }, + "Slot_compressed_type": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "choices=['', 'ZLIB', 'GZIP'] the compressed type of input data file", + "reference_type": "DEFAULT", + "label": "压缩方式" + }, + "Slot_output_data_format": { + "reference": "", + "value_type": "STRING", + "default_value": "TF_RECORD", + "help": "choices=['TF_RECORD', 'CSV_DICT'] the format for output file", + "reference_type": "DEFAULT", + "label": "输出格式" + }, + "Slot_builder_compressed_type": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "choices=['', 'ZLIB', 'GZIP'] the format for output file", + "reference_type": "DEFAULT", + "label": "输出压缩格式" + }, + "Slot_memory_limit_ratio": { + "reference": "", + "value_type": "INT", + "default_value": 70, + "help": "预测是否会OOM的时候用到,如果预测继续执行下去时占用内存会超过这个比例,就阻塞,直到尚未处理的任务处理完成。 注意这是个40-81之间的整数。", + "reference_type": "DEFAULT", + "label": "内存限制比例" + }, + "Slot_optional_fields": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "optional stat fields used in joiner, separated by comma between fields, e.g. \"label,rit\"Each field will be stripped", + "reference_type": "DEFAULT", + "label": "可选字段" + }, + + "Slot_master_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,master pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Master额外环境变量" + }, + + "Slot_worker_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,worker pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Worker额外环境变量" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT", + "default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "FLAPP额外元信息" + }, + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST", + "default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data" }], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST", + "default_value": [{ "mountPath": "/data", "name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + }, + "Slot_raw_data_metrics_sample_rate": { + "reference": "", + "value_type": "STRING", + "default_value": "1", + "help": "建议不修改,es metrics 取样比例", + "reference_type": "DEFAULT", + "label": "metrics_sample_rate" + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/raw_data.metayml b/web_console_v2/client/src/jobMetaDatas/raw_data.metayml new file mode 100644 index 000000000..fa6481df0 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/raw_data.metayml @@ -0,0 +1,246 @@ +{ + "apiVersion": "fedlearner.k8s.io/v1alpha1", + "kind": "FLApp", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "annotations":{ + "queue": "fedlearner", + "schedulerName": "batch" + }, + "labels": ${Slot_labels} + }, + "spec": { + "role": "Follower", + "peerSpecs": { + "Leader": { + "peerURL": "", + "authority": "" + } + }, + "flReplicaSpecs": { + "Master": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "DATA_PORTAL_NAME", + "value": self.name + }, + { + "name": "DATA_PORTAL_TYPE", + "value": ${Slot_data_portal_type} + }, + { + "name": "OUTPUT_PARTITION_NUM", + "value": str(${Slot_output_partition_num}) + }, + { + "name": "INPUT_BASE_DIR", + "value": ${Slot_input_base_dir} + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/raw_data/" + self.name + }, + { + "name": "RAW_DATA_PUBLISH_DIR", + "value": "portal_publish_dir/" + self.name + }, + { + "name": "FILE_WILDCARD", + "value": ${Slot_file_wildcard} + }, + { + "name": "LONG_RUNNING", + "value": ${Slot_long_running} + }, + { + "name": "CHECK_SUCCESS_TAG", + "value": ${Slot_check_success_tag} + }, + { + "name": "FILES_PER_JOB_LIMIT", + "value": str(${Slot_files_per_job_limit}) + }, + { + "name": "SINGLE_SUBFOLDER", + "value": ${Slot_single_subfolder} + }, + { + "name": "RAW_DATA_METRICS_SAMPLE_RATE", + "value": str(${Slot_raw_data_metrics_sample_rate}) + } + + ] + ${Slot_master_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/data_portal/run_data_portal_master.sh" + ], + "args": [ + ], + "resources": { + "limits": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + }, + "requests": { + "cpu": ${Slot_master_cpu}, + "memory": ${Slot_master_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": False, + "replicas": 1 + }, + "Worker": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/data_source/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + + { + "name": "BATCH_SIZE", + "value": str(${Slot_batch_size}) + }, + { + "name": "INPUT_DATA_FORMAT", + "value": ${Slot_input_data_format} + }, + { + "name": "COMPRESSED_TYPE", + "value": ${Slot_compressed_type} + }, + { + "name": "OUTPUT_DATA_FORMAT", + "value": ${Slot_output_data_format} + }, + { + "name": "BUILDER_COMPRESSED_TYPE", + "value": ${Slot_builder_compressed_type} + }, + { + "name": "MEMORY_LIMIT_RATIO", + "value": str(${Slot_memory_limit_ratio}) + }, + { + "name": "OPTIONAL_FIELDS", + "value": ${Slot_optional_fields} + }, + { + "name": "RAW_DATA_METRICS_SAMPLE_RATE", + "value": str(${Slot_raw_data_metrics_sample_rate}) + } + + + ] + ${Slot_worker_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/data_portal/run_data_portal_worker.sh" + ], + "args": [ + ], + "resources": { + "limits": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + }, + "requests": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_memory} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": False, + "replicas": ${Slot_output_partition_num} + } + } + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/transformer.json b/web_console_v2/client/src/jobMetaDatas/transformer.json new file mode 100644 index 000000000..69100d7bd --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/transformer.json @@ -0,0 +1,108 @@ + +{ + "Slot_image": { + "reference": "system.variables.spark_image", + "value_type": "STRING", + "default_value": "", + "help": "镜像地址,建议不填写,默认会使用system.variables.image_repo + '/pp_data_inspection:' + system.version", + "reference_type": "DEFAULT", + "label": "镜像" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT", + "default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "FLAPP额外元信息" + }, + "Slot_spark_transformer_file": { + "reference": "", + "value_type": "STRING", + "default_value": "transformer.py", + "help": "特征工程的脚本", + "reference_type": "DEFAULT", + "label": "特征工程脚本文件" + }, + "Slot_dataset": { + "reference": "", + "value_type": "STRING", + "default_value": "", + "help": "", + "reference_type": "DEFAULT", + "label": "输入数据集" + }, + "Slot_configs": { + "reference": "", + "value_type": "OBJECT", + "default_value": {}, + "help": "使用特征选择组件", + "reference_type": "DEFAULT", + "label": "配置" + }, + "Slot_driver_cores": { + "reference": "", + "value_type": "STRING", + "default_value": "1000m", + "help": "driver核心数", + "reference_type": "DEFAULT", + "label": "driver核心数" + }, + "Slot_driver_core_limit": { + "reference": "", + "value_type": "STRING", + "default_value": "1200m", + "help": "driver核心数限制", + "reference_type": "DEFAULT", + "label": "driver核心数限制" + }, + "Slot_driver_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "1024m", + "help": "driver内存", + "reference_type": "DEFAULT", + "label": "driver内存" + }, + "Slot_executor_cores": { + "reference": "", + "value_type": "STRING", + "default_value": "1000m", + "help": "excutor核心数", + "reference_type": "DEFAULT", + "label": "excutor核心数" + }, + "Slot_executor_instances": { + "reference": "", + "value_type": "INT", + "default_value": 1, + "help": "excutor实例数", + "reference_type": "DEFAULT", + "label": "excutor实例数" + }, + "Slot_executor_memory": { + "reference": "", + "value_type": "STRING", + "default_value": "512m", + "help": "excutor内存", + "reference_type": "DEFAULT", + "label": "excutor内存" + }, + + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST", + "default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data"}], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST", + "default_value": [{"mountPath": "/data","name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/transformer.metayml b/web_console_v2/client/src/jobMetaDatas/transformer.metayml new file mode 100644 index 000000000..ca4a8420e --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/transformer.metayml @@ -0,0 +1,54 @@ +{ + "apiVersion": "sparkoperator.k8s.io/v1beta2", + "kind": "SparkApplication", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "labels": ${Slot_labels}, + "annotations": { + "queue": "fedlearner-spark", + "schedulerName": "batch", + }, + }, + "spec": { + "type": "Python", + "pythonVersion": "3", + "mode": "cluster", + "image": ${Slot_image} or system.variables.image_repo + "/pp_data_inspection:" + system.version, + "imagePullPolicy": "IfNotPresent", + "volumes": ${Slot_volumes}, + "mainApplicationFile": ${Slot_spark_transformer_file}, + "arguments": [ + ${Slot_dataset}, + "rds/**", + str(${Slot_configs}) + ], + "sparkVersion": "3.0.0", + "restartPolicy": { + "type": "OnFailure", + "onFailureRetries": 3, + "onFailureRetryInterval": 10, + "onSubmissionFailureRetries": 5, + "onSubmissionFailureRetryInterval": 20 + }, + "driver": { + "cores": ${Slot_driver_cores}, + "coreLimit": ${Slot_driver_core_limit}, + "memory": ${Slot_driver_memory}, + "labels": { + "version": "3.0.0" + }, + "serviceAccount": "spark", + "volumeMounts": ${Slot_volume_mounts} + }, + "executor": { + "cores": ${Slot_executor_cores}, + "instances": ${Slot_executor_instances}, + "memory": ${Slot_executor_memory}, + "labels": { + "version": "3.0.0" + }, + "volumeMounts": ${Slot_volume_mounts} + } + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/tree_model_evaluation.json b/web_console_v2/client/src/jobMetaDatas/tree_model_evaluation.json new file mode 100644 index 000000000..87ca2f2f5 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/tree_model_evaluation.json @@ -0,0 +1,274 @@ +{ + "Slot_role": { + "reference": "", + "value_type": "STRING", + "default_value": "Leader", + "help": "Flapp 通讯时的角色 Leader 或 Follower", + "reference_type": "DEFAULT", + "label": "Flapp通讯时角色" + }, + "Slot_storage_root_path": { + "reference": "project.variables.storage_root_path", + "value_type": "STRING", + "default_value": "/data", + "help": "联邦学习中任务存储根目录", + "reference_type": "PROJECT", + "label": "存储根目录" + }, + "Slot_image_version": { + "reference": "", + "value_type": "STRING", + "default_value": "882310f", + "help": "建议不修改,指定Pod中运行的容器镜像版本,前缀为system.variables.image_repo + '/fedlearner:'", + "reference_type": "DEFAULT", + "label": "容器镜像版本" + }, + "Slot_worker_cpu": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "8000m", + "label": "所需CPU", + "help": "所需CPU" + }, + "Slot_worker_mem": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "16Gi", + "label": "所需内存", + "help": "所需内存" + }, + "Slot_data_source": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "求交数据集名称", + "help": "求交数据集名称" + }, + "Slot_data_path": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "数据存放位置", + "help": "数据存放位置" + }, + "Slot_mode": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "eval", + "label": "任务类型,train或eval", + "help": "任务类型,train或eval" + }, + "Slot_loss_type": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "logistic", + "label": "损失函数类型", + "help": "损失函数类型,logistic或mse,默认logistic" + }, + "Slot_file_ext": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": ".data", + "label": "文件后缀", + "help": "文件后缀" + }, + "Slot_file_type": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "tfrecord", + "label": "文件类型,csv或tfrecord", + "help": "文件类型,csv或tfrecord" + }, + "Slot_learning_rate": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "NUMBER", + "default_value": 0.3, + "label": "学习率", + "help": "学习率" + }, + "Slot_max_iters": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 5, + "label": "迭代数", + "help": "树的数量" + }, + "Slot_max_depth": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 3, + "label": "最大深度", + "help": "最大深度" + }, + "Slot_max_bins": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 33, + "label": "最大分箱数", + "help": "最大分箱数" + }, + "Slot_l2_regularization": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "NUMBER", + "default_value": 1, + "label": "L2惩罚系数", + "help": "L2惩罚系数" + }, + "Slot_num_parallel": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 1, + "label": "进程数量", + "help": "进程数量" + }, + "Slot_enable_packing": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": true, + "label": "是否开启优化", + "help": "是否开启优化" + }, + "Slot_ignore_fields": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "不入模的特征", + "help": "以逗号分隔如:name,age,sex" + }, + "Slot_cat_fields": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "类别类型特征", + "help": "类别类型特征,特征的值需要是非负整数。以逗号分隔如:alive,country,sex" + }, + "Slot_label_field": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "label", + "label": "label特征名", + "help": "label特征名" + }, + "Slot_load_model_name": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "模型任务名称", + "help": "按任务名称加载模型,{STORAGE_ROOT_PATH}/job_output/{LOAD_MODEL_NAME}/exported_models" + }, + "Slot_load_model_path": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "模型文件地址", + "help": "模型文件地址" + }, + "Slot_validation_data_path": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "验证数据集地址", + "help": "验证数据集地址" + }, + "Slot_send_scores_to_follower": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": false, + "label": "是否发送结果到follower", + "help": "是否发送结果到follower" + }, + "Slot_send_metrics_to_follower": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": false, + "label": "是否发送指标到follower", + "help": "是否发送指标到follower" + }, + "Slot_verbosity": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 1, + "label": "日志输出等级", + "help": "日志输出等级" + }, + "Slot_no_data": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": false, + "label": "Leader是否没数据", + "help": "Leader是否没数据" + }, + "Slot_verify_example_ids": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": false, + "label": "是否检查example_id对齐", + "help": "是否检查example_id对齐 If set to true, the first column of the data will be treated as example ids that must match between leader and follower" + }, + "Slot_es_batch_size": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 10, + "label": "ES_BATCH_SIZE", + "help": "ES_BATCH_SIZE" + }, + "Slot_worker_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,worker pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Worker额外环境变量" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT", + "default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "FLAPP额外元信息" + }, + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST", + "default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data"}], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST", + "default_value": [{"mountPath": "/data","name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/tree_model_evaluation.metayml b/web_console_v2/client/src/jobMetaDatas/tree_model_evaluation.metayml new file mode 100644 index 000000000..5e8a031f9 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/tree_model_evaluation.metayml @@ -0,0 +1,209 @@ +{ + "apiVersion": "fedlearner.k8s.io/v1alpha1", + "kind": "FLApp", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "annotations":{ + "queue": "fedlearner", + "schedulerName": "batch" + }, + "labels": ${Slot_labels} + }, + "spec": { + "role": ${Slot_role}, + "cleanPodPolicy": "All", + "peerSpecs": { + "Leader" if ${Slot_role}=="Follower" else "Follower": { + "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc:80", + "authority": project.participants[0].egress_host, + "extraHeaders": { + "x-host": "fedlearner-operator." + project.participants[0].egress_domain + } + } + }, + "flReplicaSpecs": { + "Worker": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/job_output/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "MODE", + "value": ${Slot_mode} + }, + { + "name": "LOSS_TYPE", + "value": ${Slot_loss_type} + }, + { + "name": "DATA_SOURCE", + "value": ${Slot_data_source} + }, + { + "name": "DATA_PATH", + "value": ${Slot_data_path} + }, + { + "name": "VALIDATION_DATA_PATH", + "value": ${Slot_validation_data_path} + }, + { + "name": "NO_DATA", + "value": str(${Slot_no_data}) + }, + { + "name": "FILE_EXT", + "value": ${Slot_file_ext} + }, + { + "name": "FILE_TYPE", + "value": ${Slot_file_type} + }, + { + "name": "LOAD_MODEL_PATH", + "value": ${Slot_load_model_path} + }, + { + "name": "LOAD_MODEL_NAME", + "value": ${Slot_load_model_name} + }, + { + "name": "VERBOSITY", + "value": str(${Slot_verbosity}) + }, + { + "name": "LEARNING_RATE", + "value": str(${Slot_learning_rate}) + }, + { + "name": "MAX_ITERS", + "value": str(${Slot_max_iters}) + }, + { + "name": "MAX_DEPTH", + "value": str(${Slot_max_depth}) + }, + { + "name": "MAX_BINS", + "value": str(${Slot_max_bins}) + }, + { + "name": "L2_REGULARIZATION", + "value": str(${Slot_l2_regularization}) + }, + { + "name": "NUM_PARALLEL", + "value": str(${Slot_num_parallel}) + }, + { + "name": "VERIFY_EXAMPLE_IDS", + "value": str(${Slot_verify_example_ids}) + }, + { + "name": "IGNORE_FIELDS", + "value": ${Slot_ignore_fields} + }, + { + "name": "CAT_FIELDS", + "value": ${Slot_cat_fields} + }, + { + "name": "LABEL_FIELD", + "value": ${Slot_label_field} + }, + { + "name": "SEND_SCORES_TO_FOLLOWER", + "value": str(${Slot_send_scores_to_follower}) + }, + { + "name": "SEND_METRICS_TO_FOLLOWER", + "value": str(${Slot_send_metrics_to_follower}) + }, + { + "name": "ENABLE_PACKING", + "value": str(${Slot_enable_packing}) + }, + { + "name": "ES_BATCH_SIZE", + "value": str(${Slot_es_batch_size}) + } + ] + ${Slot_worker_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + }, + { + "containerPort": 50052, + "name": "tf-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/wait4pair_wrapper.sh" + ], + "args": [ + "/app/deploy/scripts/trainer/run_tree_worker.sh" + ], + "resources": { + "limits": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_mem} + }, + "requests": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_mem} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": True, + "replicas": 1 + } + } + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/tree_model_training.json b/web_console_v2/client/src/jobMetaDatas/tree_model_training.json new file mode 100644 index 000000000..ac37f8972 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/tree_model_training.json @@ -0,0 +1,274 @@ +{ + "Slot_role": { + "reference": "", + "value_type": "STRING", + "default_value": "Leader", + "help": "Flapp 通讯时的角色 Leader 或 Follower", + "reference_type": "DEFAULT", + "label": "Flapp通讯时角色" + }, + "Slot_storage_root_path": { + "reference": "project.variables.storage_root_path", + "value_type": "STRING", + "default_value": "/data", + "help": "联邦学习中任务存储根目录", + "reference_type": "PROJECT", + "label": "存储根目录" + }, + "Slot_image_version": { + "reference": "", + "value_type": "STRING", + "default_value": "882310f", + "help": "建议不修改,指定Pod中运行的容器镜像版本,前缀为system.variables.image_repo + '/fedlearner:'", + "reference_type": "DEFAULT", + "label": "容器镜像版本" + }, + "Slot_worker_cpu": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "8000m", + "label": "所需CPU", + "help": "所需CPU" + }, + "Slot_worker_mem": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "16Gi", + "label": "所需内存", + "help": "所需内存" + }, + "Slot_data_source": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "求交数据集名称", + "help": "求交数据集名称" + }, + "Slot_data_path": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "数据存放位置", + "help": "数据存放位置" + }, + "Slot_mode": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "train", + "label": "任务类型,train或eval", + "help": "任务类型,train或eval" + }, + "Slot_loss_type": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "logistic", + "label": "损失函数类型", + "help": "损失函数类型,logistic或mse,默认logistic" + }, + "Slot_file_ext": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": ".data", + "label": "文件后缀", + "help": "文件后缀" + }, + "Slot_file_type": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "tfrecord", + "label": "文件类型,csv或tfrecord", + "help": "文件类型,csv或tfrecord" + }, + "Slot_learning_rate": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "NUMBER", + "default_value": 0.3, + "label": "学习率", + "help": "学习率" + }, + "Slot_max_iters": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 5, + "label": "迭代数", + "help": "树的数量" + }, + "Slot_max_depth": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 3, + "label": "最大深度", + "help": "最大深度" + }, + "Slot_max_bins": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 33, + "label": "最大分箱数", + "help": "最大分箱数" + }, + "Slot_l2_regularization": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "NUMBER", + "default_value": 1, + "label": "L2惩罚系数", + "help": "L2惩罚系数" + }, + "Slot_num_parallel": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 1, + "label": "进程数量", + "help": "进程数量" + }, + "Slot_enable_packing": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": true, + "label": "是否开启优化", + "help": "是否开启优化" + }, + "Slot_ignore_fields": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "不入模的特征", + "help": "以逗号分隔如:name,age,sex" + }, + "Slot_cat_fields": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "类别类型特征", + "help": "类别类型特征,特征的值需要是非负整数。以逗号分隔如:alive,country,sex" + }, + "Slot_label_field": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "label", + "label": "label特征名", + "help": "label特征名" + }, + "Slot_load_model_name": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "模型任务名称", + "help": "按任务名称加载模型,{STORAGE_ROOT_PATH}/job_output/{LOAD_MODEL_NAME}/exported_models" + }, + "Slot_load_model_path": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "模型文件地址", + "help": "模型文件地址" + }, + "Slot_validation_data_path": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "STRING", + "default_value": "", + "label": "验证数据集地址", + "help": "验证数据集地址" + }, + "Slot_send_scores_to_follower": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": false, + "label": "是否发送结果到follower", + "help": "是否发送结果到follower" + }, + "Slot_send_metrics_to_follower": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": false, + "label": "是否发送指标到follower", + "help": "是否发送指标到follower" + }, + "Slot_verbosity": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 1, + "label": "日志输出等级", + "help": "日志输出等级" + }, + "Slot_no_data": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": false, + "label": "Leader是否没数据", + "help": "Leader是否没数据" + }, + "Slot_verify_example_ids": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "BOOL", + "default_value": false, + "label": "是否检查example_id对齐", + "help": "是否检查example_id对齐 If set to true, the first column of the data will be treated as example ids that must match between leader and follower" + }, + "Slot_es_batch_size": { + "reference_type": "DEFAULT", + "reference": "", + "value_type": "INT", + "default_value": 10, + "label": "ES_BATCH_SIZE", + "help": "ES_BATCH_SIZE" + }, + "Slot_worker_envs": { + "reference": "", + "value_type": "LIST", + "default_value": [], + "help": "数组类型,worker pod额外的环境变量", + "reference_type": "DEFAULT", + "label": "Worker额外环境变量" + }, + "Slot_labels": { + "reference": "system.variables.labels", + "value_type": "OBJECT", + "default_value": {}, + "help": "建议不修改,格式: {}", + "reference_type": "SYSTEM", + "label": "FLAPP额外元信息" + }, + "Slot_volumes": { + "reference": "system.variables.volumes_list", + "value_type": "LIST", + "default_value": [{"persistentVolumeClaim": {"claimName": "pvc-fedlearner-default"},"name": "data"}], + "help": "建议不修改,数组类型,和volume_mounts一一对应", + "reference_type": "SYSTEM", + "label": "为Pod提供的卷" + }, + "Slot_volume_mounts": { + "reference": "system.variables.volume_mounts_list", + "value_type": "LIST", + "default_value": [{"mountPath": "/data","name": "data"}], + "help": "建议不修改,容器中卷挂载的位置,数组类型", + "reference_type": "SYSTEM", + "label": "卷挂载位置" + } +} diff --git a/web_console_v2/client/src/jobMetaDatas/tree_model_training.metayml b/web_console_v2/client/src/jobMetaDatas/tree_model_training.metayml new file mode 100644 index 000000000..5e8a031f9 --- /dev/null +++ b/web_console_v2/client/src/jobMetaDatas/tree_model_training.metayml @@ -0,0 +1,209 @@ +{ + "apiVersion": "fedlearner.k8s.io/v1alpha1", + "kind": "FLApp", + "metadata": { + "name": self.name, + "namespace": system.variables.namespace, + "annotations":{ + "queue": "fedlearner", + "schedulerName": "batch" + }, + "labels": ${Slot_labels} + }, + "spec": { + "role": ${Slot_role}, + "cleanPodPolicy": "All", + "peerSpecs": { + "Leader" if ${Slot_role}=="Follower" else "Follower": { + "peerURL": "fedlearner-stack-ingress-nginx-controller.default.svc:80", + "authority": project.participants[0].egress_host, + "extraHeaders": { + "x-host": "fedlearner-operator." + project.participants[0].egress_domain + } + } + }, + "flReplicaSpecs": { + "Worker": { + "template": { + "spec": { + "restartPolicy": "Never", + "containers": [ + { + "env": system.basic_envs_list + [ + { + "name": "STORAGE_ROOT_PATH", + "value": ${Slot_storage_root_path} + }, + { + "name": "ROLE", + "value": ${Slot_role}.lower() + }, + { + "name": "APPLICATION_ID", + "value": self.name + }, + { + "name": "OUTPUT_BASE_DIR", + "value": ${Slot_storage_root_path} + "/job_output/" + self.name + }, + { + "name": "EGRESS_URL", + "value": "fedlearner-stack-ingress-nginx-controller.default.svc:80" + }, + { + "name": "EGRESS_HOST", + "value": project.participants[0].egress_host + }, + { + "name": "EGRESS_DOMAIN", + "value": project.participants[0].egress_domain + }, + { + "name": "MODE", + "value": ${Slot_mode} + }, + { + "name": "LOSS_TYPE", + "value": ${Slot_loss_type} + }, + { + "name": "DATA_SOURCE", + "value": ${Slot_data_source} + }, + { + "name": "DATA_PATH", + "value": ${Slot_data_path} + }, + { + "name": "VALIDATION_DATA_PATH", + "value": ${Slot_validation_data_path} + }, + { + "name": "NO_DATA", + "value": str(${Slot_no_data}) + }, + { + "name": "FILE_EXT", + "value": ${Slot_file_ext} + }, + { + "name": "FILE_TYPE", + "value": ${Slot_file_type} + }, + { + "name": "LOAD_MODEL_PATH", + "value": ${Slot_load_model_path} + }, + { + "name": "LOAD_MODEL_NAME", + "value": ${Slot_load_model_name} + }, + { + "name": "VERBOSITY", + "value": str(${Slot_verbosity}) + }, + { + "name": "LEARNING_RATE", + "value": str(${Slot_learning_rate}) + }, + { + "name": "MAX_ITERS", + "value": str(${Slot_max_iters}) + }, + { + "name": "MAX_DEPTH", + "value": str(${Slot_max_depth}) + }, + { + "name": "MAX_BINS", + "value": str(${Slot_max_bins}) + }, + { + "name": "L2_REGULARIZATION", + "value": str(${Slot_l2_regularization}) + }, + { + "name": "NUM_PARALLEL", + "value": str(${Slot_num_parallel}) + }, + { + "name": "VERIFY_EXAMPLE_IDS", + "value": str(${Slot_verify_example_ids}) + }, + { + "name": "IGNORE_FIELDS", + "value": ${Slot_ignore_fields} + }, + { + "name": "CAT_FIELDS", + "value": ${Slot_cat_fields} + }, + { + "name": "LABEL_FIELD", + "value": ${Slot_label_field} + }, + { + "name": "SEND_SCORES_TO_FOLLOWER", + "value": str(${Slot_send_scores_to_follower}) + }, + { + "name": "SEND_METRICS_TO_FOLLOWER", + "value": str(${Slot_send_metrics_to_follower}) + }, + { + "name": "ENABLE_PACKING", + "value": str(${Slot_enable_packing}) + }, + { + "name": "ES_BATCH_SIZE", + "value": str(${Slot_es_batch_size}) + } + ] + ${Slot_worker_envs}, + "imagePullPolicy": "IfNotPresent", + "name": "tensorflow", + "volumeMounts": ${Slot_volume_mounts}, + "image": system.variables.image_repo + "/fedlearner:" + ${Slot_image_version}, + "ports": [ + { + "containerPort": 50051, + "name": "flapp-port", + "protocol": "TCP" + }, + { + "containerPort": 50052, + "name": "tf-port", + "protocol": "TCP" + } + ], + "command": [ + "/app/deploy/scripts/wait4pair_wrapper.sh" + ], + "args": [ + "/app/deploy/scripts/trainer/run_tree_worker.sh" + ], + "resources": { + "limits": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_mem} + }, + "requests": { + "cpu": ${Slot_worker_cpu}, + "memory": ${Slot_worker_mem} + } + } + } + ], + "imagePullSecrets": [ + { + "name": "regcred" + } + ], + "volumes": ${Slot_volumes} + } + }, + "pair": True, + "replicas": 1 + } + } + } +} diff --git a/web_console_v2/client/src/services/algorithm.ts b/web_console_v2/client/src/services/algorithm.ts new file mode 100644 index 000000000..ebd0eb6b9 --- /dev/null +++ b/web_console_v2/client/src/services/algorithm.ts @@ -0,0 +1,308 @@ +import request, { BASE_URL } from 'libs/request'; +import { APIResponse } from 'typings/app'; +import { + FileTreeNode, + FileContent, + FileQueryParams, + UploadFileQueryParams, + UpdateFileQueryParams, + RenameFileQueryParams, + DeleteFileQueryParams, + AlgorithmProject, + Algorithm, +} from 'typings/algorithm'; + +export function fetchAlgorithmProjectFileTreeList(id: ID): APIResponse<FileTreeNode[]> { + return request.get(`/v2/algorithm_projects/${id}/tree`).then((resp) => { + // 204 No Content + if (!resp) { + return { + data: [], + }; + } + return resp; + }); +} +export function fetchAlgorithmProjectFileContentDetail( + id: ID, + params: FileQueryParams, +): APIResponse<FileContent> { + return request.get(`/v2/algorithm_projects/${id}/files`, { + params, + }); +} + +export function uploadAlgorithmProjectFileContent( + id: ID, + payload: UploadFileQueryParams, +): APIResponse<Omit<FileContent, 'content'>> { + const formData = new FormData(); + + Object.keys(payload).forEach((key) => { + const value = (payload as any)[key]; + formData.append(key, value); + }); + + return request.post(`/v2/algorithm_projects/${id}/files`, formData); +} +export function createOrUpdateAlgorithmProjectFileContent( + id: ID, + payload: UpdateFileQueryParams, +): APIResponse<FileContent> { + const formData = new FormData(); + + Object.keys(payload).forEach((key) => { + const value = (payload as any)[key]; + formData.append(key, value); + }); + + return request.put(`/v2/algorithm_projects/${id}/files`, formData); +} +export function renameAlgorithmProjectFileContent( + id: ID, + payload: RenameFileQueryParams, +): Promise<null> { + return request.patch(`/v2/algorithm_projects/${id}/files`, payload); +} +export function deleteAlgorithmProjectFileContent( + id: ID, + params: DeleteFileQueryParams, +): Promise<null> { + return request.delete(`/v2/algorithm_projects/${id}/files`, { + params, + }); +} + +export function fetchAlgorithmFileTreeList(id?: ID): APIResponse<FileTreeNode[]> { + return request.get(`/v2/algorithms/${id}/tree`).then((resp) => { + // 204 No Content + if (!resp) { + return { + data: [], + }; + } + return resp; + }); +} + +export function fetchAlgorithmFileContentDetail( + id: ID, + params: FileQueryParams, +): APIResponse<FileContent> { + return request.get(`/v2/algorithms/${id}/files`, { + params, + }); +} + +export function fetchPendingAlgorithmFileTreeList( + projId?: ID, + id?: ID, +): APIResponse<FileTreeNode[]> { + return request.get(`/v2/projects/${projId}/pending_algorithms/${id}/tree`).then((resp) => { + // 204 No Content + if (!resp) { + return { + data: [], + }; + } + return resp; + }); +} + +export function fetchPendingAlgorithmFileContentDetail( + projId?: ID, + id?: ID, + params?: FileQueryParams, +): APIResponse<FileContent> { + return request.get(`/v2/projects/${projId}/pending_algorithms/${id}/files`, { + params, + }); +} + +export function fetchProjectList( + projectId?: ID, + params?: Record<string, any> | string, +): APIResponse<AlgorithmProject[]> { + if (!projectId && projectId !== 0) { + return Promise.reject(new Error('请选择工作区')); + } + return request.get(`/v2/projects/${projectId}/algorithm_projects`, { + params, + removeFalsy: true, + snake_case: true, + }); +} + +// 拉取对侧发送过来的算法列表 +export function fetchProjectPendingList(projectId?: ID): APIResponse<Algorithm[]> { + if (!projectId && projectId !== 0) { + return Promise.reject(new Error('请选择工作区')); + } + return request.get(`/v2/projects/${projectId}/pending_algorithms`); +} + +export function createProject( + platformProjId: ID, + payload: FormData, +): APIResponse<AlgorithmProject> { + return request.post(`/v2/projects/${platformProjId}/algorithm_projects`, payload); +} + +export function patchProject( + projectId: ID, + payload: Partial<AlgorithmProject>, +): APIResponse<AlgorithmProject> { + return request.patch(`/v2/algorithm_projects/${projectId}`, payload); +} + +export function fetchProjectDetail(id: ID): APIResponse<AlgorithmProject> { + return request.get(`/v2/algorithm_projects/${id}`); +} + +export function getAlgorithmDetail(id: ID): APIResponse<Algorithm> { + return request.get(`/v2/algorithms/${id}`); +} + +export function postPublishAlgorithm(id: ID, comment?: string): APIResponse<AlgorithmProject> { + return request.post(`/v2/algorithm_projects/${id}:publish`, { comment }); +} + +export function postSendAlgorithm(id: ID, comment?: string): APIResponse<AlgorithmProject> { + return request.post(`/v2/algorithms/${id}:send`, { + comment, + }); +} + +export function postAcceptAlgorithm( + projectId: ID, + algorithmProjId: ID, + payload: { + name: string; + comment?: string; + }, +) { + return request.post( + `/v2/projects/${projectId}/pending_algorithms/${algorithmProjId}:accept`, + payload, + ); +} + +export function fetchAlgorithmList( + project_id?: ID, + params?: { algo_project_id: ID }, +): APIResponse<Algorithm[]> { + return request.get(`/v2/projects/${project_id}/algorithms`, { params }); +} + +export function deleteAlgorithm(id: ID) { + return request.delete(`/v2/algorithms/${id}`); +} + +export function deleteAlgorithmProject(id: ID) { + return request.delete(`/v2/algorithm_projects/${id}`); +} + +export function getFullAlgorithmProjectDownloadHref(algorithmProjectId: ID): string { + return `${window.location.origin}${BASE_URL}/v2/algorithm_projects/${algorithmProjectId}?download=true`; +} +export function getFullAlgorithmDownloadHref(algorithmId: ID): string { + return `${window.location.origin}${BASE_URL}/v2/algorithms/${algorithmId}?download=true`; +} + +export function updatePresetAlgorithm(payload?: any): APIResponse<AlgorithmProject[]> { + return request.post(`/v2/preset_algorithms:update`, payload); +} + +export function releaseAlgorithmProject( + projectId?: ID, + algorithmId?: ID, + params?: { comment: string }, +): Promise<null> { + return request.post(`/v2/projects/${projectId}/algorithms/${algorithmId}:release`, { params }); +} + +export function publishAlgorithm( + projectId?: ID, + algorithmId?: ID, + params?: { comment: string }, +): Promise<null> { + return request.post(`/v2/projects/${projectId}/algorithms/${algorithmId}:publish`, { params }); +} + +export function unpublishAlgorithm(projectId?: ID, algorithmId?: ID): Promise<null> { + return request.post(`/v2/projects/${projectId}/algorithms/${algorithmId}:unpublish`); +} + +export function fetchPeerAlgorithmProjectList( + projectId?: ID, + participantId?: ID, + params?: Record<string, any> | string, +): APIResponse<AlgorithmProject[]> { + return request.get(`/v2/projects/${projectId}/participants/${participantId}/algorithm_projects`, { + params, + removeFalsy: true, + snake_case: true, + }); +} + +export function fetchPeerAlgorithmProjectById( + projectId?: ID, + participantId?: ID, + algorithm_project_uuid?: ID, +): APIResponse<AlgorithmProject> { + return request.get( + `/v2/projects/${projectId}/participants/${participantId}/algorithm_projects/${algorithm_project_uuid}`, + ); +} + +export function fetchPeerAlgorithmList( + projectId?: ID, + participantId?: ID, + params?: { algorithm_project_uuid: ID }, +): APIResponse<Algorithm[]> { + return request.get(`/v2/projects/${projectId}/participants/${participantId}/algorithms`, { + params, + }); +} + +export function fetchPeerAlgorithmDetail( + projectId?: ID, + participantId?: ID, + uuid?: ID, +): APIResponse<Algorithm> { + return request.get(`/v2/projects/${projectId}/participants/${participantId}/algorithms/${uuid}`); +} + +export function fetchPeerAlgorithmFileTreeList( + projectId?: ID, + participantId?: ID, + uuid?: ID, +): APIResponse<FileTreeNode[]> { + return request + .get(`/v2/projects/${projectId}/participants/${participantId}/algorithms/${uuid}/tree`) + .then((resp) => { + // 204 No Content + if (!resp) { + return { + data: [], + }; + } + return resp; + }); +} + +export function fetchPeerAlgorithmProjectFileContentDetail( + projectId?: ID, + participantId?: ID, + uuid?: ID, + params?: FileQueryParams, +): APIResponse<FileContent> { + return request.get( + `/v2/projects/${projectId}/participants/${participantId}/algorithms/${uuid}/files`, + { params }, + ); +} + +export function fetchAlgorithmByUuid(projectId: ID, algorithmUuid: ID): APIResponse<Algorithm> { + return request.get(`/v2/projects/${projectId}/algorithms/${algorithmUuid}`); +} diff --git a/web_console_v2/client/src/services/audit.ts b/web_console_v2/client/src/services/audit.ts new file mode 100644 index 000000000..e2cc018db --- /dev/null +++ b/web_console_v2/client/src/services/audit.ts @@ -0,0 +1,15 @@ +import request from 'libs/request'; +import { APIResponse } from 'typings/app'; +import { Audit, AuditQueryParams, AuditDeleteParams } from 'typings/audit'; + +export function fetchAuditList(params?: AuditQueryParams): APIResponse<Audit[]> { + return request.get('/v2/events', { + params, + removeFalsy: true, + snake_case: true, + }); +} + +export function deleteAudit(params: AuditDeleteParams) { + return request.delete('/v2/events', { params, removeFalsy: true, snake_case: true }); +} diff --git a/web_console_v2/client/src/services/cleanup.ts b/web_console_v2/client/src/services/cleanup.ts new file mode 100644 index 000000000..258122059 --- /dev/null +++ b/web_console_v2/client/src/services/cleanup.ts @@ -0,0 +1,15 @@ +import request from 'libs/request'; +import { APIResponse } from 'typings/app'; +import { Cleanup, CleanupQueryParams } from 'typings/cleanup'; + +export function fetchCleanupList(params?: CleanupQueryParams): APIResponse<Cleanup[]> { + return request(`/v2/cleanups`, { params, snake_case: true }); +} + +export function fetchCleanupById(cleanup_id?: ID): APIResponse<Cleanup> { + return request(`/v2/cleanups/${cleanup_id}`); +} + +export function postCleanupState(cleanup_id?: ID): APIResponse<Cleanup> { + return request.post(`/v2/cleanups/${cleanup_id}:cancel`); +} diff --git a/web_console_v2/client/src/services/composer.ts b/web_console_v2/client/src/services/composer.ts new file mode 100644 index 000000000..28859ec1d --- /dev/null +++ b/web_console_v2/client/src/services/composer.ts @@ -0,0 +1,29 @@ +import request from 'libs/request'; +import { APIResponse } from 'typings/app'; +import { ItemStatus, SchedulerItem, SchedulerRunner, SchedulerQueryParams } from 'typings/composer'; + +export function fetchSchedulerItemList( + params?: SchedulerQueryParams, +): APIResponse<SchedulerItem[]> { + return request(`/v2/scheduler_items`, { params, snake_case: true }); +} + +export function fetchSchedulerRunnerList( + params?: SchedulerQueryParams, +): APIResponse<SchedulerRunner[]> { + return request(`/v2/scheduler_runners`, { params, snake_case: true }); +} + +export function fetchRunnersByItemId( + item_id: ID, + params?: SchedulerQueryParams, +): APIResponse<SchedulerRunner[]> { + return request(`/v2/scheduler_items/${item_id}`, { params, snake_case: true }); +} + +export function patchEditItemState( + item_id: ID, + status: ItemStatus, +): APIResponse<SchedulerRunner[]> { + return request.patch(`/v2/scheduler_items/${item_id}`, { status }); +} diff --git a/web_console_v2/client/src/services/flag.ts b/web_console_v2/client/src/services/flag.ts new file mode 100644 index 000000000..ea475d437 --- /dev/null +++ b/web_console_v2/client/src/services/flag.ts @@ -0,0 +1,10 @@ +import request from 'libs/request'; +import { Flag } from 'typings/flag'; + +export function fetchFlagList(): Promise<{ data: Flag }> { + return request('/v2/flags'); +} + +export function fetchParticipantFlagById(participantId: ID): Promise<{ data: Flag }> { + return request(`/v2/participants/${participantId}/flags`); +} diff --git a/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/files/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/files/index.ts new file mode 100644 index 000000000..31d175035 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/files/index.ts @@ -0,0 +1,80 @@ +import { AxiosRequestConfig } from 'axios'; +import { FileContent } from 'typings/algorithm'; +import { getFileInfoByFilePath } from 'shared/file'; + +import { leaderPythonFile, followerPythonFile } from 'services/mocks/v2/algorithms/examples'; + +const get = (config: AxiosRequestConfig) => { + const { parentPath, fileName } = getFileInfoByFilePath(config.params.path); + let finalFile: FileContent = { + path: parentPath, + filename: fileName, + content: `I am ${config.params.path}`, + }; + + switch (config.params.path) { + case 'leader/main.py': + finalFile = leaderPythonFile; + break; + case 'follower/main.py': + finalFile = followerPythonFile; + break; + default: + break; + } + + return { + data: { + data: finalFile, + }, + status: 200, + }; +}; + +export const put = (config: AxiosRequestConfig) => { + const path = config.data.get('path'); + const fileName = config.data.get('filename'); + const content = config.data.get('file'); + const finalFile: FileContent = { + path: path, + filename: fileName, + content: content, + }; + + return { + data: { + data: finalFile, + }, + status: 200, + }; +}; + +export const post = (config: AxiosRequestConfig) => { + const path = config.data.get('path'); + const fileName = config.data.get('filename'); + const finalFile: Omit<FileContent, 'content'> = { + path: path, + filename: fileName, + }; + + return { + data: { + data: finalFile, + }, + status: 200, + }; +}; + +export const patch = { + data: { + data: null, + }, + status: 200, +}; +export const DELETE = { + data: { + data: null, + }, + status: 200, +}; +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/index.ts new file mode 100644 index 000000000..24a62ad00 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/index.ts @@ -0,0 +1,10 @@ +import { normalAlgorithmProject } from 'services/mocks/v2/algorithm_projects/examples'; + +const get = () => { + return { + data: { data: normalAlgorithmProject }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/tree/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/tree/index.ts new file mode 100644 index 000000000..2e221716f --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/__id__/tree/index.ts @@ -0,0 +1,10 @@ +import { fileTree } from 'services/mocks/v2/algorithms/examples'; + +const get = { + data: { + data: fileTree, + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithm_projects/examples.ts b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/examples.ts new file mode 100644 index 000000000..d6bc9dd3d --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/examples.ts @@ -0,0 +1,145 @@ +import { + FileTreeNode, + FileContent, + AlgorithmProject, + EnumAlgorithmProjectType, + AlgorithmVersionStatus, +} from 'typings/algorithm'; + +export const fileTree: FileTreeNode[] = [ + { + filename: 'follower', + path: 'follower', + size: 96, + mtime: 1637141275, + is_directory: true, + files: [ + { + filename: 'main.py', + path: 'follower/main.py', + mtime: 1637141275, + size: 0, + is_directory: false, + files: [], + }, + ], + }, + { + filename: 'leader', + path: 'leader', + size: 96, + mtime: 1637141275, + is_directory: true, + files: [ + { + filename: 'main.py', + path: 'leader/main.py', + size: 17, + mtime: 1637141275, + is_directory: false, + files: [], + }, + ], + }, +]; + +export const normalAlgorithmProject: AlgorithmProject = { + id: 3, + name: 'e2e test', + project_id: 1, + latest_version: 0, + type: 'NN_VERTICAL' as EnumAlgorithmProjectType, + source: 'USER', + publish_status: 'UNPUBLISHED', + release_status: 'RELEASED', + creator_id: null, + username: 'abcabc', + participant_id: null, + path: '/data/algorithm_projects/e2e-test-20211206_115929-57da1', + parameter: { + variables: [ + { + name: 'aaa', + required: true, + value: '', + display_name: '', + comment: '', + value_type: 'STRING', + }, + { + name: 'bbbb', + required: true, + value: '', + display_name: '', + comment: '', + value_type: 'STRING', + }, + ], + }, + comment: 'algorithm for end to end test', + created_at: 1638791969, + updated_at: 1638864824, + deleted_at: null, + algorithms: [ + { + id: 3, + name: 'e2e test', + project_id: 1, + version: 1, + type: 'NN_VERTICAL' as EnumAlgorithmProjectType, + source: 'USER', + algorithm_project_id: 3, + creator_id: null, + username: 'abcabc', + participant_id: null, + path: '/data/algorithms/e2e-test-v1-20211206_115929-2db0f', + status: AlgorithmVersionStatus.PUBLISHED, + parameter: { + variables: [ + { + name: 'aaa', + required: true, + value: '', + display_name: '', + comment: '', + value_type: 'STRING', + }, + { + name: 'bbbb', + required: true, + value: '', + display_name: '', + comment: '', + value_type: 'STRING', + }, + { + name: 'cccc', + required: false, + value: '', + display_name: '', + comment: '', + value_type: 'STRING', + }, + ], + }, + favorite: false, + comment: null, + created_at: 1638791969, + updated_at: 1638791969, + deleted_at: null, + }, + ], +}; + +export const followerPythonFile: FileContent = { + path: 'follower', + filename: 'main.py', + content: + "# coding: utf-8\nimport logging\nimport datetime\n\nimport tensorflow.compat.v1 as tf \nimport fedlearner.trainer as flt \nimport os\n\nfrom slot_2_bucket import slot_2_bucket\n\n_SLOT_2_IDX = {pair[0]: i for i, pair in enumerate(slot_2_bucket)}\n_SLOT_2_BUCKET = slot_2_bucket\nROLE = \"leader\"\n\nparser = flt.trainer_worker.create_argument_parser()\nparser.add_argument('--batch-size', type=int, default=256,\n help='Training batch size.')\nparser.add_argument('--clean-model', type=bool, default=True,\n help='clean checkpoint and saved_model')\nargs = parser.parse_args()\nargs.sparse_estimator = True\n\ndef apply_clean():\n if args.worker_rank == 0 and args.clean_model and tf.io.gfile.exists(args.checkpoint_path):\n tf.logging.info(\"--clean_model flag set. Removing existing checkpoint_path dir:\"\n \" {}\".format(args.checkpoint_path))\n tf.io.gfile.rmtree(args.checkpoint_path)\n\n if args.worker_rank == 0 and args.clean_model and args.export_path and tf.io.gfile.exists(args.export_path):\n tf.logging.info(\"--clean_model flag set. Removing existing savedmodel dir:\"\n \" {}\".format(args.export_path))\n tf.io.gfile.rmtree(args.export_path)\n\n\ndef input_fn(bridge, trainer_master=None):\n dataset = flt.data.DataBlockLoader(\n args.batch_size, ROLE, bridge, trainer_master).make_dataset()\n \n def parse_fn(example):\n feature_map = {}\n feature_map[\"example_id\"] = tf.FixedLenFeature([], tf.string)\n feature_map['fids'] = tf.VarLenFeature(tf.int64)\n # feature_map['y'] = tf.FixedLenFeature([], tf.int64)\n features = tf.parse_example(example, features=feature_map)\n # labels = {'y': features.pop('y')}\n labels = {'y': tf.constant(0)}\n return features, labels\n dataset = dataset.map(map_func=parse_fn, num_parallel_calls=tf.data.experimental.AUTOTUNE)\n dataset = dataset.prefetch(2)\n return dataset\n \n # feature_map = {\"fids\": tf.VarLenFeature(tf.int64)}\n # feature_map['example_id'] = tf.FixedLenFeature([], tf.string)\n # record_batch = dataset.make_batch_iterator().get_next()\n # features = tf.parse_example(record_batch, features=feature_map)\n # return features, None\n\ndef raw_serving_input_receiver_fn():\n feature_map = {\n 'fids_indices': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_indices'),\n 'fids_values': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_values'),\n 'fids_dense_shape': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_dense_shape')\n }\n return tf.estimator.export.ServingInputReceiver(\n feature_map, feature_map)\n\n\ndef model_fn(model, features, labels, mode):\n\n def sum_pooling(embeddings, slots):\n slot_embeddings = []\n for slot in slots:\n slot_embeddings.append(embeddings[_SLOT_2_IDX[slot]])\n if len(slot_embeddings) == 1:\n return slot_embeddings[0]\n return tf.add_n(slot_embeddings)\n\n global_step = tf.train.get_or_create_global_step()\n num_slot, embed_size = len(_SLOT_2_BUCKET), 8\n xavier_initializer = tf.glorot_normal_initializer()\n\n flt.feature.FeatureSlot.set_default_bias_initializer(\n tf.zeros_initializer())\n flt.feature.FeatureSlot.set_default_vec_initializer(\n tf.random_uniform_initializer(-0.0078125, 0.0078125))\n flt.feature.FeatureSlot.set_default_bias_optimizer(\n tf.train.FtrlOptimizer(learning_rate=0.01))\n flt.feature.FeatureSlot.set_default_vec_optimizer(\n tf.train.AdagradOptimizer(learning_rate=0.01))\n\n # deal with input cols\n categorical_embed = []\n num_slot, embed_dim = len(_SLOT_2_BUCKET), 8\n\n with tf.variable_scope(\"leader\"):\n for slot, bucket_size in _SLOT_2_BUCKET:\n fs = model.add_feature_slot(slot, bucket_size)\n fc = model.add_feature_column(fs)\n categorical_embed.append(fc.add_vector(embed_dim))\n\n\n # concate all embeddings\n slot_embeddings = categorical_embed\n concat_embedding = tf.concat(slot_embeddings, axis=1)\n output_size = len(slot_embeddings) * embed_dim\n\n model.freeze_slots(features)\n\n with tf.variable_scope(\"follower\"):\n fc1_size, fc2_size, fc3_size = 16, 16, 16\n w1 = tf.get_variable('w1', shape=[output_size, fc1_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b1 = tf.get_variable(\n 'b1', shape=[fc1_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n w2 = tf.get_variable('w2', shape=[fc1_size, fc2_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b2 = tf.get_variable(\n 'b2', shape=[fc2_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n w3 = tf.get_variable('w3', shape=[fc2_size, fc3_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b3 = tf.get_variable(\n 'b3', shape=[fc3_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n\n act1_l = tf.nn.relu(tf.nn.bias_add(tf.matmul(concat_embedding, w1), b1))\n act1_l = tf.layers.batch_normalization(act1_l, training=True)\n act2_l = tf.nn.relu(tf.nn.bias_add(tf.matmul(act1_l, w2), b2))\n act2_l = tf.layers.batch_normalization(act2_l, training=True)\n embedding = tf.nn.relu(tf.nn.bias_add(tf.matmul(act2_l, w3), b3))\n embedding = tf.layers.batch_normalization(embedding, training=True)\n\n if mode == tf.estimator.ModeKeys.TRAIN:\n embedding_grad = model.send('embedding', embedding, require_grad=True)\n optimizer = tf.train.GradientDescentOptimizer(0.01)\n train_op = model.minimize(\n optimizer, embedding, grad_loss=embedding_grad, global_step=global_step)\n return model.make_spec(mode, loss=tf.math.reduce_mean(embedding), train_op=train_op)\n elif mode == tf.estimator.ModeKeys.PREDICT:\n return model.make_spec(mode, predictions={'embedding': embedding})\n\nif __name__ == '__main__':\n logging.basicConfig(\n level=logging.INFO,\n format='%(asctime)-15s [%(filename)s:%(lineno)d] %(levelname)s %(message)s'\n )\n apply_clean()\n flt.trainer_worker.train(\n ROLE, args, input_fn,\n model_fn, raw_serving_input_receiver_fn)\n", +}; +export const leaderPythonFile: FileContent = { + path: 'leader', + filename: 'main.py', + content: + "# coding: utf-8\n# encoding=utf8\nimport logging\n\nimport tensorflow.compat.v1 as tf\n\nimport fedlearner.trainer as flt\nimport os\n\nROLE = 'follower'\n\nparser = flt.trainer_worker.create_argument_parser()\nparser.add_argument('--batch-size', type=int, default=256,\n help='Training batch size.')\nparser.add_argument('--clean-model', type=bool, default=True,\n help='clean checkpoint and saved_model')\nargs = parser.parse_args()\n\ndef apply_clean():\n if args.worker_rank == 0 and args.clean_model and tf.io.gfile.exists(args.checkpoint_path):\n tf.logging.info(\"--clean_model flag set. Removing existing checkpoint_path dir:\"\n \" {}\".format(args.checkpoint_path))\n tf.io.gfile.rmtree(args.checkpoint_path)\n\n if args.worker_rank == 0 and args.clean_model and args.export_path and tf.io.gfile.exists(args.export_path):\n tf.logging.info(\"--clean_model flag set. Removing existing savedmodel dir:\"\n \" {}\".format(args.export_path))\n tf.io.gfile.rmtree(args.export_path)\n\ndef input_fn(bridge, trainer_master=None):\n dataset = flt.data.DataBlockLoader(\n args.batch_size, ROLE, bridge, trainer_master).make_dataset()\n \n def parse_fn(example):\n feature_map = {}\n feature_map['example_id'] = tf.FixedLenFeature([], tf.string)\n # feature_map['y'] = tf.FixedLenFeature([], tf.int64)\n features = tf.parse_example(example, features=feature_map)\n labels = {'y': tf.constant(0, shape=[1])}\n return features, labels\n \n dataset = dataset.map(map_func=parse_fn,\n num_parallel_calls=tf.data.experimental.AUTOTUNE)\n dataset = dataset.prefetch(2)\n return dataset\n \n\ndef raw_serving_input_receiver_fn():\n features = {}\n features['embedding'] = tf.placeholder(dtype=tf.float32, shape=[1, 16], name='embedding')\n receiver_tensors = {\n 'embedding': features['embedding']\n }\n return tf.estimator.export.ServingInputReceiver(\n features, receiver_tensors)\n\ndef model_fn(model, features, labels, mode):\n global_step = tf.train.get_or_create_global_step()\n xavier_initializer = tf.glorot_normal_initializer()\n\n fc1_size = 16\n with tf.variable_scope('follower'):\n w1f = tf.get_variable('w1f', shape=[\n fc1_size, 1], dtype=tf.float32, initializer=tf.random_uniform_initializer(-0.01, 0.01))\n b1f = tf.get_variable(\n 'b1f', shape=[1], dtype=tf.float32, initializer=tf.zeros_initializer())\n \n if mode == tf.estimator.ModeKeys.TRAIN:\n embedding = model.recv('embedding', tf.float32, require_grad=True)\n else:\n embedding = features['embedding']\n \n logits = tf.nn.bias_add(tf.matmul(embedding, w1f), b1f)\n\n if mode == tf.estimator.ModeKeys.TRAIN:\n y = tf.dtypes.cast(labels['y'], tf.float32)\n loss = tf.nn.sigmoid_cross_entropy_with_logits(\n labels=y, logits=logits)\n loss = tf.math.reduce_mean(loss)\n\n # cala auc\n pred = tf.math.sigmoid(logits)\n print('==============================================================')\n print(tf.shape(y))\n print(tf.shape(pred))\n _, auc = tf.metrics.auc(labels=y, predictions=pred)\n\n logging_hook = tf.train.LoggingTensorHook(\n {\"loss\": loss, \"auc\": auc}, every_n_iter=10)\n\n optimizer = tf.train.GradientDescentOptimizer(0.01)\n train_op = model.minimize(optimizer, loss, global_step=global_step)\n return model.make_spec(mode, loss=loss, train_op=train_op,\n training_hooks=[logging_hook])\n\n if mode == tf.estimator.ModeKeys.PREDICT:\n return model.make_spec(mode, predictions=logits)\n\nif __name__ == '__main__':\n logging.basicConfig(\n level=logging.INFO,\n format='%(asctime)-15s [%(filename)s:%(lineno)d] %(levelname)s %(message)s'\n )\n apply_clean()\n flt.trainer_worker.train(\n ROLE, args, input_fn,\n model_fn, raw_serving_input_receiver_fn)\n", +}; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithm_projects/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/index.ts new file mode 100644 index 000000000..4b5422a15 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithm_projects/index.ts @@ -0,0 +1,14 @@ +import { normalAlgorithmProject } from 'services/mocks/v2/algorithm_projects/examples'; + +const get = { + data: { + data: [normalAlgorithmProject], + }, + status: 200, +}; + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/files/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/files/index.ts new file mode 100644 index 000000000..57c23c1fa --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/files/index.ts @@ -0,0 +1,33 @@ +import { AxiosRequestConfig } from 'axios'; +import { FileContent } from 'typings/algorithm'; +import { getFileInfoByFilePath } from 'shared/file'; + +import { leaderPythonFile, followerPythonFile } from 'services/mocks/v2/algorithms/examples'; + +const get = (config: AxiosRequestConfig) => { + const { parentPath, fileName } = getFileInfoByFilePath(config.params.path); + let finalFile: FileContent = { + path: parentPath, + filename: fileName, + content: `I am ${config.params.path}`, + }; + switch (config.params.path) { + case 'leader/main.py': + finalFile = leaderPythonFile; + break; + case 'follower/main.py': + finalFile = followerPythonFile; + break; + default: + break; + } + + return { + data: { + data: finalFile, + }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/index.ts new file mode 100644 index 000000000..3b37e166a --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/index.ts @@ -0,0 +1,43 @@ +import { AxiosRequestConfig } from 'axios'; + +const get = (config: AxiosRequestConfig) => { + return { + data: { + data: { + id: config._id, + uuid: 'udea6a8a478404f85b17', + name: 'hang-e2e-test-122323', + project_id: 31, + version: 3, + type: 'NN_VERTICAL', + source: 'USER', + algorithm_project_id: 73, + username: 'admin', + participant_id: null, + path: + 'hdfs:///home/byte_aml_tob/fedlearner_v2/algorithms/hang-e2e-test-122323-v3-20220106_100837-2ddea', + parameter: { + variables: [ + { + name: 'lr', + value: '0.1', + required: true, + display_name: '', + comment: '', + value_type: 'STRING', + }, + ], + }, + favorite: false, + comment: 'aaa', + created_at: 1641463720, + updated_at: 1649310452, + deleted_at: null, + participant_name: null, + }, + }, + status: config._id === 110 ? 500 : 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/tree/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/tree/index.ts new file mode 100644 index 000000000..2e221716f --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/tree/index.ts @@ -0,0 +1,10 @@ +import { fileTree } from 'services/mocks/v2/algorithms/examples'; + +const get = { + data: { + data: fileTree, + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithms/examples.ts b/web_console_v2/client/src/services/mocks/v2/algorithms/examples.ts new file mode 100644 index 000000000..8eb414008 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithms/examples.ts @@ -0,0 +1,59 @@ +import { FileTreeNode, FileContent } from 'typings/algorithm'; + +export const fileTree: FileTreeNode[] = [ + { + filename: 'follower', + path: 'follower', + size: 96, + mtime: 1637141275, + is_directory: true, + files: [ + { + filename: 'main.py', + path: 'follower/main.py', + mtime: 1637141275, + size: 0, + is_directory: false, + files: [], + }, + ], + }, + { + filename: 'leader', + path: 'leader', + size: 96, + mtime: 1637141275, + is_directory: true, + files: [ + { + filename: 'main.py', + path: 'leader/main.py', + size: 17, + mtime: 1637141275, + is_directory: false, + files: [], + }, + ], + }, + { + filename: 'test.py', + path: 'test.py', + mtime: 1637141275, + size: 0, + is_directory: false, + files: [], + }, +]; + +export const followerPythonFile: FileContent = { + path: 'follower', + filename: 'main.py', + content: + "# coding: utf-8\nimport logging\nimport datetime\n\nimport tensorflow.compat.v1 as tf \nimport fedlearner.trainer as flt \nimport os\n\nfrom slot_2_bucket import slot_2_bucket\n\n_SLOT_2_IDX = {pair[0]: i for i, pair in enumerate(slot_2_bucket)}\n_SLOT_2_BUCKET = slot_2_bucket\nROLE = \"leader\"\n\nparser = flt.trainer_worker.create_argument_parser()\nparser.add_argument('--batch-size', type=int, default=256,\n help='Training batch size.')\nparser.add_argument('--clean-model', type=bool, default=True,\n help='clean checkpoint and saved_model')\nargs = parser.parse_args()\nargs.sparse_estimator = True\n\ndef apply_clean():\n if args.worker_rank == 0 and args.clean_model and tf.io.gfile.exists(args.checkpoint_path):\n tf.logging.info(\"--clean_model flag set. Removing existing checkpoint_path dir:\"\n \" {}\".format(args.checkpoint_path))\n tf.io.gfile.rmtree(args.checkpoint_path)\n\n if args.worker_rank == 0 and args.clean_model and args.export_path and tf.io.gfile.exists(args.export_path):\n tf.logging.info(\"--clean_model flag set. Removing existing savedmodel dir:\"\n \" {}\".format(args.export_path))\n tf.io.gfile.rmtree(args.export_path)\n\n\ndef input_fn(bridge, trainer_master=None):\n dataset = flt.data.DataBlockLoader(\n args.batch_size, ROLE, bridge, trainer_master).make_dataset()\n \n def parse_fn(example):\n feature_map = {}\n feature_map[\"example_id\"] = tf.FixedLenFeature([], tf.string)\n feature_map['fids'] = tf.VarLenFeature(tf.int64)\n # feature_map['y'] = tf.FixedLenFeature([], tf.int64)\n features = tf.parse_example(example, features=feature_map)\n # labels = {'y': features.pop('y')}\n labels = {'y': tf.constant(0)}\n return features, labels\n dataset = dataset.map(map_func=parse_fn, num_parallel_calls=tf.data.experimental.AUTOTUNE)\n dataset = dataset.prefetch(2)\n return dataset\n \n # feature_map = {\"fids\": tf.VarLenFeature(tf.int64)}\n # feature_map['example_id'] = tf.FixedLenFeature([], tf.string)\n # record_batch = dataset.make_batch_iterator().get_next()\n # features = tf.parse_example(record_batch, features=feature_map)\n # return features, None\n\ndef raw_serving_input_receiver_fn():\n feature_map = {\n 'fids_indices': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_indices'),\n 'fids_values': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_values'),\n 'fids_dense_shape': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_dense_shape')\n }\n return tf.estimator.export.ServingInputReceiver(\n feature_map, feature_map)\n\n\ndef model_fn(model, features, labels, mode):\n\n def sum_pooling(embeddings, slots):\n slot_embeddings = []\n for slot in slots:\n slot_embeddings.append(embeddings[_SLOT_2_IDX[slot]])\n if len(slot_embeddings) == 1:\n return slot_embeddings[0]\n return tf.add_n(slot_embeddings)\n\n global_step = tf.train.get_or_create_global_step()\n num_slot, embed_size = len(_SLOT_2_BUCKET), 8\n xavier_initializer = tf.glorot_normal_initializer()\n\n flt.feature.FeatureSlot.set_default_bias_initializer(\n tf.zeros_initializer())\n flt.feature.FeatureSlot.set_default_vec_initializer(\n tf.random_uniform_initializer(-0.0078125, 0.0078125))\n flt.feature.FeatureSlot.set_default_bias_optimizer(\n tf.train.FtrlOptimizer(learning_rate=0.01))\n flt.feature.FeatureSlot.set_default_vec_optimizer(\n tf.train.AdagradOptimizer(learning_rate=0.01))\n\n # deal with input cols\n categorical_embed = []\n num_slot, embed_dim = len(_SLOT_2_BUCKET), 8\n\n with tf.variable_scope(\"leader\"):\n for slot, bucket_size in _SLOT_2_BUCKET:\n fs = model.add_feature_slot(slot, bucket_size)\n fc = model.add_feature_column(fs)\n categorical_embed.append(fc.add_vector(embed_dim))\n\n\n # concate all embeddings\n slot_embeddings = categorical_embed\n concat_embedding = tf.concat(slot_embeddings, axis=1)\n output_size = len(slot_embeddings) * embed_dim\n\n model.freeze_slots(features)\n\n with tf.variable_scope(\"follower\"):\n fc1_size, fc2_size, fc3_size = 16, 16, 16\n w1 = tf.get_variable('w1', shape=[output_size, fc1_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b1 = tf.get_variable(\n 'b1', shape=[fc1_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n w2 = tf.get_variable('w2', shape=[fc1_size, fc2_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b2 = tf.get_variable(\n 'b2', shape=[fc2_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n w3 = tf.get_variable('w3', shape=[fc2_size, fc3_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b3 = tf.get_variable(\n 'b3', shape=[fc3_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n\n act1_l = tf.nn.relu(tf.nn.bias_add(tf.matmul(concat_embedding, w1), b1))\n act1_l = tf.layers.batch_normalization(act1_l, training=True)\n act2_l = tf.nn.relu(tf.nn.bias_add(tf.matmul(act1_l, w2), b2))\n act2_l = tf.layers.batch_normalization(act2_l, training=True)\n embedding = tf.nn.relu(tf.nn.bias_add(tf.matmul(act2_l, w3), b3))\n embedding = tf.layers.batch_normalization(embedding, training=True)\n\n if mode == tf.estimator.ModeKeys.TRAIN:\n embedding_grad = model.send('embedding', embedding, require_grad=True)\n optimizer = tf.train.GradientDescentOptimizer(0.01)\n train_op = model.minimize(\n optimizer, embedding, grad_loss=embedding_grad, global_step=global_step)\n return model.make_spec(mode, loss=tf.math.reduce_mean(embedding), train_op=train_op)\n elif mode == tf.estimator.ModeKeys.PREDICT:\n return model.make_spec(mode, predictions={'embedding': embedding})\n\nif __name__ == '__main__':\n logging.basicConfig(\n level=logging.INFO,\n format='%(asctime)-15s [%(filename)s:%(lineno)d] %(levelname)s %(message)s'\n )\n apply_clean()\n flt.trainer_worker.train(\n ROLE, args, input_fn,\n model_fn, raw_serving_input_receiver_fn)\n", +}; +export const leaderPythonFile: FileContent = { + path: 'leader', + filename: 'main.py', + content: + "# coding: utf-8\n# encoding=utf8\nimport logging\n\nimport tensorflow.compat.v1 as tf\n\nimport fedlearner.trainer as flt\nimport os\n\nROLE = 'follower'\n\nparser = flt.trainer_worker.create_argument_parser()\nparser.add_argument('--batch-size', type=int, default=256,\n help='Training batch size.')\nparser.add_argument('--clean-model', type=bool, default=True,\n help='clean checkpoint and saved_model')\nargs = parser.parse_args()\n\ndef apply_clean():\n if args.worker_rank == 0 and args.clean_model and tf.io.gfile.exists(args.checkpoint_path):\n tf.logging.info(\"--clean_model flag set. Removing existing checkpoint_path dir:\"\n \" {}\".format(args.checkpoint_path))\n tf.io.gfile.rmtree(args.checkpoint_path)\n\n if args.worker_rank == 0 and args.clean_model and args.export_path and tf.io.gfile.exists(args.export_path):\n tf.logging.info(\"--clean_model flag set. Removing existing savedmodel dir:\"\n \" {}\".format(args.export_path))\n tf.io.gfile.rmtree(args.export_path)\n\ndef input_fn(bridge, trainer_master=None):\n dataset = flt.data.DataBlockLoader(\n args.batch_size, ROLE, bridge, trainer_master).make_dataset()\n \n def parse_fn(example):\n feature_map = {}\n feature_map['example_id'] = tf.FixedLenFeature([], tf.string)\n # feature_map['y'] = tf.FixedLenFeature([], tf.int64)\n features = tf.parse_example(example, features=feature_map)\n labels = {'y': tf.constant(0, shape=[1])}\n return features, labels\n \n dataset = dataset.map(map_func=parse_fn,\n num_parallel_calls=tf.data.experimental.AUTOTUNE)\n dataset = dataset.prefetch(2)\n return dataset\n \n\ndef raw_serving_input_receiver_fn():\n features = {}\n features['embedding'] = tf.placeholder(dtype=tf.float32, shape=[1, 16], name='embedding')\n receiver_tensors = {\n 'embedding': features['embedding']\n }\n return tf.estimator.export.ServingInputReceiver(\n features, receiver_tensors)\n\ndef model_fn(model, features, labels, mode):\n global_step = tf.train.get_or_create_global_step()\n xavier_initializer = tf.glorot_normal_initializer()\n\n fc1_size = 16\n with tf.variable_scope('follower'):\n w1f = tf.get_variable('w1f', shape=[\n fc1_size, 1], dtype=tf.float32, initializer=tf.random_uniform_initializer(-0.01, 0.01))\n b1f = tf.get_variable(\n 'b1f', shape=[1], dtype=tf.float32, initializer=tf.zeros_initializer())\n \n if mode == tf.estimator.ModeKeys.TRAIN:\n embedding = model.recv('embedding', tf.float32, require_grad=True)\n else:\n embedding = features['embedding']\n \n logits = tf.nn.bias_add(tf.matmul(embedding, w1f), b1f)\n\n if mode == tf.estimator.ModeKeys.TRAIN:\n y = tf.dtypes.cast(labels['y'], tf.float32)\n loss = tf.nn.sigmoid_cross_entropy_with_logits(\n labels=y, logits=logits)\n loss = tf.math.reduce_mean(loss)\n\n # cala auc\n pred = tf.math.sigmoid(logits)\n print('==============================================================')\n print(tf.shape(y))\n print(tf.shape(pred))\n _, auc = tf.metrics.auc(labels=y, predictions=pred)\n\n logging_hook = tf.train.LoggingTensorHook(\n {\"loss\": loss, \"auc\": auc}, every_n_iter=10)\n\n optimizer = tf.train.GradientDescentOptimizer(0.01)\n train_op = model.minimize(optimizer, loss, global_step=global_step)\n return model.make_spec(mode, loss=loss, train_op=train_op,\n training_hooks=[logging_hook])\n\n if mode == tf.estimator.ModeKeys.PREDICT:\n return model.make_spec(mode, predictions=logits)\n\nif __name__ == '__main__':\n logging.basicConfig(\n level=logging.INFO,\n format='%(asctime)-15s [%(filename)s:%(lineno)d] %(levelname)s %(message)s'\n )\n apply_clean()\n flt.trainer_worker.train(\n ROLE, args, input_fn,\n model_fn, raw_serving_input_receiver_fn)\n", +}; diff --git a/web_console_v2/client/src/services/mocks/v2/algorithms/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithms/index.ts new file mode 100644 index 000000000..988f41c57 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/algorithms/index.ts @@ -0,0 +1,39 @@ +import { FakeAlgorithm } from 'typings/modelCenter'; + +const list: FakeAlgorithm[] = new Array(4).fill(undefined).map((_, index) => { + return { + id: index + 1, + name: 'mock假算法名称' + (index + 1), + value: JSON.stringify({ + 'owner.py': '# coding: utf-8\n', + [`id_${index + 1}.py'`]: '# coding: utf-8\n', + 'leader/main.py': + "# coding: utf-8\nimport logging\nimport datetime\n\nimport tensorflow.compat.v1 as tf \nimport fedlearner.trainer as flt \nimport os\n\nfrom slot_2_bucket import slot_2_bucket\n\n_SLOT_2_IDX = {pair[0]: i for i, pair in enumerate(slot_2_bucket)}\n_SLOT_2_BUCKET = slot_2_bucket\nROLE = \"leader\"\n\nparser = flt.trainer_worker.create_argument_parser()\nparser.add_argument('--batch-size', type=int, default=256,\n help='Training batch size.')\nparser.add_argument('--clean-model', type=bool, default=True,\n help='clean checkpoint and saved_model')\nargs = parser.parse_args()\nargs.sparse_estimator = True\n\ndef apply_clean():\n if args.worker_rank == 0 and args.clean_model and tf.io.gfile.exists(args.checkpoint_path):\n tf.logging.info(\"--clean_model flag set. Removing existing checkpoint_path dir:\"\n \" {}\".format(args.checkpoint_path))\n tf.io.gfile.rmtree(args.checkpoint_path)\n\n if args.worker_rank == 0 and args.clean_model and args.export_path and tf.io.gfile.exists(args.export_path):\n tf.logging.info(\"--clean_model flag set. Removing existing savedmodel dir:\"\n \" {}\".format(args.export_path))\n tf.io.gfile.rmtree(args.export_path)\n\n\ndef input_fn(bridge, trainer_master=None):\n dataset = flt.data.DataBlockLoader(\n args.batch_size, ROLE, bridge, trainer_master).make_dataset()\n \n def parse_fn(example):\n feature_map = {}\n feature_map[\"example_id\"] = tf.FixedLenFeature([], tf.string)\n feature_map['fids'] = tf.VarLenFeature(tf.int64)\n # feature_map['y'] = tf.FixedLenFeature([], tf.int64)\n features = tf.parse_example(example, features=feature_map)\n # labels = {'y': features.pop('y')}\n labels = {'y': tf.constant(0)}\n return features, labels\n dataset = dataset.map(map_func=parse_fn, num_parallel_calls=tf.data.experimental.AUTOTUNE)\n dataset = dataset.prefetch(2)\n return dataset\n \n # feature_map = {\"fids\": tf.VarLenFeature(tf.int64)}\n # feature_map['example_id'] = tf.FixedLenFeature([], tf.string)\n # record_batch = dataset.make_batch_iterator().get_next()\n # features = tf.parse_example(record_batch, features=feature_map)\n # return features, None\n\ndef raw_serving_input_receiver_fn():\n feature_map = {\n 'fids_indices': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_indices'),\n 'fids_values': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_values'),\n 'fids_dense_shape': tf.placeholder(dtype=tf.int64, shape=[None], name='fids_dense_shape')\n }\n return tf.estimator.export.ServingInputReceiver(\n feature_map, feature_map)\n\n\ndef model_fn(model, features, labels, mode):\n\n def sum_pooling(embeddings, slots):\n slot_embeddings = []\n for slot in slots:\n slot_embeddings.append(embeddings[_SLOT_2_IDX[slot]])\n if len(slot_embeddings) == 1:\n return slot_embeddings[0]\n return tf.add_n(slot_embeddings)\n\n global_step = tf.train.get_or_create_global_step()\n num_slot, embed_size = len(_SLOT_2_BUCKET), 8\n xavier_initializer = tf.glorot_normal_initializer()\n\n flt.feature.FeatureSlot.set_default_bias_initializer(\n tf.zeros_initializer())\n flt.feature.FeatureSlot.set_default_vec_initializer(\n tf.random_uniform_initializer(-0.0078125, 0.0078125))\n flt.feature.FeatureSlot.set_default_bias_optimizer(\n tf.train.FtrlOptimizer(learning_rate=0.01))\n flt.feature.FeatureSlot.set_default_vec_optimizer(\n tf.train.AdagradOptimizer(learning_rate=0.01))\n\n # deal with input cols\n categorical_embed = []\n num_slot, embed_dim = len(_SLOT_2_BUCKET), 8\n\n with tf.variable_scope(\"leader\"):\n for slot, bucket_size in _SLOT_2_BUCKET:\n fs = model.add_feature_slot(slot, bucket_size)\n fc = model.add_feature_column(fs)\n categorical_embed.append(fc.add_vector(embed_dim))\n\n\n # concate all embeddings\n slot_embeddings = categorical_embed\n concat_embedding = tf.concat(slot_embeddings, axis=1)\n output_size = len(slot_embeddings) * embed_dim\n\n model.freeze_slots(features)\n\n with tf.variable_scope(\"follower\"):\n fc1_size, fc2_size, fc3_size = 16, 16, 16\n w1 = tf.get_variable('w1', shape=[output_size, fc1_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b1 = tf.get_variable(\n 'b1', shape=[fc1_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n w2 = tf.get_variable('w2', shape=[fc1_size, fc2_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b2 = tf.get_variable(\n 'b2', shape=[fc2_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n w3 = tf.get_variable('w3', shape=[fc2_size, fc3_size], dtype=tf.float32,\n initializer=xavier_initializer)\n b3 = tf.get_variable(\n 'b3', shape=[fc3_size], dtype=tf.float32, initializer=tf.zeros_initializer())\n\n act1_l = tf.nn.relu(tf.nn.bias_add(tf.matmul(concat_embedding, w1), b1))\n act1_l = tf.layers.batch_normalization(act1_l, training=True)\n act2_l = tf.nn.relu(tf.nn.bias_add(tf.matmul(act1_l, w2), b2))\n act2_l = tf.layers.batch_normalization(act2_l, training=True)\n embedding = tf.nn.relu(tf.nn.bias_add(tf.matmul(act2_l, w3), b3))\n embedding = tf.layers.batch_normalization(embedding, training=True)\n\n if mode == tf.estimator.ModeKeys.TRAIN:\n embedding_grad = model.send('embedding', embedding, require_grad=True)\n optimizer = tf.train.GradientDescentOptimizer(0.01)\n train_op = model.minimize(\n optimizer, embedding, grad_loss=embedding_grad, global_step=global_step)\n return model.make_spec(mode, loss=tf.math.reduce_mean(embedding), train_op=train_op)\n elif mode == tf.estimator.ModeKeys.PREDICT:\n return model.make_spec(mode, predictions={'embedding': embedding})\n\nif __name__ == '__main__':\n logging.basicConfig(\n level=logging.INFO,\n format='%(asctime)-15s [%(filename)s:%(lineno)d] %(levelname)s %(message)s'\n )\n apply_clean()\n flt.trainer_worker.train(\n ROLE, args, input_fn,\n model_fn, raw_serving_input_receiver_fn)\n", + 'leader/slot_2_bucket.py': + '# coding: utf-8\nslot_2_bucket = [(0, 2),(1, 2),(2, 2),(3, 2),(4, 2),(5, 2),(6, 2),(7, 2),(8, 2),(9, 2),(10, 2),(11, 2),(12, 2),(13, 1341),(14, 535),(15, 74138),(16, 70862),(17, 279),(18, 17),(19, 11019),(20, 591),(21, 4),(22, 30227),(23, 4791),(24, 75100),(25, 3075),(26, 27),(27, 9226),(28, 79191),(29, 11),(30, 3990),(31, 1898),(32, 5),\n(33, 76976),(34, 18),(35, 16),(36, 36534),(37, 74),(38, 29059)]\n', + 'follower/main.py': + "# coding: utf-8\n# encoding=utf8\nimport logging\n\nimport tensorflow.compat.v1 as tf\n\nimport fedlearner.trainer as flt\nimport os\n\nROLE = 'follower'\n\nparser = flt.trainer_worker.create_argument_parser()\nparser.add_argument('--batch-size', type=int, default=256,\n help='Training batch size.')\nparser.add_argument('--clean-model', type=bool, default=True,\n help='clean checkpoint and saved_model')\nargs = parser.parse_args()\n\ndef apply_clean():\n if args.worker_rank == 0 and args.clean_model and tf.io.gfile.exists(args.checkpoint_path):\n tf.logging.info(\"--clean_model flag set. Removing existing checkpoint_path dir:\"\n \" {}\".format(args.checkpoint_path))\n tf.io.gfile.rmtree(args.checkpoint_path)\n\n if args.worker_rank == 0 and args.clean_model and args.export_path and tf.io.gfile.exists(args.export_path):\n tf.logging.info(\"--clean_model flag set. Removing existing savedmodel dir:\"\n \" {}\".format(args.export_path))\n tf.io.gfile.rmtree(args.export_path)\n\ndef input_fn(bridge, trainer_master=None):\n dataset = flt.data.DataBlockLoader(\n args.batch_size, ROLE, bridge, trainer_master).make_dataset()\n \n def parse_fn(example):\n feature_map = {}\n feature_map['example_id'] = tf.FixedLenFeature([], tf.string)\n # feature_map['y'] = tf.FixedLenFeature([], tf.int64)\n features = tf.parse_example(example, features=feature_map)\n labels = {'y': tf.constant(0, shape=[1])}\n return features, labels\n \n dataset = dataset.map(map_func=parse_fn,\n num_parallel_calls=tf.data.experimental.AUTOTUNE)\n dataset = dataset.prefetch(2)\n return dataset\n \n\ndef raw_serving_input_receiver_fn():\n features = {}\n features['embedding'] = tf.placeholder(dtype=tf.float32, shape=[1, 16], name='embedding')\n receiver_tensors = {\n 'embedding': features['embedding']\n }\n return tf.estimator.export.ServingInputReceiver(\n features, receiver_tensors)\n\ndef model_fn(model, features, labels, mode):\n global_step = tf.train.get_or_create_global_step()\n xavier_initializer = tf.glorot_normal_initializer()\n\n fc1_size = 16\n with tf.variable_scope('follower'):\n w1f = tf.get_variable('w1f', shape=[\n fc1_size, 1], dtype=tf.float32, initializer=tf.random_uniform_initializer(-0.01, 0.01))\n b1f = tf.get_variable(\n 'b1f', shape=[1], dtype=tf.float32, initializer=tf.zeros_initializer())\n \n if mode == tf.estimator.ModeKeys.TRAIN:\n embedding = model.recv('embedding', tf.float32, require_grad=True)\n else:\n embedding = features['embedding']\n \n logits = tf.nn.bias_add(tf.matmul(embedding, w1f), b1f)\n\n if mode == tf.estimator.ModeKeys.TRAIN:\n y = tf.dtypes.cast(labels['y'], tf.float32)\n loss = tf.nn.sigmoid_cross_entropy_with_logits(\n labels=y, logits=logits)\n loss = tf.math.reduce_mean(loss)\n\n # cala auc\n pred = tf.math.sigmoid(logits)\n print('==============================================================')\n print(tf.shape(y))\n print(tf.shape(pred))\n _, auc = tf.metrics.auc(labels=y, predictions=pred)\n\n logging_hook = tf.train.LoggingTensorHook(\n {\"loss\": loss, \"auc\": auc}, every_n_iter=10)\n\n optimizer = tf.train.GradientDescentOptimizer(0.01)\n train_op = model.minimize(optimizer, loss, global_step=global_step)\n return model.make_spec(mode, loss=loss, train_op=train_op,\n training_hooks=[logging_hook])\n\n if mode == tf.estimator.ModeKeys.PREDICT:\n return model.make_spec(mode, predictions=logits)\n\nif __name__ == '__main__':\n logging.basicConfig(\n level=logging.INFO,\n format='%(asctime)-15s [%(filename)s:%(lineno)d] %(levelname)s %(message)s'\n )\n apply_clean()\n flt.trainer_worker.train(\n ROLE, args, input_fn,\n model_fn, raw_serving_input_receiver_fn)\n", + 'follower/slot_2_bucket.py': + '# coding: utf-8\nslot_2_bucket = [(0, 2),(1, 2),(2, 2),(3, 2),(4, 2),(5, 2),(6, 2),(7, 2),(8, 2),(9, 2),(10, 2),(11, 2),(12, 2),(13, 1341),(14, 535),(15, 74138),(16, 70862),(17, 279),(18, 17),(19, 11019),(20, 591),(21, 4),(22, 30227),(23, 4791),(24, 75100),(25, 3075),(26, 27),(27, 9226),(28, 79191),(29, 11),(30, 3990),(31, 1898),(32, 5),\n(33, 76976),(34, 18),(35, 16),(36, 36534),(37, 74),(38, 29059)]\n', + }), + comment: '备注' + (index + 1), + type: 'NN_MODEL', + + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }; +}); + +const get = { + data: { + data: list, + }, + status: 200, +}; + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/compare_model/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/compare_model/__id__/index.ts new file mode 100644 index 000000000..dd82f2eba --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/compare_model/__id__/index.ts @@ -0,0 +1,124 @@ +import { AxiosRequestConfig } from 'axios'; + +const get = (config: AxiosRequestConfig) => ({ + data: { + data: { + id: 12342323, + name: 'mock评估任务', + state: Math.floor(Math.random() * 3), + dataset: 'test-dataset', + comment: '我是说明文案我是说明文案我是说明文案我是说明文案', + modelList: ['Xgbootst-v8', 'Xgbootst-v7', 'Xgbootst-v71'], + // modelList: ['Xgbootst-v8'], + extra: JSON.stringify({ + comment: + '我是说明文案我是说明文案我是说明文案我是说明文案我是说明文案我是说明文案我是说明文案我是说明文案', + creator: '测试员', + }), + algorithm: '树模型', + metrics: [ + { + auc_roc: 0.95, + accuracy: 0.52, + precision: 0.28, + recall: 0.48, + f1_score: 0.95, + log_loss: 0.35, + }, + { + auc_roc: 0.55, + accuracy: 0.35, + precision: 0.75, + recall: 0.65, + f1_score: 0.45, + log_loss: 0.105, + }, + { + auc_roc: 0.15, + accuracy: 0.25, + precision: 0.35, + recall: 0.45, + f1_score: 0.55, + log_loss: 0.35, + }, + ], + confusionMatrix: [ + [0.95, 0.05, 0.24, 0.76], + [0.45, 0.25, 0.34, 0.86], + [0.45, 0.25, 0.34, 0.86], + ], + featureImportance: [ + [ + { + label: 'Duration', + value: 77, + }, + { + label: 'Mooth', + value: 40, + }, + { + label: 'Day', + value: 37, + }, + { + label: 'Contact', + value: 30, + }, + + { + label: 'POutcome', + value: 23, + }, + { + label: 'PDay', + value: 17, + }, + { + label: 'Education', + value: 11, + }, + ], + [ + { + label: 'Duration', + value: 67, + }, + { + label: 'Mooth', + value: 20, + }, + { + label: 'Day', + value: 47, + }, + { + label: 'Contact', + value: 10, + }, + + { + label: 'POutcome', + value: 63, + }, + { + label: 'PDay', + value: 47, + }, + { + label: 'Education', + value: 31, + }, + ], + ], + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }, + }, + status: 200, +}); + +export const patch = { data: {}, status: 200 }; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/compare_model/index.ts b/web_console_v2/client/src/services/mocks/v2/compare_model/index.ts new file mode 100644 index 000000000..7e24c456b --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/compare_model/index.ts @@ -0,0 +1,29 @@ +const list = new Array(4).fill(undefined).map((_, index) => { + return { + id: index + 1, + name: 'mock对比报告名称' + (index + 1), + comment: '我是说明文案', + compare_number: 5, + extra: JSON.stringify({ + comment: '我是说明', + creator: '测试员', + }), + + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }; +}); + +const get = { + data: { + data: list, + }, + status: 200, +}; + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/dashboards/index.ts b/web_console_v2/client/src/services/mocks/v2/dashboards/index.ts new file mode 100644 index 000000000..b3dc04d33 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/dashboards/index.ts @@ -0,0 +1,31 @@ +import { Dashboard } from 'typings/operation'; + +const list: Dashboard[] = [ + { + name: 'dashboard1', + url: + 'xxx', + uuid: 'xvlksdhjlfsdlf', + }, + { + name: 'dashboard2', + url: 'https://reactjs.org/', + uuid: 'xvlksdhjlfsfsdfdlf', + }, +]; + +const get = { + data: { + data: list, + }, + status: 200, +}; + +export const post = { + data: { + data: undefined, + }, + status: 201, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/data_sources/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/data_sources/__id__/index.ts new file mode 100644 index 000000000..ad0f09c7a --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/data_sources/__id__/index.ts @@ -0,0 +1,23 @@ +import { AxiosRequestConfig } from 'axios'; + +const get = () => ({ + data: { + data: { + id: 1, + type: 'hdfs', + name: 'mock数据源1', + created_at: 1608582145, + url: 'hdfs://hadoop-master:9000/user/hadoop/test.csv', + }, + }, + status: 200, +}); + +export const DELETE = (config: AxiosRequestConfig) => { + return { + data: '', + status: 204, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/data_sources/index.ts b/web_console_v2/client/src/services/mocks/v2/data_sources/index.ts new file mode 100644 index 000000000..153e52569 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/data_sources/index.ts @@ -0,0 +1,57 @@ +import { DataSource } from 'typings/dataset'; + +const list: DataSource[] = [ + { + id: 1, + uuid: 'uxbuxa', + type: 'hdfs', + name: 'mock数据源1', + created_at: 1608582145, + url: 'hdfs://hadoop-master:9000/user/hadoop/test.csv', + project_id: 1, + dataset_format: 'TABULAR', + dataset_type: 'STREAMING', + store_format: 'TFRECORDS', + }, + { + id: 2, + uuid: 'uxbusxa', + type: 'hdfs', + name: 'mock数据源2', + created_at: 1609582145, + url: + 'hdfs://home/byte_aml_tob/fedlearner_v2/dataset/20220218_141000_e2e-test-dataset-20220218-060927', + project_id: 1, + dataset_format: 'TABULAR', + dataset_type: 'STREAMING', + store_format: 'TFRECORDS', + }, + { + id: 3, + uuid: 'uxbusxa', + type: 'http', + name: 'mock数据源3', + created_at: 1610582145, + url: 'http://www.baidu.com', + project_id: 1, + dataset_format: 'TABULAR', + dataset_type: 'STREAMING', + store_format: 'TFRECORDS', + }, +]; + +const get = { + data: { + data: list, + }, + status: 200, +}; + +export const post = { + data: { + data: undefined, + }, + status: 201, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/data_alignment/index.ts b/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/data_alignment/index.ts new file mode 100644 index 000000000..179f11a09 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/data_alignment/index.ts @@ -0,0 +1,35 @@ +import { DataJobVariable } from 'typings/dataset'; +import { + stringInput, + numberInput, + objectInput, + listInput, + asyncSwitch, + codeEditor, + hideStringInput, +} from '../../variables/examples'; + +const get = { + data: { + data: { + is_federated: false, + variables: [ + hideStringInput, + stringInput, + numberInput, + asyncSwitch, + objectInput, + listInput, + codeEditor, + ].map((item) => { + return { + ...item, + widget_schema: JSON.stringify(item.widget_schema), + }; + }) as DataJobVariable[], + }, + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/data_join/index.ts b/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/data_join/index.ts new file mode 100644 index 000000000..179f11a09 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/data_join/index.ts @@ -0,0 +1,35 @@ +import { DataJobVariable } from 'typings/dataset'; +import { + stringInput, + numberInput, + objectInput, + listInput, + asyncSwitch, + codeEditor, + hideStringInput, +} from '../../variables/examples'; + +const get = { + data: { + data: { + is_federated: false, + variables: [ + hideStringInput, + stringInput, + numberInput, + asyncSwitch, + objectInput, + listInput, + codeEditor, + ].map((item) => { + return { + ...item, + widget_schema: JSON.stringify(item.widget_schema), + }; + }) as DataJobVariable[], + }, + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/rsa_psi_data_join/index.ts b/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/rsa_psi_data_join/index.ts new file mode 100644 index 000000000..179f11a09 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/dataset_job_definitions/rsa_psi_data_join/index.ts @@ -0,0 +1,35 @@ +import { DataJobVariable } from 'typings/dataset'; +import { + stringInput, + numberInput, + objectInput, + listInput, + asyncSwitch, + codeEditor, + hideStringInput, +} from '../../variables/examples'; + +const get = { + data: { + data: { + is_federated: false, + variables: [ + hideStringInput, + stringInput, + numberInput, + asyncSwitch, + objectInput, + listInput, + codeEditor, + ].map((item) => { + return { + ...item, + widget_schema: JSON.stringify(item.widget_schema), + }; + }) as DataJobVariable[], + }, + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/datasets/__id__/batches.ts b/web_console_v2/client/src/services/mocks/v2/datasets/__id__/batches.ts new file mode 100644 index 000000000..7c76a325a --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/datasets/__id__/batches.ts @@ -0,0 +1,6 @@ +export const post = () => ({ + data: { + data: { success: true }, + }, + status: 200, +}); diff --git a/web_console_v2/client/src/services/mocks/v2/datasets/__id__/feature.ts b/web_console_v2/client/src/services/mocks/v2/datasets/__id__/feature.ts new file mode 100644 index 000000000..1e132de51 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/datasets/__id__/feature.ts @@ -0,0 +1,37 @@ +const get = { + data: { + data: [ + { + key: '缺失率', + value: '23%', + }, + { + key: '均值', + value: '234243', + }, + { + key: '最大值', + value: '20', + }, + { + key: '最小值', + value: '234243', + }, + { + key: '中位数', + value: '234243', + }, + { + key: '峰度', + value: '3', + }, + { + key: '偏度', + value: '3', + }, + ], + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/datasets/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/datasets/__id__/index.ts new file mode 100644 index 000000000..037cf7a5a --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/datasets/__id__/index.ts @@ -0,0 +1,33 @@ +import { AxiosRequestConfig } from 'axios'; + +import { unfinishedImporting } from '../examples'; + +const get = () => ({ + data: { + data: unfinishedImporting, + }, + status: 200, +}); + +export const DELETE = (config: AxiosRequestConfig) => { + const datasetId = config._id as string; + + return Math.random() > 0.5 + ? { + // Delete success + data: '', + status: 204, + } + : { + // Delete fail + data: { + code: 409, + message: { + [datasetId]: [`The dataset ${datasetId} is being processed`], + }, + }, + status: 409, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/datasets/__id__/preview.ts b/web_console_v2/client/src/services/mocks/v2/datasets/__id__/preview.ts new file mode 100644 index 000000000..7494d1eb2 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/datasets/__id__/preview.ts @@ -0,0 +1,324 @@ +const get = { + data: { + data: { + dtypes: [ + { + key: '_1', + value: 'double', + }, + { + key: '_2', + value: 'double', + }, + { + key: '_3', + value: 'double', + }, + { + key: '_4', + value: 'double', + }, + { + key: '_5', + value: 'double', + }, + { + key: '_6', + value: 'double', + }, + { + key: '_7', + value: 'double', + }, + { + key: '_8', + value: 'double', + }, + { + key: '_9', + value: 'double', + }, + { + key: '_10', + value: 'double', + }, + { + key: 'raw_id', + value: 'string', + }, + ], + count: 5000, + sample: [ + [ + 0, + 0.9500884413719177, + 0, + 0, + -0.10321885347366333, + -0.9772778749465942, + -0.15135720372200012, + 0.4105985164642334, + ], + [ + 1, + 1.4940791130065918, + 1, + 1, + 0.3130677044391632, + 0.3336743414402008, + -0.2051582634449005, + -0.8540957570075989, + ], + [ + 2, + 0.04575851559638977, + 2, + 2, + 1.5327792167663574, + -1.4543657302856445, + -0.18718385696411133, + 1.4693588018417358, + ], + [ + 3, + 1.2302906513214111, + 3, + 3, + -0.38732680678367615, + 0.15634897351264954, + 1.202379822731018, + -0.302302747964859, + ], + [ + 4, + -1.2527953386306763, + 4, + 4, + -1.6138978004455566, + -0.4380742907524109, + 0.7774903774261475, + -0.21274028718471527, + ], + [ + 5, + 0.06651721894741058, + 5, + 5, + -0.6343221068382263, + 0.4283318817615509, + 0.30247190594673157, + -0.3627411723136902, + ], + [ + 6, + -1.630198359489441, + 6, + 6, + -0.9072983860969543, + -0.4017809331417084, + 0.46278226375579834, + 0.05194539576768875, + ], + [ + 7, + -0.8707971572875977, + 7, + 7, + -0.3115525245666504, + -0.6848101019859314, + -0.5788496732711792, + 0.056165341287851334, + ], + [ + 8, + 1.1787796020507812, + 8, + 8, + -1.0707526206970215, + 1.895889163017273, + -0.1799248307943344, + 1.0544517040252686, + ], + [ + 9, + 0.01050002034753561, + 9, + 9, + 0.12691208720207214, + 0.7065731883049011, + 1.7858705520629883, + 0.4019893705844879, + ], + [ + 10, + -0.4136189818382263, + 10, + 10, + 1.922942042350769, + 1.9436211585998535, + -0.747454822063446, + 1.4805147647857666, + ], + [ + 11, + 0.9472519755363464, + 11, + 11, + 0.6140793561935425, + 0.8024563789367676, + -0.15501008927822113, + 0.922206699848175, + ], + [ + 12, + -0.4351535439491272, + 12, + 12, + 0.6722947359085083, + -0.14963454008102417, + 1.8492637872695923, + 0.40746182203292847, + ], + [ + 13, + 0.5765908360481262, + 13, + 13, + 0.39600670337677, + 0.676433265209198, + -0.20829875767230988, + -1.0930615663528442, + ], + [ + 14, + -0.9128222465515137, + 14, + 14, + -1.31590735912323, + 0.9444794654846191, + 1.117016315460205, + -0.46158459782600403, + ], + [ + 15, + 1.1266359090805054, + 15, + 15, + -1.1474686861038208, + -0.6634783148765564, + -1.0799314975738525, + -0.43782004714012146, + ], + [ + 16, + -1.0002152919769287, + 16, + 16, + 1.1880297660827637, + 0.8443629741668701, + -1.5447710752487183, + 0.31694260239601135, + ], + [ + 17, + -0.8034096360206604, + 17, + 17, + -0.4555324912071228, + 0.6815944910049438, + -0.6895498037338257, + 0.01747915893793106, + ], + [ + 18, + -1.1043833494186401, + 18, + 18, + -0.73956298828125, + -1.602057695388794, + 0.05216507986187935, + 1.543014645576477, + ], + [ + 19, + 0.7717905640602112, + 19, + 19, + 2.163235902786255, + -0.1715463250875473, + 0.8235041499137878, + 1.336527943611145, + ], + ], + metrics: { + raw_id: { + count: '5000', + mean: '2499.5', + stddev: '1443.5200033252052', + min: '0', + max: '4999', + missing_count: '0', + }, + z_2: { + count: '5000', + mean: '0.003840906049218029', + stddev: '0.999312078264177', + min: '-3.6270735', + max: '3.4571788', + missing_count: '0', + }, + example_id: { + count: '5000', + mean: '2499.5', + stddev: '1443.5200033252052', + min: '0', + max: '4999', + missing_count: '0', + }, + event_time: { + count: '5000', + mean: '2499.5', + stddev: '1443.5200033252052', + min: '0', + max: '4999', + missing_count: '0', + }, + z_4: { + count: '5000', + mean: '-0.0073523533177183705', + stddev: '0.9991068745409053', + min: '-4.4466324', + max: '3.8317902', + missing_count: '0', + }, + z_1: { + count: '5000', + mean: '0.00408399432664155', + stddev: '0.9994457324528213', + min: '-3.6942854', + max: '3.6361017', + missing_count: '0', + }, + z_3: { + count: '5000', + mean: '-0.012627735345379915', + stddev: '0.9899747337202139', + min: '-3.1699786', + max: '3.4915504', + missing_count: '0', + }, + z_5: { + count: '5000', + mean: '0.011706419334438396', + stddev: '0.9869785943536253', + min: '-3.5810459', + max: '3.6056588', + missing_count: '0', + }, + }, + }, + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/evaluation/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/evaluation/__id__/index.ts new file mode 100644 index 000000000..dd82f2eba --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/evaluation/__id__/index.ts @@ -0,0 +1,124 @@ +import { AxiosRequestConfig } from 'axios'; + +const get = (config: AxiosRequestConfig) => ({ + data: { + data: { + id: 12342323, + name: 'mock评估任务', + state: Math.floor(Math.random() * 3), + dataset: 'test-dataset', + comment: '我是说明文案我是说明文案我是说明文案我是说明文案', + modelList: ['Xgbootst-v8', 'Xgbootst-v7', 'Xgbootst-v71'], + // modelList: ['Xgbootst-v8'], + extra: JSON.stringify({ + comment: + '我是说明文案我是说明文案我是说明文案我是说明文案我是说明文案我是说明文案我是说明文案我是说明文案', + creator: '测试员', + }), + algorithm: '树模型', + metrics: [ + { + auc_roc: 0.95, + accuracy: 0.52, + precision: 0.28, + recall: 0.48, + f1_score: 0.95, + log_loss: 0.35, + }, + { + auc_roc: 0.55, + accuracy: 0.35, + precision: 0.75, + recall: 0.65, + f1_score: 0.45, + log_loss: 0.105, + }, + { + auc_roc: 0.15, + accuracy: 0.25, + precision: 0.35, + recall: 0.45, + f1_score: 0.55, + log_loss: 0.35, + }, + ], + confusionMatrix: [ + [0.95, 0.05, 0.24, 0.76], + [0.45, 0.25, 0.34, 0.86], + [0.45, 0.25, 0.34, 0.86], + ], + featureImportance: [ + [ + { + label: 'Duration', + value: 77, + }, + { + label: 'Mooth', + value: 40, + }, + { + label: 'Day', + value: 37, + }, + { + label: 'Contact', + value: 30, + }, + + { + label: 'POutcome', + value: 23, + }, + { + label: 'PDay', + value: 17, + }, + { + label: 'Education', + value: 11, + }, + ], + [ + { + label: 'Duration', + value: 67, + }, + { + label: 'Mooth', + value: 20, + }, + { + label: 'Day', + value: 47, + }, + { + label: 'Contact', + value: 10, + }, + + { + label: 'POutcome', + value: 63, + }, + { + label: 'PDay', + value: 47, + }, + { + label: 'Education', + value: 31, + }, + ], + ], + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }, + }, + status: 200, +}); + +export const patch = { data: {}, status: 200 }; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/evaluation/index.ts b/web_console_v2/client/src/services/mocks/v2/evaluation/index.ts new file mode 100644 index 000000000..767bef977 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/evaluation/index.ts @@ -0,0 +1,32 @@ +const list = new Array(4).fill(undefined).map((_, index) => { + return { + id: index + 1, + name: 'mock评估任务名称' + (index + 1), + state: Math.floor(Math.random() * 3), + dataset: 'test-dataset', + dataset_id: 109, + comment: '我是说明文案', + modelList: ['Xgbootst-v8', 'Xgbootst-v7', 'Xgbootst-v6'], + extra: JSON.stringify({ + comment: '我是说明', + creator: '测试员', + }), + + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }; +}); + +const get = { + data: { + data: list, + }, + status: 200, +}; + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/events/index.ts b/web_console_v2/client/src/services/mocks/v2/events/index.ts new file mode 100644 index 000000000..b8a9bab79 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/events/index.ts @@ -0,0 +1,150 @@ +const get = { + data: { + data: [ + { + resource_type: 'user', + op_type: 'delete', + result: 'succeeded', + source: 'ui', + name: 'deleteUser', + id: 12, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: 'ba4ec7c9-6f18-476e-a464-c03b25d754cb', + resource_name: '74', + extra: null, + created_at: 1631262553, + }, + { + resource_type: 'user', + op_type: 'create', + result: 'succeeded', + source: 'ui', + name: 'createUser', + id: 11, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: '4ff3151d-ea60-4835-bb7d-72f99b4d3eec', + resource_name: '3543543', + extra: + '{"username": "3543543", "xxx": "xx==", "name": "sidofij", "email": "iajdofijo@ijoaif.com", "role": "USER"}', + created_at: 1631262541, + }, + { + resource_type: 'user', + op_type: 'delete', + result: 'succeeded', + source: 'ui', + name: 'deleteUser', + id: 10, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: 'e675ed0b-d27a-4da2-af55-9420afc8a4fc', + resource_name: 'signin', + extra: null, + created_at: 1631262447, + }, + { + resource_type: 'user', + op_type: 'delete', + result: 'succeeded', + source: 'ui', + name: 'deleteUser', + id: 9, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: '6fc153a5-8c0e-49bd-879e-ff3e3d721a94', + resource_name: 'signin', + extra: null, + created_at: 1631262344, + }, + { + resource_type: 'user', + op_type: 'update', + result: 'succeeded', + source: 'ui', + name: 'updateUser', + id: 8, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: 'f28174ed-e357-4cc4-8598-3f6a65b9bbf9', + resource_name: '73', + extra: '{"password": "YWFhYWExMzIxQA=="}', + created_at: 1631260894, + }, + { + resource_type: 'user', + op_type: 'delete', + result: 'succeeded', + source: 'ui', + name: 'deleteUser', + id: 7, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: 'c114dc20-97aa-418c-916a-99e73c841431', + resource_name: 'signin', + extra: null, + created_at: 1631260550, + }, + { + resource_type: 'user', + op_type: 'delete', + result: 'succeeded', + source: 'ui', + name: 'deleteUser', + id: 6, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: 'df648970-c902-42b7-a917-ac267702726e', + resource_name: 'signin', + extra: null, + created_at: 1631260410, + }, + { + resource_type: 'user', + op_type: 'update', + result: 'succeeded', + source: 'ui', + name: 'updateUser', + id: 5, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: 'eb4b7821-f4bd-4209-8529-8f018be79897', + resource_name: '73', + extra: '{"password": "YXNkZmFzZGZAMTIz"}', + created_at: 1631260076, + }, + { + resource_type: 'user', + op_type: 'delete', + result: 'succeeded', + source: 'ui', + name: 'deleteUser', + id: 4, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: '700f6bda-6537-46ff-9338-c83aa5fe5dfc', + resource_name: '71', + extra: null, + created_at: 1631260069, + }, + { + resource_type: 'user', + op_type: 'delete', + result: 'succeeded', + source: 'ui', + name: 'deleteUser', + id: 3, + user: { id: 50, username: 'admin', role: 'ADMIN' }, + user_id: 50, + uuid: 'cfe49414-9883-4fba-b76e-971339b4f977', + resource_name: '72', + extra: null, + created_at: 1631260067, + }, + ], + page_meta: { current_page: 1, page_size: 10, total_pages: 2, total_items: 12 }, + }, + status: 200, +}; +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/flags/index.ts b/web_console_v2/client/src/services/mocks/v2/flags/index.ts new file mode 100644 index 000000000..a3af09362 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/flags/index.ts @@ -0,0 +1,20 @@ +const get = () => { + return { + data: { + data: { + bcs_support_enabled: true, + data_module_beta_enabled: true, + data_module_compatible_enabled: false, + preset_template_edit_enabled: true, + user_management_enabled: true, + workspace_enabled: false, + x_host_section_enabled: false, + dashboard_enabled: false, + ot_psi_enabled: true, + }, + }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/intersection_datasets/__id__/preview.ts b/web_console_v2/client/src/services/mocks/v2/intersection_datasets/__id__/preview.ts new file mode 100644 index 000000000..274ceabff --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/intersection_datasets/__id__/preview.ts @@ -0,0 +1,328 @@ +const previewData = { + dtypes: [ + { + key: '_1', + value: 'double', + }, + { + key: '_2', + value: 'double', + }, + { + key: '_3', + value: 'double', + }, + { + key: '_4', + value: 'double', + }, + { + key: '_5', + value: 'double', + }, + { + key: '_6', + value: 'double', + }, + { + key: '_7', + value: 'double', + }, + { + key: '_8', + value: 'double', + }, + { + key: '_9', + value: 'double', + }, + { + key: '_10', + value: 'double', + }, + { + key: 'raw_id', + value: 'string', + }, + ], + count: 3000, + sample: [ + [ + 0, + 0.9500884413719177, + 0, + 0, + -0.10321885347366333, + -0.9772778749465942, + -0.15135720372200012, + 0.4105985164642334, + ], + [ + 1, + 1.4940791130065918, + 1, + 1, + 0.3130677044391632, + 0.3336743414402008, + -0.2051582634449005, + -0.8540957570075989, + ], + [ + 2, + 0.04575851559638977, + 2, + 2, + 1.5327792167663574, + -1.4543657302856445, + -0.18718385696411133, + 1.4693588018417358, + ], + [ + 3, + 1.2302906513214111, + 3, + 3, + -0.38732680678367615, + 0.15634897351264954, + 1.202379822731018, + -0.302302747964859, + ], + [ + 4, + -1.2527953386306763, + 4, + 4, + -1.6138978004455566, + -0.4380742907524109, + 0.7774903774261475, + -0.21274028718471527, + ], + [ + 5, + 0.06651721894741058, + 5, + 5, + -0.6343221068382263, + 0.4283318817615509, + 0.30247190594673157, + -0.3627411723136902, + ], + [ + 6, + -1.630198359489441, + 6, + 6, + -0.9072983860969543, + -0.4017809331417084, + 0.46278226375579834, + 0.05194539576768875, + ], + [ + 7, + -0.8707971572875977, + 7, + 7, + -0.3115525245666504, + -0.6848101019859314, + -0.5788496732711792, + 0.056165341287851334, + ], + [ + 8, + 1.1787796020507812, + 8, + 8, + -1.0707526206970215, + 1.895889163017273, + -0.1799248307943344, + 1.0544517040252686, + ], + [ + 9, + 0.01050002034753561, + 9, + 9, + 0.12691208720207214, + 0.7065731883049011, + 1.7858705520629883, + 0.4019893705844879, + ], + [ + 10, + -0.4136189818382263, + 10, + 10, + 1.922942042350769, + 1.9436211585998535, + -0.747454822063446, + 1.4805147647857666, + ], + [ + 11, + 0.9472519755363464, + 11, + 11, + 0.6140793561935425, + 0.8024563789367676, + -0.15501008927822113, + 0.922206699848175, + ], + [ + 12, + -0.4351535439491272, + 12, + 12, + 0.6722947359085083, + -0.14963454008102417, + 1.8492637872695923, + 0.40746182203292847, + ], + [ + 13, + 0.5765908360481262, + 13, + 13, + 0.39600670337677, + 0.676433265209198, + -0.20829875767230988, + -1.0930615663528442, + ], + [ + 14, + -0.9128222465515137, + 14, + 14, + -1.31590735912323, + 0.9444794654846191, + 1.117016315460205, + -0.46158459782600403, + ], + [ + 15, + 1.1266359090805054, + 15, + 15, + -1.1474686861038208, + -0.6634783148765564, + -1.0799314975738525, + -0.43782004714012146, + ], + [ + 16, + -1.0002152919769287, + 16, + 16, + 1.1880297660827637, + 0.8443629741668701, + -1.5447710752487183, + 0.31694260239601135, + ], + [ + 17, + -0.8034096360206604, + 17, + 17, + -0.4555324912071228, + 0.6815944910049438, + -0.6895498037338257, + 0.01747915893793106, + ], + [ + 18, + -1.1043833494186401, + 18, + 18, + -0.73956298828125, + -1.602057695388794, + 0.05216507986187935, + 1.543014645576477, + ], + [ + 19, + 0.7717905640602112, + 19, + 19, + 2.163235902786255, + -0.1715463250875473, + 0.8235041499137878, + 1.336527943611145, + ], + ], + metrics: { + raw_id: { + count: '5000', + mean: '2499.5', + stddev: '1443.5200033252052', + min: '0', + max: '4999', + missing_count: '0.1', + }, + z_2: { + count: '5000', + mean: '0.003840906049218029', + stddev: '0.999312078264177', + min: '-3.6270735', + max: '3.4571788', + missing_count: '0.2', + }, + example_id: { + count: '5000', + mean: '2499.5', + stddev: '1443.5200033252052', + min: '0', + max: '4999', + missing_count: '0.3', + }, + event_time: { + count: '5000', + mean: '2499.5', + stddev: '1443.5200033252052', + min: '0', + max: '4999', + missing_count: '0', + }, + z_4: { + count: '5000', + mean: '-0.0073523533177183705', + stddev: '0.9991068745409053', + min: '-4.4466324', + max: '3.8317902', + missing_count: '0', + }, + z_1: { + count: '5000', + mean: '0.00408399432664155', + stddev: '0.9994457324528213', + min: '-3.6942854', + max: '3.6361017', + missing_count: '0', + }, + z_3: { + count: '5000', + mean: '-0.012627735345379915', + stddev: '0.9899747337202139', + min: '-3.1699786', + max: '3.4915504', + missing_count: '0', + }, + z_5: { + count: '5000', + mean: '0.011706419334438396', + stddev: '0.9869785943536253', + min: '-3.5810459', + max: '3.6056588', + missing_count: '0', + }, + }, +}; +const get = { + data: { + data: { + current: previewData, + base: previewData, + }, + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/intersection_datasets/examples.ts b/web_console_v2/client/src/services/mocks/v2/intersection_datasets/examples.ts new file mode 100644 index 000000000..393c5cb18 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/intersection_datasets/examples.ts @@ -0,0 +1,122 @@ +import { IntersectionDataset } from 'typings/dataset'; + +export const readyToRun: IntersectionDataset = { + comment: '', + created_at: 1621503114, + data_source: 'u9d9fd94b01324f5ba90-data-join-job', + dataset_id: 65, + dataset_name: 'yry-test', + deleted_at: null, + id: 1, + job_name: 'data-module2', + kind: 0, + name: '求交数据集 2021-05-20-17:31:54', + path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/u9d9fd94b01324f5ba90-data-join-job', + peer_name: 'aliyun-test1', + project_id: 14, + status: 'READY_TO_RUN', + updated_at: 1621503114, + workflow_id: 34143, + file_size: 54321, +}; +export const invalid: IntersectionDataset = { + id: 42, + project_id: 14, + dataset_id: 187, + workflow_id: 121734, + name: '求交数据集 2021-08-11-21:27:44', + path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/u9d9fd94b01324f5ba90-data-join-job', + comment: '', + kind: 0, + created_at: 1628688464, + updated_at: 1628688464, + deleted_at: null, + status: 'INVALID', + job_name: 'wzzz1', + peer_name: 'aliyun-test1', + dataset_name: 'wz-psi-test2', + data_source: 'u9d9fd94b01324f5ba90-data-join-job', + file_size: 54321, +}; +export const running: IntersectionDataset = { + id: 44, + project_id: 14, + dataset_id: 162, + workflow_id: 153713, + name: '求交数据集 2021-09-02-15:47:02', + path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/u7e0239d6f0f94271a33-data-join-job', + comment: '', + kind: 0, + created_at: 1630568822, + updated_at: 1630568822, + deleted_at: null, + status: 'RUNNING', + job_name: 'etst-12124234', + peer_name: 'aliyun-test1', + dataset_name: 'wz-test-2', + data_source: 'u7e0239d6f0f94271a33-data-join-job', + file_size: 54321, +}; +export const completed: IntersectionDataset = { + comment: '', + created_at: 1631168097, + data_source: 'ue24311305a4a4b6397d-psi-data-join-job', + dataset_id: 202, + dataset_name: 'wz-test-09071620', + deleted_at: null, + id: 53, + job_name: 'sfsdfsf', + kind: 0, + name: '求交数据集 2021-09-09-14:14:57', + path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/ue24311305a4a4b6397d-psi-data-join-job', + peer_name: 'aliyun-test1', + project_id: 14, + status: 'COMPLETED', + updated_at: 1631168097, + workflow_id: 153972, + file_size: 54321, +}; +export const stopped: IntersectionDataset = { + id: 18, + project_id: 14, + dataset_id: 126, + workflow_id: 88949, + name: '求交数据集 2021-07-19-21:06:12', + path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/ua01bceb669ee42a3b6b-data-join-job', + comment: '', + kind: 0, + created_at: 1626699972, + updated_at: 1626699972, + deleted_at: null, + status: 'STOPPED', + job_name: 'workflow-fcg02', + peer_name: 'aliyun-test', + dataset_name: 'cg01', + data_source: 'ua01bceb669ee42a3b6b-data-join-job', + file_size: 54321, +}; +export const failed: IntersectionDataset = { + id: 9, + project_id: 14, + dataset_id: 98, + workflow_id: 68479, + name: '求交数据集 2021-07-01-12:37:00', + path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/ua01bceb669ee42a3b6b-data-join-job', + comment: '', + kind: 0, + created_at: 1625114220, + updated_at: 1625114220, + deleted_at: null, + status: 'FAILED', + job_name: 'm09', + peer_name: 'aliyun-test', + dataset_name: 'mock10', + data_source: 'ua01bceb669ee42a3b6b-data-join-job', + file_size: 54321, +}; diff --git a/web_console_v2/client/src/services/mocks/v2/intersection_datasets/index.ts b/web_console_v2/client/src/services/mocks/v2/intersection_datasets/index.ts new file mode 100644 index 000000000..0bb9d3fc7 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/intersection_datasets/index.ts @@ -0,0 +1,10 @@ +import { readyToRun, invalid, running, completed, stopped, failed } from './examples'; + +const get = { + data: { + data: [running, completed, stopped, failed, readyToRun, invalid], + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/jobs/__id__/events.ts b/web_console_v2/client/src/services/mocks/v2/jobs/__id__/events.ts new file mode 100644 index 000000000..9edf9d130 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/jobs/__id__/events.ts @@ -0,0 +1,15 @@ +let offset = 0; + +const get = (config: any) => { + offset += 1; + return { + data: { + data: Array(config.params.max_lines) + .fill(null) + .map((_, index) => 'Events:' + index + offset), + }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/jobs/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/jobs/__id__/index.ts new file mode 100644 index 000000000..08c55e45e --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/jobs/__id__/index.ts @@ -0,0 +1,223 @@ +const get = (config: any) => { + return { + data: { + data: { + id: 13, + name: 'u0d81863120e64c35aae-raw-data-job', + job_type: 'RAW_DATA', + state: 'FAILED', + is_disabled: false, + workflow_id: 4, + project_id: 1, + flapp_snapshot: null, + sparkapp_snapshot: null, + snapshot: + '{"app": {"status": {"appState": "FLStateShutDown", "completionTime": null, "flReplicaStatus": {"Master": {"active": {}, "failed": {"u0d81863120e64c35aae-raw-data-job-follower-master-0-0fa05dfb-0ce1-40da-894b-69af79223197": {}, "u0d81863120e64c35aae-raw-data-job-follower-master-0-20581ad4-d24a-4b20-9958-e7b0c979b24e": {}, "u0d81863120e64c35aae-raw-data-job-follower-master-0-39116f42-0cad-484a-8c37-7424b5084626": {}, "u0d81863120e64c35aae-raw-data-job-follower-master-0-3e89045e-52ff-4457-ab0d-7a917d2278c6": {}, "u0d81863120e64c35aae-raw-data-job-follower-master-0-b741750c-efbb-4893-adb5-838d7fdcddff": {}, "u0d81863120e64c35aae-raw-data-job-follower-master-0-c5610c81-1b7b-4180-85fd-6e065f538bc9": {}}, "local": {"u0d81863120e64c35aae-raw-data-job-follower-master-0": {}}, "mapping": {}, "remote": {}, "succeeded": {}}, "Worker": {"active": {}, "failed": {"u0d81863120e64c35aae-raw-data-job-follower-worker-0-cc2ecf1f-011f-4b36-98cb-dcac561ca6bf": {}, "u0d81863120e64c35aae-raw-data-job-follower-worker-1-f9d2835f-eeca-4a88-89d0-9694904b48bd": {}, "u0d81863120e64c35aae-raw-data-job-follower-worker-2-90e2ee0e-ba90-4d5b-b841-b894a17533cd": {}, "u0d81863120e64c35aae-raw-data-job-follower-worker-3-bca627c6-2c72-4e26-82bc-bd0e1a131ae2": {}}, "local": {"u0d81863120e64c35aae-raw-data-job-follower-worker-0": {}, "u0d81863120e64c35aae-raw-data-job-follower-worker-1": {}, "u0d81863120e64c35aae-raw-data-job-follower-worker-2": {}, "u0d81863120e64c35aae-raw-data-job-follower-worker-3": {}}, "mapping": {}, "remote": {}, "succeeded": {}}}}}, "pods": {"items": [{"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:41+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:41+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://f96c6294df7efc5e5e46d83fae462ed0512b26d96946c306e062265a2b05380b", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": false, "restart_count": 0, "started": false, "state": {"running": null, "terminated": {"container_id": "docker://f96c6294df7efc5e5e46d83fae462ed0512b26d96946c306e062265a2b05380b", "exit_code": 1, "finished_at": "2022-04-21T08:41:41+00:00", "message": null, "reason": "Error", "signal": null, "started_at": "2022-04-21T08:41:35+00:00"}, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.64", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Failed", "pod_ip": "172.20.1.228", "pod_i_ps": [{"ip": "172.20.1.228"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:41:33+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:41:33+00:00", "deletion_grace_period_seconds": 0, "deletion_timestamp": "2022-04-21T08:41:44+00:00", "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "0", "fl-replica-type": "master", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-master-0-b741750c-efbb-4893-adb5-838d7fdcddff", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982405396", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-master-0-b741750c-efbb-4893-adb5-838d7fdcddff", "uid": "cf8adcce-4344-4777-8c3d-f6c7f47971dc"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:34+00:00", "message": null, "reason": null, "status": "True", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:34+00:00", "message": null, "reason": null, "status": "True", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://4a2d516148b8d0457cd144fada8e39fa6b02cf6698d74ec5fcc351add764a4b6", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": true, "restart_count": 0, "started": true, "state": {"running": {"started_at": "2022-04-21T08:41:34+00:00"}, "terminated": null, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.57", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Running", "pod_ip": "172.20.0.222", "pod_i_ps": [{"ip": "172.20.0.222"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:41:33+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:41:33+00:00", "deletion_grace_period_seconds": null, "deletion_timestamp": null, "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "0", "fl-replica-type": "worker", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-worker-0-cc2ecf1f-011f-4b36-98cb-dcac561ca6bf", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982404984", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-worker-0-cc2ecf1f-011f-4b36-98cb-dcac561ca6bf", "uid": "03fdb82a-a9d7-4280-8c13-013676e9a752"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:35+00:00", "message": null, "reason": null, "status": "True", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:35+00:00", "message": null, "reason": null, "status": "True", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://eca6aecd645b52dea03dfc2d64535ae33f1c78b39ae056299f5e9ff6bbf9a789", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": true, "restart_count": 0, "started": true, "state": {"running": {"started_at": "2022-04-21T08:41:34+00:00"}, "terminated": null, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.62", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Running", "pod_ip": "172.20.2.93", "pod_i_ps": [{"ip": "172.20.2.93"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:41:33+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:41:33+00:00", "deletion_grace_period_seconds": null, "deletion_timestamp": null, "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "1", "fl-replica-type": "worker", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-worker-1-f9d2835f-eeca-4a88-89d0-9694904b48bd", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982405009", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-worker-1-f9d2835f-eeca-4a88-89d0-9694904b48bd", "uid": "c2fb28d9-43ae-4ab3-988c-54dacfc2037c"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:35+00:00", "message": null, "reason": null, "status": "True", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:35+00:00", "message": null, "reason": null, "status": "True", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://db00170b8d2ab93a5541e8207e94bdfe9fcc415b8d6742993c6cb970cac9fe0a", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": true, "restart_count": 0, "started": true, "state": {"running": {"started_at": "2022-04-21T08:41:34+00:00"}, "terminated": null, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.60", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Running", "pod_ip": "172.20.1.167", "pod_i_ps": [{"ip": "172.20.1.167"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:41:33+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:41:33+00:00", "deletion_grace_period_seconds": null, "deletion_timestamp": null, "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "2", "fl-replica-type": "worker", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-worker-2-90e2ee0e-ba90-4d5b-b841-b894a17533cd", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982405014", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-worker-2-90e2ee0e-ba90-4d5b-b841-b894a17533cd", "uid": "5c39edb3-f34b-47c6-9a7b-c86f1377982a"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:35+00:00", "message": null, "reason": null, "status": "True", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:35+00:00", "message": null, "reason": null, "status": "True", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:33+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://ee4c255a9a0bcf865f0fcc9b8f3fd06817cfc45037525f998e77f50af62b0c1a", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": true, "restart_count": 0, "started": true, "state": {"running": {"started_at": "2022-04-21T08:41:34+00:00"}, "terminated": null, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.64", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Running", "pod_ip": "172.20.1.227", "pod_i_ps": [{"ip": "172.20.1.227"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:41:33+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:41:33+00:00", "deletion_grace_period_seconds": null, "deletion_timestamp": null, "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "3", "fl-replica-type": "worker", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-worker-3-bca627c6-2c72-4e26-82bc-bd0e1a131ae2", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982405032", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-worker-3-bca627c6-2c72-4e26-82bc-bd0e1a131ae2", "uid": "45506ffd-82c7-464d-8e0d-a1003ce5c7f8"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:44+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:52+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:52+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:44+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://d3d7d35423c1da5822bf6edd311ec63e36b12f9c4e0e902d21db3ca0f5f274bb", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": false, "restart_count": 0, "started": false, "state": {"running": null, "terminated": {"container_id": "docker://d3d7d35423c1da5822bf6edd311ec63e36b12f9c4e0e902d21db3ca0f5f274bb", "exit_code": 1, "finished_at": "2022-04-21T08:41:52+00:00", "message": null, "reason": "Error", "signal": null, "started_at": "2022-04-21T08:41:45+00:00"}, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.59", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Failed", "pod_ip": "172.20.1.94", "pod_i_ps": [{"ip": "172.20.1.94"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:41:44+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:41:44+00:00", "deletion_grace_period_seconds": 0, "deletion_timestamp": "2022-04-21T08:41:54+00:00", "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "0", "fl-replica-type": "master", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-master-0-c5610c81-1b7b-4180-85fd-6e065f538bc9", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982405829", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-master-0-c5610c81-1b7b-4180-85fd-6e065f538bc9", "uid": "25066279-22f4-4a54-af52-e7b73b85ec13"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:54+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:03+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:03+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:41:54+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://47f1bd13a385a28028977d590b23bc33246277aac34b7d2835b3dd7efd582a4a", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": false, "restart_count": 0, "started": false, "state": {"running": null, "terminated": {"container_id": "docker://47f1bd13a385a28028977d590b23bc33246277aac34b7d2835b3dd7efd582a4a", "exit_code": 1, "finished_at": "2022-04-21T08:42:02+00:00", "message": null, "reason": "Error", "signal": null, "started_at": "2022-04-21T08:41:55+00:00"}, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.59", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Failed", "pod_ip": "172.20.1.95", "pod_i_ps": [{"ip": "172.20.1.95"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:41:54+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:41:54+00:00", "deletion_grace_period_seconds": 0, "deletion_timestamp": "2022-04-21T08:42:04+00:00", "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "0", "fl-replica-type": "master", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-master-0-0fa05dfb-0ce1-40da-894b-69af79223197", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982406271", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-master-0-0fa05dfb-0ce1-40da-894b-69af79223197", "uid": "6fe1044e-e13d-4206-b9b4-6de1c52df48a"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:04+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:13+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:13+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:04+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://ef5b213dc9b879cf8a5cf14dd6af4b86f066f73a7c21479bc8e87ff3e68c6924", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": false, "restart_count": 0, "started": false, "state": {"running": null, "terminated": {"container_id": "docker://ef5b213dc9b879cf8a5cf14dd6af4b86f066f73a7c21479bc8e87ff3e68c6924", "exit_code": 1, "finished_at": "2022-04-21T08:42:12+00:00", "message": null, "reason": "Error", "signal": null, "started_at": "2022-04-21T08:42:05+00:00"}, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.59", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Failed", "pod_ip": "172.20.1.96", "pod_i_ps": [{"ip": "172.20.1.96"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:42:04+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:42:04+00:00", "deletion_grace_period_seconds": 0, "deletion_timestamp": "2022-04-21T08:42:14+00:00", "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "0", "fl-replica-type": "master", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-master-0-39116f42-0cad-484a-8c37-7424b5084626", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982406709", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-master-0-39116f42-0cad-484a-8c37-7424b5084626", "uid": "d7283a7a-a66e-427a-bfe9-12c5542bc43e"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:14+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:23+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:23+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:14+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://c0fc917668d5f3e8fbbf6b6fecee660bba3df7faaf9ce248f61985ca16484da0", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": false, "restart_count": 0, "started": false, "state": {"running": null, "terminated": {"container_id": "docker://c0fc917668d5f3e8fbbf6b6fecee660bba3df7faaf9ce248f61985ca16484da0", "exit_code": 1, "finished_at": "2022-04-21T08:42:22+00:00", "message": null, "reason": "Error", "signal": null, "started_at": "2022-04-21T08:42:15+00:00"}, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.59", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Failed", "pod_ip": "172.20.1.97", "pod_i_ps": [{"ip": "172.20.1.97"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:42:14+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:42:14+00:00", "deletion_grace_period_seconds": 0, "deletion_timestamp": "2022-04-21T08:42:24+00:00", "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "0", "fl-replica-type": "master", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-master-0-20581ad4-d24a-4b20-9958-e7b0c979b24e", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982407144", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-master-0-20581ad4-d24a-4b20-9958-e7b0c979b24e", "uid": "9b466c64-f70c-4449-ba34-f094200f3309"}}, {"status": {"conditions": [{"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:24+00:00", "message": null, "reason": null, "status": "True", "type": "Initialized"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:32+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "Ready"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:32+00:00", "message": "containers with unready status: [tensorflow]", "reason": "ContainersNotReady", "status": "False", "type": "ContainersReady"}, {"last_probe_time": null, "last_transition_time": "2022-04-21T08:42:24+00:00", "message": null, "reason": null, "status": "True", "type": "PodScheduled"}], "container_statuses": [{"container_id": "docker://de5ed97e5c40861378439814230db66654d54e88668e93e6379908d3a2a2f2f8", "image": "artifact.bytedance.com/fedlearner/fedlearner:882310f", "image_id": "docker-pullable://artifact.bytedance.com/fedlearner/fedlearner@sha256:170c117f8615b53372b5b8e3aaec14997f3d5a77c3921824ecc24f5b99dbf577", "last_state": {"running": null, "terminated": null, "waiting": null}, "name": "tensorflow", "ready": false, "restart_count": 0, "started": false, "state": {"running": null, "terminated": {"container_id": "docker://de5ed97e5c40861378439814230db66654d54e88668e93e6379908d3a2a2f2f8", "exit_code": 1, "finished_at": "2022-04-21T08:42:32+00:00", "message": null, "reason": "Error", "signal": null, "started_at": "2022-04-21T08:42:25+00:00"}, "waiting": null}}], "ephemeral_container_statuses": null, "host_ip": "192.168.252.59", "init_container_statuses": null, "message": null, "nominated_node_name": null, "phase": "Failed", "pod_ip": "172.20.1.98", "pod_i_ps": [{"ip": "172.20.1.98"}], "qos_class": "Guaranteed", "reason": null, "start_time": "2022-04-21T08:42:24+00:00"}, "metadata": {"annotations": {"kubernetes.io/psp": "ack.privileged"}, "cluster_name": null, "creation_timestamp": "2022-04-21T08:42:24+00:00", "deletion_grace_period_seconds": 0, "deletion_timestamp": "2022-04-21T08:42:34+00:00", "finalizers": null, "generate_name": null, "generation": null, "labels": {"app-name": "u0d81863120e64c35aae-raw-data-job", "fl-replica-index": "0", "fl-replica-type": "master", "role": "follower"}, "managed_fields": null, "name": "u0d81863120e64c35aae-raw-data-job-follower-master-0-3e89045e-52ff-4457-ab0d-7a917d2278c6", "namespace": "default", "owner_references": [{"api_version": "fedlearner.k8s.io/v1alpha1", "block_owner_deletion": true, "controller": true, "kind": "FLApp", "name": "u0d81863120e64c35aae-raw-data-job", "uid": "620e92c7-0842-41dd-8eff-cf9c7e010b6b"}], "resource_version": "2982407585", "self_link": "/api/v1/namespaces/default/pods/u0d81863120e64c35aae-raw-data-job-follower-master-0-3e89045e-52ff-4457-ab0d-7a917d2278c6", "uid": "b739e218-6b15-4c7b-b2d3-cddd30ddd370"}}], "deleted": false}}', + error_message: null, + crd_meta: 'api_version: "fedlearner.k8s.io/v1alpha1"\n', + crd_kind: 'FLApp', + created_at: 1650530491, + updated_at: 1650530554, + deleted_at: null, + complete_at: 0, + pods: [ + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-master-0-b741750c-efbb-4893-adb5-838d7fdcddff', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.228', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530493, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-worker-0-cc2ecf1f-011f-4b36-98cb-dcac561ca6bf', + pod_type: 'WORKER', + state: 'RUNNING', + pod_ip: '172.20.0.222', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530493, + message: '', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-worker-1-f9d2835f-eeca-4a88-89d0-9694904b48bd', + pod_type: 'WORKER', + state: 'RUNNING', + pod_ip: '172.20.2.93', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530493, + message: '', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-worker-2-90e2ee0e-ba90-4d5b-b841-b894a17533cd', + pod_type: 'WORKER', + state: 'RUNNING', + pod_ip: '172.20.1.167', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530493, + message: '', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-worker-3-bca627c6-2c72-4e26-82bc-bd0e1a131ae2', + pod_type: 'WORKER', + state: 'RUNNING', + pod_ip: '172.20.1.227', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530493, + message: '', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-master-0-c5610c81-1b7b-4180-85fd-6e065f538bc9', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.94', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530504, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-master-0-0fa05dfb-0ce1-40da-894b-69af79223197', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.95', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530514, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-master-0-39116f42-0cad-484a-8c37-7424b5084626', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.96', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530524, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-master-0-20581ad4-d24a-4b20-9958-e7b0c979b24e', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.97', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530534, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-master-0-3e89045e-52ff-4457-ab0d-7a917d2278c6', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.98', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530544, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64c35aae-raw-a-job-follower-master-0-3e89045e-52ff-4457-ab0d-7a917d2278c6', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.98', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530544, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-mar-0-3e89045e-52ff-4457-ab0d-7a917d2278c6', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.98', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530544, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64aae-raw-data-job-follower-master-0-3e89045e-52ff-4457-ab0d-7a917d2278c6', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.98', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530544, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + { + name: + 'u0d81863120e64c35aae-raw-data-job-follower-mas-0-3e89045e-52ff-4457-ab0d-7a917d2278c6', + pod_type: 'MASTER', + state: 'FAILED_AND_FREED', + pod_ip: '172.20.1.98', + limits_cpu: '', + limits_memory: '', + requests_cpu: '', + requests_memory: '', + creation_timestamp: 1650530544, + message: + 'terminated:Error, Ready:containers with unready status: [tensorflow], ContainersReady:containers with unready status: [tensorflow]', + }, + ], + }, + }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/jobs/__id__/log.ts b/web_console_v2/client/src/services/mocks/v2/jobs/__id__/log.ts new file mode 100644 index 000000000..9a10b6fc4 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/jobs/__id__/log.ts @@ -0,0 +1,15 @@ +let offset = 0; + +const get = (config: any) => { + offset += 1; + return { + data: { + data: Array(config.params.max_lines) + .fill(null) + .map((_, index) => index + offset), + }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/jobs/__id__/metrics.ts b/web_console_v2/client/src/services/mocks/v2/jobs/__id__/metrics.ts new file mode 100644 index 000000000..d9b5e9b44 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/jobs/__id__/metrics.ts @@ -0,0 +1,982 @@ +const datas = [ + { + width: 640.0, + height: 480.0, + axes: [ + { + bbox: [0.22375000000000006, 0.10999999999999999, 0.5774999999999999, 0.77], + xlim: [-1.25, 1.25], + ylim: [-1.25, 1.25], + xdomain: [-1.25, 1.25], + ydomain: [-1.25, 1.25], + xscale: 'linear', + yscale: 'linear', + axes: [ + { + position: 'bottom', + nticks: 0, + tickvalues: [], + tickformat_formatter: '', + tickformat: '', + scale: 'linear', + fontsize: null, + grid: { gridOn: false }, + visible: true, + }, + { + position: 'left', + nticks: 0, + tickvalues: [], + tickformat_formatter: '', + tickformat: '', + scale: 'linear', + fontsize: null, + grid: { gridOn: false }, + visible: true, + }, + ], + axesbg: '#FFFFFF', + axesbgalpha: null, + zoomable: true, + id: 'el27399140377262465264', + lines: [], + paths: [ + { + data: 'data01', + xindex: 0, + yindex: 1, + coordinates: 'data', + pathcodes: ['M', 'C', 'C', 'L', 'L', 'Z'], + id: 'el27399140377279386456', + dasharray: 'none', + alpha: 1, + facecolor: '#1F77B4', + edgecolor: 'none', + edgewidth: 1.0, + zorder: 1, + }, + { + data: 'data02', + xindex: 0, + yindex: 1, + coordinates: 'data', + pathcodes: ['M', 'C', 'C', 'L', 'L', 'Z'], + id: 'el27399140377279388304', + dasharray: 'none', + alpha: 1, + facecolor: '#FF7F0E', + edgecolor: 'none', + edgewidth: 1.0, + zorder: 1, + }, + { + data: 'data03', + xindex: 0, + yindex: 1, + coordinates: 'data', + pathcodes: [ + 'M', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'C', + 'L', + 'L', + 'Z', + ], + id: 'el27399140377279467984', + dasharray: 'none', + alpha: 1, + facecolor: '#2CA02C', + edgecolor: 'none', + edgewidth: 1.0, + zorder: 1, + }, + ], + markers: [], + texts: [ + { + text: 'joined', + position: [0.8763816039133014, 0.6647971753266928], + coordinates: 'data', + h_anchor: 'start', + v_baseline: 'central', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 3, + id: 'el27399140377279387072', + }, + { + text: '20.7%', + position: [0.47802632940725526, 0.3626166410872869], + coordinates: 'data', + h_anchor: 'middle', + v_baseline: 'central', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 3, + id: 'el27399140377279387744', + }, + { + text: 'fake', + position: [0.21574897175355073, 1.078634498422559], + coordinates: 'data', + h_anchor: 'start', + v_baseline: 'central', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 3, + id: 'el27399140377279388864', + }, + { + text: '2.4%', + position: [0.11768125732011855, 0.5883460900486686], + coordinates: 'data', + h_anchor: 'middle', + v_baseline: 'central', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 3, + id: 'el27399140377279389536', + }, + { + text: 'unjoined', + position: [-0.8237741370424402, -0.728969252534003], + coordinates: 'data', + h_anchor: 'end', + v_baseline: 'central', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 3, + id: 'el27399140377279468544', + }, + { + text: '76.9%', + position: [-0.4493313474776946, -0.3976195922912743], + coordinates: 'data', + h_anchor: 'middle', + v_baseline: 'central', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 3, + id: 'el27399140377279469216', + }, + ], + collections: [], + images: [], + sharex: [], + sharey: [], + }, + ], + data: { + data01: [ + [1.0, 0.0], + [1.0, 0.21820079894835423], + [0.9285826170074498, 0.4305181901537911], + [0.7967105490120922, 0.6043610684788115], + [0.6648384810167345, 0.778203946803832], + [0.4796231331981813, 0.9041975662225926], + [0.2694953978142985, 0.9630016773385771], + [0.0, 0.0], + [1.0, 0.0], + ], + data02: [ + [0.2694953978142985, 0.9630016773385771], + [0.2452718235109056, 0.9697806289717859], + [0.22080109242657905, 0.9756431793030209], + [0.19613542886686428, 0.980576816747781], + [0.1714697653071495, 0.985510454192541], + [0.14662672853725212, 0.9895116665384701], + [0.12165933742033867, 0.9925719145827391], + [0.0, 0.0], + [0.2694953978142985, 0.9630016773385771], + ], + data03: [ + [0.12165933742033867, 0.9925719145827391], + [0.021502077677944084, 1.0048481695110472], + [-0.07993884628881305, 1.0018941880099832], + [-0.1792120172735806, 0.9838104760901532], + [-0.27848518825834817, 0.965726764170323], + [-0.37445180349668694, 0.9327207674176923], + [-0.46384670318802657, 0.8859154790055306], + [-0.5532416028793662, 0.8391101905933689], + [-0.6350393019114111, 0.7790425329480014], + [-0.70645672246074, 0.7077562428477964], + [-0.7778741430100689, 0.6364699527475913], + [-0.838092026911118, 0.5547827841076038], + [-0.8850615261004146, 0.46547405407477355], + [-0.9320310252897112, 0.3761653240419432], + [-0.9652133336125761, 0.28025952924150166], + [-0.983479459086153, 0.18101976012471133], + [-1.0017455845597298, 0.081779991007921], + [-1.0048859890582735, -0.01965533282713952], + [-0.9927938237312678, -0.11983498471251325], + [-0.980701658404262, -0.22001463659788698], + [-0.9535156373792587, -0.31778941515946724], + [-0.9121607354797193, -0.4098326398044664], + [-0.8708058335801799, -0.5018758644494655], + [-0.8157564496405441, -0.5871316700570876], + [-0.748885579129491, -0.6626993204854572], + [-0.682014708618438, -0.7382669709138269], + [-0.6040894543840283, -0.8032795990289915], + [-0.5177611385234677, -0.8555252208058407], + [-0.4314328226629071, -0.9077708425826899], + [-0.33769175226295606, -0.9466501273267461], + [-0.239727365379465, -0.9708402496230883], + [-0.14176297849597397, -0.9950303719194306], + [-0.04069906430070804, -1.0042538370746796], + [0.06002578734777352, -0.9981968267096825], + [0.16075063899625508, -0.9921398163446853], + [0.2599809725164783, -0.9708718128793092], + [0.3543405836398784, -0.9351164370204121], + [0.44870019476327855, -0.899361061161515], + [0.5371066461612921, -0.849528477311702], + [0.6165520043927017, -0.7873141849854745], + [0.6959973626241114, -0.7250998926592469], + [0.7655702777977194, -0.6512175772097164], + [0.8229036070767262, -0.5681810041352253], + [0.880236936355733, -0.4851444310607343], + [0.924672985891642, -0.393906146532909], + [0.9546998673857932, -0.2975704340379754], + [0.9847267488799443, -0.20123472154304178], + [1.000000011809464, -0.10090668706900392], + [0.9999999999999931, 1.1703344580048182e-7], + [0.0, 0.0], + [0.12165933742033867, 0.9925719145827391], + ], + }, + id: 'el27399140377638971096', + plugins: [ + { type: 'reset' }, + { type: 'zoom', button: true, enabled: false }, + { type: 'boxzoom', button: true, enabled: false }, + ], + }, + + { + width: 640.0, + height: 480.0, + axes: [ + { + bbox: [0.125, 0.10999999999999999, 0.775, 0.77], + xlim: [18244.033333333333, 18646.63333333333], + ylim: [0.0, 5831.7], + xdomain: [ + [2019, 11, 14, 0, 48, 0, 0.0], + [2021, 0, 19, 15, 12, 0, 0.0], + ], + ydomain: [0.0, 5831.7], + xscale: 'date', + yscale: 'linear', + axes: [ + { + position: 'bottom', + nticks: 7, + tickvalues: null, + tickformat_formatter: '', + tickformat: null, + scale: 'linear', + fontsize: 10.0, + grid: { gridOn: false }, + visible: true, + }, + { + position: 'left', + nticks: 7, + tickvalues: null, + tickformat_formatter: '', + tickformat: null, + scale: 'linear', + fontsize: 10.0, + grid: { gridOn: false }, + visible: true, + }, + ], + axesbg: '#FFFFFF', + axesbgalpha: null, + zoomable: true, + id: 'el27399140377282417776', + lines: [], + paths: [ + { + data: 'data02', + xindex: 0, + yindex: 1, + coordinates: 'axes', + pathcodes: ['M', 'L', 'L', 'L', 'Z'], + id: 'el27399140377282968656', + dasharray: 'none', + alpha: 1, + facecolor: '#1F77B4', + edgecolor: 'none', + edgewidth: 1.0, + zorder: 1000001.0, + }, + { + data: 'data02', + xindex: 0, + yindex: 2, + coordinates: 'axes', + pathcodes: ['M', 'L', 'L', 'L', 'Z'], + id: 'el27399140377282969328', + dasharray: 'none', + alpha: 1, + facecolor: '#FF7F0E', + edgecolor: 'none', + edgewidth: 1.0, + zorder: 1000001.0, + }, + { + data: 'data02', + xindex: 0, + yindex: 3, + coordinates: 'axes', + pathcodes: ['M', 'L', 'L', 'L', 'Z'], + id: 'el27399140377283051984', + dasharray: 'none', + alpha: 1, + facecolor: '#2CA02C', + edgecolor: 'none', + edgewidth: 1.0, + zorder: 1000001.0, + }, + { + data: 'data03', + xindex: 0, + yindex: 1, + coordinates: 'axes', + pathcodes: ['M', 'L', 'S', 'L', 'S', 'L', 'S', 'L', 'S', 'Z'], + id: 'el27399140377282967760', + dasharray: 'none', + alpha: 0.8, + facecolor: 'rgba(255, 255, 255, 0.8)', + edgecolor: 'rgba(204, 204, 204, 0.8)', + edgewidth: 1.0, + zorder: 1000000.0, + }, + ], + markers: [], + texts: [ + { + text: 'joined', + position: [0.8538306451612904, 0.9364177489177489], + coordinates: 'axes', + h_anchor: 'start', + v_baseline: 'auto', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 1000003.0, + id: 'el27399140377282967984', + }, + { + text: 'fake', + position: [0.8538306451612904, 0.8797498797498798], + coordinates: 'axes', + h_anchor: 'start', + v_baseline: 'auto', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 1000003.0, + id: 'el27399140377282968768', + }, + { + text: 'unjoined', + position: [0.8538306451612904, 0.8230820105820105], + coordinates: 'axes', + h_anchor: 'start', + v_baseline: 'auto', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 1000003.0, + id: 'el27399140377282969440', + }, + ], + collections: [ + { + offsets: 'data01', + xindex: 0, + yindex: 1, + paths: [ + [ + [ + [18262.333333333332, 75.0], + [18262.333333333332, 0.0], + [18293.333333333332, 0.0], + [18322.333333333332, 0.0], + [18353.333333333332, 0.0], + [18383.333333333332, 0.0], + [18414.333333333332, 0.0], + [18444.333333333332, 0.0], + [18475.333333333332, 0.0], + [18506.333333333332, 0.0], + [18536.333333333332, 0.0], + [18567.333333333332, 0.0], + [18597.333333333332, 0.0], + [18628.333333333332, 0.0], + [18628.333333333332, 1007.0], + [18628.333333333332, 1007.0], + [18597.333333333332, 1037.0], + [18567.333333333332, 1004.0], + [18536.333333333332, 1030.0], + [18506.333333333332, 1039.0], + [18475.333333333332, 1043.0], + [18444.333333333332, 1098.0], + [18414.333333333332, 1084.0], + [18383.333333333332, 1089.0], + [18353.333333333332, 1041.0], + [18322.333333333332, 1130.0], + [18293.333333333332, 1035.0], + [18262.333333333332, 75.0], + ], + [ + 'M', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'Z', + ], + ], + ], + pathtransforms: [], + alphas: [null], + edgecolors: [], + facecolors: ['#1F77B4'], + edgewidths: [1.0], + offsetcoordinates: 'display', + pathcoordinates: 'data', + zorder: 1, + id: 'el27399140377282498400', + }, + { + offsets: 'data01', + xindex: 0, + yindex: 0, + paths: [ + [ + [ + [18262.333333333332, 85.0], + [18262.333333333332, 75.0], + [18293.333333333332, 1035.0], + [18322.333333333332, 1130.0], + [18353.333333333332, 1041.0], + [18383.333333333332, 1089.0], + [18414.333333333332, 1084.0], + [18444.333333333332, 1098.0], + [18475.333333333332, 1043.0], + [18506.333333333332, 1039.0], + [18536.333333333332, 1030.0], + [18567.333333333332, 1004.0], + [18597.333333333332, 1037.0], + [18628.333333333332, 1007.0], + [18628.333333333332, 1131.0], + [18628.333333333332, 1131.0], + [18597.333333333332, 1171.0], + [18567.333333333332, 1144.0], + [18536.333333333332, 1141.0], + [18506.333333333332, 1152.0], + [18475.333333333332, 1159.0], + [18444.333333333332, 1213.0], + [18414.333333333332, 1221.0], + [18383.333333333332, 1215.0], + [18353.333333333332, 1145.0], + [18322.333333333332, 1262.0], + [18293.333333333332, 1151.0], + [18262.333333333332, 85.0], + ], + [ + 'M', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'Z', + ], + ], + ], + pathtransforms: [], + alphas: [null], + edgecolors: [], + facecolors: ['#FF7F0E'], + edgewidths: [1.0], + offsetcoordinates: 'display', + pathcoordinates: 'data', + zorder: 1, + id: 'el27399140377282657696', + }, + { + offsets: 'data01', + xindex: 0, + yindex: 0, + paths: [ + [ + [ + [18262.333333333332, 395.0], + [18262.333333333332, 85.0], + [18293.333333333332, 1151.0], + [18322.333333333332, 1262.0], + [18353.333333333332, 1145.0], + [18383.333333333332, 1215.0], + [18414.333333333332, 1221.0], + [18444.333333333332, 1213.0], + [18475.333333333332, 1159.0], + [18506.333333333332, 1152.0], + [18536.333333333332, 1141.0], + [18567.333333333332, 1144.0], + [18597.333333333332, 1171.0], + [18628.333333333332, 1131.0], + [18628.333333333332, 1879.0], + [18628.333333333332, 1879.0], + [18597.333333333332, 5445.0], + [18567.333333333332, 5235.0], + [18536.333333333332, 5391.0], + [18506.333333333332, 5318.0], + [18475.333333333332, 5487.0], + [18444.333333333332, 5495.0], + [18414.333333333332, 5489.0], + [18383.333333333332, 5554.0], + [18353.333333333332, 5219.0], + [18322.333333333332, 5545.0], + [18293.333333333332, 5086.0], + [18262.333333333332, 395.0], + ], + [ + 'M', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'Z', + ], + ], + ], + pathtransforms: [], + alphas: [null], + edgecolors: [], + facecolors: ['#2CA02C'], + edgewidths: [1.0], + offsetcoordinates: 'display', + pathcoordinates: 'data', + zorder: 1, + id: 'el27399140377282657752', + }, + ], + images: [], + sharex: ['el27399140377282656520'], + sharey: [], + }, + { + bbox: [0.125, 0.10999999999999999, 0.775, 0.77], + xlim: [18244.033333333333, 18646.63333333333], + ylim: [0.17257092043303401, 0.5532258607797037], + xdomain: [ + [2019, 11, 14, 0, 48, 0, 0.0], + [2021, 0, 19, 15, 12, 0, 0.0], + ], + ydomain: [0.17257092043303401, 0.5532258607797037], + xscale: 'date', + yscale: 'linear', + axes: [ + { + position: 'bottom', + nticks: 7, + tickvalues: null, + tickformat_formatter: '', + tickformat: null, + scale: 'linear', + fontsize: 10.0, + grid: { gridOn: false }, + visible: false, + }, + { + position: 'right', + nticks: 10, + tickvalues: null, + tickformat_formatter: '', + tickformat: null, + scale: 'linear', + fontsize: 10.0, + grid: { gridOn: false }, + visible: true, + }, + ], + axesbg: 'none', + axesbgalpha: 0.0, + zoomable: true, + id: 'el27399140377282656520', + lines: [ + { + data: 'data04', + xindex: 0, + yindex: 1, + coordinates: 'data', + id: 'el27399140377282656856', + color: '#000000', + linewidth: 1.5, + dasharray: 'none', + alpha: 1, + zorder: 2, + drawstyle: 'default', + }, + ], + paths: [], + markers: [], + texts: [], + collections: [], + images: [], + sharex: ['el27399140377282417776'], + sharey: [], + }, + ], + data: { + data01: [[0.0, 0.0]], + data02: [ + [0.7754256272401433, 0.9364177489177489, 0.8797498797498798, 0.8230820105820105], + [0.831429211469534, 0.9364177489177489, 0.8797498797498798, 0.8230820105820105], + [0.831429211469534, 0.9627224627224626, 0.9060545935545936, 0.8493867243867242], + [0.7754256272401433, 0.9627224627224626, 0.9060545935545936, 0.8493867243867242], + ], + data03: [ + [0.7698252688172043, 0.7999338624338624], + [0.9803987455197131, 0.7999338624338624], + [0.9859991039426522, 0.7999338624338624], + [0.9859991039426522, 0.8074494949494949], + [0.9859991039426522, 0.9736952861952862], + [0.9859991039426522, 0.9812109187109187], + [0.9803987455197131, 0.9812109187109187], + [0.7698252688172043, 0.9812109187109187], + [0.7642249103942652, 0.9812109187109187], + [0.7642249103942652, 0.9736952861952862], + [0.7642249103942652, 0.8074494949494949], + [0.7642249103942652, 0.7999338624338624], + [0.7698252688172043, 0.7999338624338624], + ], + data04: [ + [18262.333333333332, 0.189873417721519], + [18293.333333333332, 0.2034998033818325], + [18322.333333333332, 0.2037871956717764], + [18353.333333333332, 0.1994634987545507], + [18383.333333333332, 0.19607490097227223], + [18414.333333333332, 0.19748588085261432], + [18444.333333333332, 0.19981801637852592], + [18475.333333333332, 0.1900856570074722], + [18506.333333333332, 0.19537420082737872], + [18536.333333333332, 0.1910591726952328], + [18567.333333333332, 0.19178605539637059], + [18597.333333333332, 0.19044995408631774], + [18628.333333333332, 0.5359233634912187], + ], + }, + id: 'el27399140377279386232', + plugins: [ + { type: 'reset' }, + { type: 'zoom', button: true, enabled: false }, + { type: 'boxzoom', button: true, enabled: false }, + ], + }, + + { + width: 640.0, + height: 480.0, + axes: [ + { + bbox: [0.125, 0.10999999999999999, 0.775, 0.77], + xlim: [18682.16736111111, 18682.16837962963], + ylim: [18272.802496527776, 18674.291369212962], + xdomain: [ + [2021, 1, 24, 4, 1, 0, 0.0], + [2021, 1, 24, 4, 2, 28, 0.0], + ], + ydomain: [ + [2020, 0, 11, 19, 15, 35, 700.0], + [2021, 1, 16, 6, 59, 34, 300.0], + ], + xscale: 'date', + yscale: 'date', + axes: [ + { + position: 'bottom', + nticks: 9, + tickvalues: null, + tickformat_formatter: '', + tickformat: null, + scale: 'linear', + fontsize: 10.0, + grid: { gridOn: false }, + visible: true, + }, + { + position: 'left', + nticks: 6, + tickvalues: null, + tickformat_formatter: '', + tickformat: null, + scale: 'linear', + fontsize: 10.0, + grid: { gridOn: false }, + visible: true, + }, + ], + axesbg: '#FFFFFF', + axesbgalpha: null, + zoomable: true, + id: 'el27399140377284792784', + lines: [ + { + data: 'data01', + xindex: 0, + yindex: 1, + coordinates: 'data', + id: 'el27399140377284756592', + color: '#1F77B4', + linewidth: 1.5, + dasharray: 'none', + alpha: 1, + zorder: 2, + drawstyle: 'default', + }, + { + data: 'data01', + xindex: 0, + yindex: 2, + coordinates: 'data', + id: 'el27399140377285030296', + color: '#FF7F0E', + linewidth: 1.5, + dasharray: 'none', + alpha: 1, + zorder: 2, + drawstyle: 'default', + }, + { + data: 'data02', + xindex: 0, + yindex: 1, + coordinates: 'axes', + id: 'el27399140377285032424', + color: '#1F77B4', + linewidth: 1.5, + dasharray: 'none', + alpha: 1, + zorder: 1000002.0, + drawstyle: 'default', + }, + { + data: 'data02', + xindex: 0, + yindex: 2, + coordinates: 'axes', + id: 'el27399140377285033544', + color: '#FF7F0E', + linewidth: 1.5, + dasharray: 'none', + alpha: 1, + zorder: 1000002.0, + drawstyle: 'default', + }, + ], + paths: [ + { + data: 'data03', + xindex: 0, + yindex: 1, + coordinates: 'axes', + pathcodes: ['M', 'L', 'S', 'L', 'S', 'L', 'S', 'L', 'S', 'Z'], + id: 'el27399140377285031416', + dasharray: 'none', + alpha: 0.8, + facecolor: 'rgba(255, 255, 255, 0.8)', + edgecolor: 'rgba(204, 204, 204, 0.8)', + edgewidth: 1.0, + zorder: 1000000.0, + }, + ], + markers: [], + texts: [ + { + text: 'min event time', + position: [0.10360663082437274, 0.9364177489177489], + coordinates: 'axes', + h_anchor: 'start', + v_baseline: 'auto', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 1000003.0, + id: 'el27399140377285031640', + }, + { + text: 'max event time', + position: [0.10360663082437274, 0.8797498797498798], + coordinates: 'axes', + h_anchor: 'start', + v_baseline: 'auto', + rotation: -0.0, + fontsize: 10.0, + color: '#000000', + alpha: 1, + zorder: 1000003.0, + id: 'el27399140377285033040', + }, + ], + collections: [], + images: [], + sharex: [], + sharey: [], + }, + ], + data: { + data01: [ + [18682.167407407407, 18291.09008101852, 18293.72167824074], + [18682.167465277777, 18291.103043981482, 18343.059780092593], + [18682.16752314815, 18291.0853125, 18350.985925925925], + [18682.16758101852, 18291.056030092594, 18293.913738425927], + [18682.167638888888, 18291.05199074074, 18411.45994212963], + [18682.167696759258, 18293.572719907406, 18349.517581018517], + [18682.16775462963, 18291.060023148148, 18465.904675925925], + [18682.1678125, 18350.12814814815, 18518.39300925926], + [18682.16787037037, 18348.474050925925, 18521.27170138889], + [18682.167928240742, 18404.96017361111, 18553.053819444445], + [18682.16798611111, 18404.83542824074, 18578.506180555556], + [18682.16804398148, 18449.878252314815, 18519.624050925926], + [18682.16810185185, 18461.460810185185, 18655.967789351853], + [18682.168159722223, 18517.329710648148, 18579.093402777777], + [18682.168217592593, 18519.287291666667, 18632.651493055557], + [18682.168275462962, 18576.185046296298, 18655.932627314814], + [18682.168333333335, 18577.358935185184, 18656.041875], + ], + data02: [ + [0.025201612903225812, 0.9495701058201058, 0.8929022366522368], + [0.0812051971326165, 0.9495701058201058, 0.8929022366522368], + ], + data03: [ + [0.01960125448028671, 0.8566017316017316], + [0.330981182795699, 0.8566017316017316], + [0.33658154121863804, 0.8566017316017316], + [0.33658154121863804, 0.8641173641173642], + [0.33658154121863804, 0.9736952861952862], + [0.33658154121863804, 0.9812109187109187], + [0.330981182795699, 0.9812109187109187], + [0.01960125448028671, 0.9812109187109187], + [0.014000896057347667, 0.9812109187109187], + [0.014000896057347667, 0.9736952861952862], + [0.014000896057347667, 0.8641173641173642], + [0.014000896057347667, 0.8566017316017316], + [0.01960125448028671, 0.8566017316017316], + ], + }, + id: 'el27399140377282968096', + plugins: [ + { type: 'reset' }, + { type: 'zoom', button: true, enabled: false }, + { type: 'boxzoom', button: true, enabled: false }, + ], + }, +]; + +const get = { + data: { data: datas }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/model_groups/index.ts b/web_console_v2/client/src/services/mocks/v2/model_groups/index.ts new file mode 100644 index 000000000..4f7e9bb95 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/model_groups/index.ts @@ -0,0 +1,31 @@ +const list = new Array(3).fill(undefined).map((_, index) => { + return { + id: index + 1, + + name: 'mock模型集' + (index + 1), + comment: '我是说明', + extra: JSON.stringify({ + name: 'mock模型集' + (index + 1), + comment: '我是说明', + creator: '测试员', + project_id: 14, + }), + + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }; +}); + +const get = { + data: { + data: list, + }, + status: 200, +}; + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/model_jobs/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/model_jobs/__id__/index.ts new file mode 100644 index 000000000..b8a6d41ab --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/model_jobs/__id__/index.ts @@ -0,0 +1,256 @@ +import { AxiosRequestConfig } from 'axios'; +import { modelJobMetric, modelJobMetric2 } from '../examples'; + +const workflowList = [ + { + id: 68420, + uuid: 'u8b4f360a5c2a4bb8b7c', + name: 'predict-job-3', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1], + job_ids: [1256], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'COMPLETED', + start_at: 1624521291, + stop_at: null, + created_at: 1624521217, + updated_at: 1624521291, + extra: null, + cron_config: '9 23 ? * 3', + }, + { + id: 68419, + uuid: 'u58da83ffb383465fa40', + name: 'wz-yyds1', + project_id: 23, + comment: null, + metric_is_public: false, + create_job_flags: [1], + job_ids: [], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'NEW', + start_at: null, + stop_at: null, + created_at: 1624456941, + updated_at: 1624456941, + extra: null, + cron_config: '28 2 * * ?', + }, + { + id: 68418, + uuid: 'u33fb2a7366d746a4b4f', + name: 'wz-yyds', + project_id: 23, + comment: null, + metric_is_public: false, + create_job_flags: null, + job_ids: [], + forkable: false, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'NEW', + start_at: null, + stop_at: null, + created_at: 1624455371, + updated_at: 1624455371, + extra: '', + cron_config: '', + }, + { + id: 68410, + uuid: 'u9629530fb99844178e4', + name: 'ot-server', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1], + job_ids: [1245], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'READY', + start_at: null, + stop_at: null, + created_at: 1624279235, + updated_at: 1624279235, + extra: null, + cron_config: '', + }, + { + id: 68361, + uuid: 'ub81427e3f4634fc4874', + name: 'e2e-test-121f880060caf986-copy', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1, 1, 1], + job_ids: [1143, 1144, 1145], + forkable: true, + forked_from: 68360, + peer_create_job_flags: [1, 1, 1], + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'RUNNING', + start_at: 1624458779, + stop_at: null, + created_at: 1623917337, + updated_at: 1624458779, + extra: null, + cron_config: '', + }, + { + id: 68402, + uuid: 'u00b486d9a80e486fae9', + name: 'e2e-test-7f5830060cf2925', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1, 1, 1], + job_ids: [1234, 1235, 1236], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'INVALID', + start_at: 1624189314, + stop_at: 1624189330, + created_at: 1624189243, + updated_at: 1624276779, + extra: null, + cron_config: '', + }, + { + id: 68399, + uuid: 'u20ae5a88618e4b2c880', + name: 'e2e-test-1ee17d0060cf177c', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1, 1, 1], + job_ids: [1227, 1228, 1229], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'STOPPED', + start_at: 1624188305, + stop_at: 1624276386, + created_at: 1624184722, + updated_at: 1624276386, + extra: null, + cron_config: '', + }, + { + id: 68318, + uuid: 'u1354c0b76c5441968a3', + name: 'default-credit-13', + project_id: 14, + comment: null, + metric_is_public: false, + create_job_flags: [1, 1, 1, 1], + job_ids: [1044, 1045, 1046, 1047], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'FAILED', + start_at: 1623834767, + stop_at: null, + created_at: 1623834636, + updated_at: 1623836863, + extra: '', + cron_config: '', + is_local: false, + }, +]; +const generateData = (index: any) => ({ + id: index, + name: '测试模型' + index, + version: index + 1, + type: null, + state: 1 + Math.floor(Math.random() * 6), + job_name: 'job测试模型' + index, + job: { + config: null, + created_at: 1626604927, + deleted_at: null, + error_message: null, + flapp_snapshot: null, + id: 9, + is_disabled: false, + job_type: 'TREE_MODEL_TRAINING', + name: 'uf31970209a1c40b49b7-tree-model', + pods: [], + project_id: 1, + sparkapp_snapshot: null, + state: 'STARTED', + updated_at: 1626604957, + workflow_id: 15, + }, + parent_id: null, + params: null, + metrics: index === 62 ? modelJobMetric : modelJobMetric2, + /** model set id */ + group_id: index, + extra: JSON.stringify({ + name: 'mock模型详情' + index, + comment: '我是说明' + index, + creator: '测试员', + }), + local_extra: JSON.stringify({ + 'model.desc': '我是说明' + index, + 'model.dataset_id': index, + }), + detail_level: [], + workflow: workflowList[Math.floor(Math.random() * workflowList.length)], + + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, +}); + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +const get = (config: AxiosRequestConfig) => { + return { + data: { data: generateData(Number(config._id! || 0) + 1) }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/model_jobs/examples.ts b/web_console_v2/client/src/services/mocks/v2/model_jobs/examples.ts new file mode 100644 index 000000000..b45e8e6d2 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/model_jobs/examples.ts @@ -0,0 +1,165 @@ +import { ModelJobMetrics } from 'typings/modelCenter'; + +export const modelJobMetric: ModelJobMetrics = { + train: { + acc: [ + [1, 2], + [0.6, 0.9], + ], + auc: [ + [1, 2], + [0.6, 0.8], + ], + precision: [ + [1, 2], + [0.6, 0.7], + ], + recall: [ + [1, 2], + [0.7, 0.2], + ], + f1: [ + [1, 2], + [0.6, 0.1], + ], + ks: [ + [1, 2], + [0.6, 0.7], + ], + }, + eval: { + acc: [ + [1, 2], + [0.6, 0.9], + ], + auc: [ + [1, 2], + [0.6, 0.8], + ], + precision: [ + [1, 2], + [0.6, 0.7], + ], + recall: [ + [1, 2], + [0.7, 0.2], + ], + f1: [ + [1, 2], + [0.6, 0.1], + ], + ks: [ + [1, 2], + [0.6, 0.7], + ], + }, + feature_importance: { + 'peer-0': 0.08, + 'peer-1': 0.3, + 'peer-2': 0.3, + 'peer-3': 0.1, + 'peer-4': 0.03, + age: 0.3, + overall_score: 0.3, + education: 0.1, + salary: 0.2, + height: 0.1, + weight: 0.02, + cars: 0.001, + + test_13: 0.7, + test_14: 0.6, + test_15: 0.5, + test_16: 0.4, + test_17: 0.3, + test_19: 0.2, + }, + confusion_matrix: { + tp: 30, + fp: 8, + fn: 22, + tn: 40, + }, +}; + +export const modelJobMetric2: ModelJobMetrics = { + train: { + acc: [ + [1, 2], + [0.6, 0.1], + ], + auc: [ + [1, 2], + [0.6, 0.2], + ], + precision: [ + [1, 2], + [0.6, 0.3], + ], + recall: [ + [1, 2], + [0.7, 0.4], + ], + f1: [ + [1, 2], + [0.6, 0.5], + ], + ks: [ + [1, 2], + [0.6, 0.4], + ], + }, + eval: { + acc: [ + [1, 2], + [0.6, 0.1], + ], + auc: [ + [1, 2], + [0.6, 0.2], + ], + precision: [ + [1, 2], + [0.6, 0.3], + ], + recall: [ + [1, 2], + [0.7, 0.4], + ], + f1: [ + [1, 2], + [0.6, 0.5], + ], + ks: [ + [1, 2], + [0.6, 0.4], + ], + }, + feature_importance: { + 'peer-0': 0.08, + 'peer-1': 0.3, + 'peer-2': 0.3, + 'peer-3': 0.1, + 'peer-4': 0.03, + age: 0.3, + overall_score: 0.3, + education: 0.1, + salary: 0.2, + height: 0.1, + weight: 0.02, + cars: 0.001, + + test_13: 0.7, + test_14: 0.6, + test_15: 0.5, + test_16: 0.4, + test_17: 0.3, + test_19: 0.2, + }, + confusion_matrix: { + tp: 22, + fp: 8, + fn: 22, + tn: 50, + }, +}; diff --git a/web_console_v2/client/src/services/mocks/v2/model_jobs/index.ts b/web_console_v2/client/src/services/mocks/v2/model_jobs/index.ts new file mode 100644 index 000000000..6bc989b69 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/model_jobs/index.ts @@ -0,0 +1,266 @@ +const workflowList = [ + { + id: 68420, + uuid: 'u8b4f360a5c2a4bb8b7c', + name: 'predict-job-3', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1], + job_ids: [1256], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'COMPLETED', + start_at: 1624521291, + stop_at: null, + created_at: 1624521217, + updated_at: 1624521291, + extra: '{"isTrainMode":true,"model.name":"测试模型名称1","model_group.name":"测试模型集名称1"}', + cron_config: '9 23 ? * 3', + }, + { + id: 68419, + uuid: 'u58da83ffb383465fa40', + name: 'wz-yyds1', + project_id: 23, + comment: null, + metric_is_public: false, + create_job_flags: [1], + job_ids: [], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'NEW', + start_at: null, + stop_at: null, + created_at: 1624456941, + updated_at: 1624456941, + extra: + '{"model.name":"测试模型名称1","model.desc":"测试模型描述1","model.dataset_id":1,"prediction.name":"测试预测任务名称1","prediction.desc":"测试预测任务描述1","prediction.dataset_id":1,"model.parent_job_name":"测试模型名称1","model.creator":"测试用户1","model.resource_template_type":"high","is_share_offline_prediction_result":false,"isTrainMode":false,"isPredictionMode":true}', + cron_config: '28 2 * * ?', + }, + { + id: 68418, + uuid: 'u33fb2a7366d746a4b4f', + name: 'wz-yyds', + project_id: 23, + comment: null, + metric_is_public: false, + create_job_flags: null, + job_ids: [], + forkable: false, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'NEW', + start_at: null, + stop_at: null, + created_at: 1624455371, + updated_at: 1624455371, + extra: + '{"model.name":"测试模型名称1","model.desc":"测试模型描述1","model.dataset_id":1,"prediction.name":"测试预测任务名称1","prediction.desc":"测试预测任务描述1","prediction.dataset_id":1,"model.parent_job_name":"测试模型名称1","model.creator":"测试用户1","model.resource_template_type":"high","is_share_offline_prediction_result":false,"isTrainMode":false,"isPredictionMode":true}', + cron_config: '', + }, + { + id: 68410, + uuid: 'u9629530fb99844178e4', + name: 'ot-server', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1], + job_ids: [1245], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'READY', + start_at: null, + stop_at: null, + created_at: 1624279235, + updated_at: 1624279235, + extra: + '{"model.name":"测试模型名称1","model.desc":"测试模型描述1","model.dataset_id":1,"prediction.name":"测试预测任务名称1","prediction.desc":"测试预测任务描述1","prediction.dataset_id":1,"model.parent_job_name":"测试模型名称1","model.creator":"测试用户1","model.resource_template_type":"high","is_share_offline_prediction_result":false,"isTrainMode":false,"isPredictionMode":true}', + cron_config: '', + }, + { + id: 68361, + uuid: 'ub81427e3f4634fc4874', + name: 'e2e-test-121f880060caf986-copy', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1, 1, 1], + job_ids: [1143, 1144, 1145], + forkable: true, + forked_from: 68360, + peer_create_job_flags: [1, 1, 1], + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'RUNNING', + start_at: 1624458779, + stop_at: null, + created_at: 1623917337, + updated_at: 1624458779, + extra: + '{"model.name":"测试模型名称1","model.desc":"测试模型描述1","model.dataset_id":1,"evaluation.name":"测试预测任务名称1","evaluation.desc":"测试预测任务描述1","evaluation.dataset_id":1,"model.parent_job_name":"测试模型名称1","model.creator":"测试用户1","model.resource_template_type":"high","is_share_offline_prediction_result":false,"isTrainMode":false,"isPredictionMode":false,"isEvaluationMode":true}', + cron_config: '', + }, + { + id: 68402, + uuid: 'u00b486d9a80e486fae9', + name: 'e2e-test-7f5830060cf2925', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1, 1, 1], + job_ids: [1234, 1235, 1236], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'INVALID', + start_at: 1624189314, + stop_at: 1624189330, + created_at: 1624189243, + updated_at: 1624276779, + extra: + '{"model.name":"测试模型名称1","model.desc":"测试模型描述1","model.dataset_id":1,"evaluation.name":"测试预测任务名称1","evaluation.desc":"测试预测任务描述1","evaluation.dataset_id":1,"model.parent_job_name":"测试模型名称1","model.creator":"测试用户1","model.resource_template_type":"high","is_share_offline_prediction_result":false,"isTrainMode":false,"isPredictionMode":false,"isEvaluationMode":true}', + cron_config: '', + }, + { + id: 68399, + uuid: 'u20ae5a88618e4b2c880', + name: 'e2e-test-1ee17d0060cf177c', + project_id: 31, + comment: null, + metric_is_public: false, + create_job_flags: [1, 1, 1], + job_ids: [1227, 1228, 1229], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'STOPPED', + start_at: 1624188305, + stop_at: 1624276386, + created_at: 1624184722, + updated_at: 1624276386, + extra: + '{"model.name":"测试模型名称1","model.desc":"测试模型描述1","model.dataset_id":1,"evaluation.name":"测试预测任务名称1","evaluation.desc":"测试预测任务描述1","evaluation.dataset_id":1,"model.parent_job_name":"测试模型名称1","model.creator":"测试用户1","model.resource_template_type":"high","is_share_offline_prediction_result":false,"isTrainMode":false,"isPredictionMode":false,"isEvaluationMode":true}', + cron_config: '', + }, + { + id: 68318, + uuid: 'u1354c0b76c5441968a3', + name: 'default-credit-13', + project_id: 14, + comment: null, + metric_is_public: false, + create_job_flags: [1, 1, 1, 1], + job_ids: [1044, 1045, 1046, 1047], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'FAILED', + start_at: 1623834767, + stop_at: null, + created_at: 1623834636, + updated_at: 1623836863, + extra: + '{"model.name":"测试模型名称1","model.desc":"测试模型描述1","model.dataset_id":1,"evaluation.name":"测试预测任务名称1","evaluation.desc":"测试预测任务描述1","evaluation.dataset_id":1,"model.parent_job_name":"测试模型名称1","model.creator":"测试用户1","model.resource_template_type":"high","is_share_offline_prediction_result":false,"isTrainMode":false,"isPredictionMode":false,"isEvaluationMode":true}', + cron_config: '', + is_local: false, + }, + { + id: 68499, + uuid: 'u58da83ffb383465fa40', + name: 'wz-yyds1', + project_id: 14, + comment: null, + metric_is_public: false, + create_job_flags: [1], + job_ids: [], + forkable: true, + forked_from: null, + peer_create_job_flags: null, + recur_type: 'NONE', + recur_at: null, + trigger_dataset: null, + last_triggered_batch: null, + state: 'NEW', + start_at: null, + stop_at: null, + created_at: 1624456941, + updated_at: 1624456941, + extra: + '{"model.name":"测试模型名称1","model.desc":"测试模型描述1","model.dataset_id":1,"prediction.name":"测试预测任务名称1","prediction.desc":"测试预测任务描述1","prediction.dataset_id":1,"model.parent_job_name":"测试模型名称1","model.creator":"测试用户1","model.resource_template_type":"high","is_share_offline_prediction_result":false,"isTrainMode":false,"isPredictionMode":true}', + cron_config: '', + }, +]; +const list = new Array(11).fill(undefined).map((_, index) => { + return { + id: index + 1, + name: '测试模型' + (index + 1), + version: index + 1, + type: null, + state: 1 + Math.floor(Math.random() * 6), + job_name: 'job测试模型' + (index + 1), + parent_id: null, + params: null, + metrics: null, + /** model set id */ + group_id: (index % 3) + 1, + extra: JSON.stringify({ + name: 'mock模型' + (index + 1), + comment: '我是说明', + creator: '测试员', + }), + detail_level: [], + workflow: workflowList[index % workflowList.length], + + created_at: 1608582145 + index * 10000, + updated_at: 1608582145 + index * 10000, + deleted_at: 1608582145 + index * 10000, + }; +}); + +const get = { + data: { + data: list, + }, + status: 200, +}; + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/models/index.ts b/web_console_v2/client/src/services/mocks/v2/models/index.ts new file mode 100644 index 000000000..e0acbd700 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/models/index.ts @@ -0,0 +1,109 @@ +import { Model } from 'typings/modelCenter'; + +const modelList: Model[] = [ + { + id: 7, + name: 'ucce42a49cbff4c4e930-nn-train-20210927-124432-3bd1a', + uuid: 'u3a0507d64bc442a2b66', + model_type: 'NN_MODEL', + model_path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/model_output/u3a0507d64bc442a2b66', + favorite: false, + comment: 'created_by ucce42a49cbff4c4e930-nn-train at 2021-09-27 12:44:32.419496+00:00', + group_id: null, + project_id: 14, + job_id: 42877, + model_job_id: null, + version: null, + created_at: 1632746672, + updated_at: 1632746672, + deleted_at: null, + workflow_id: 153952, + workflow_name: 'test-workflow', + job_name: 'test-job', + model_job_name: 'test-model-job', + federated_type: '', + }, + { + id: 8, + name: 'ucce42a49cbff4c4e930-nn-train-20210927-124437-3748d', + uuid: 'u195c5a39d1e44804a1a', + model_type: 'NN_MODEL', + model_path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/model_output/u195c5a39d1e44804a1a', + favorite: false, + comment: 'created_by ucce42a49cbff4c4e930-nn-train at 2021-09-27 12:44:37.030088+00:00', + group_id: null, + project_id: 14, + job_id: 42877, + model_job_id: null, + version: null, + created_at: 1632746677, + updated_at: 1632746677, + deleted_at: null, + workflow_id: 153952, + workflow_name: 'test-workflow', + job_name: 'test-job', + model_job_name: 'test-model-job', + federated_type: '', + }, + { + id: 86, + name: 'ud8b9cb500fc3435cb66-nn-train-20211009-070848-7c28f', + uuid: 'u70ca285687eb4d2fbb0', + model_type: 'NN_MODEL', + model_path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/model_output/u70ca285687eb4d2fbb0', + favorite: false, + comment: 'created_by ud8b9cb500fc3435cb66-nn-train at 2021-10-09 07:08:48.729397+00:00', + group_id: 1, + project_id: 14, + job_id: null, + model_job_id: 1, + version: null, + created_at: 1633763328, + updated_at: 1633763328, + deleted_at: null, + workflow_id: 153952, + workflow_name: 'test-workflow', + job_name: 'test-job', + model_job_name: 'test-model-job', + federated_type: '', + }, + { + id: 87, + name: 'ud8b9cb500fc3435cb66-nn-train-20211009-070856-6ca71', + uuid: 'u4b6e82b75e644c90907', + model_type: 'NN_MODEL', + model_path: + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/model_output/u4b6e82b75e644c90907', + favorite: false, + comment: 'created_by ud8b9cb500fc3435cb66-nn-train at 2021-10-09 07:08:56.799515+00:00', + group_id: 2, + project_id: 14, + job_id: null, + model_job_id: 2, + version: null, + created_at: 1633763336, + updated_at: 1633763336, + deleted_at: null, + workflow_id: 153952, + workflow_name: 'test-workflow', + job_name: 'test-job', + model_job_name: 'test-model-job', + federated_type: '', + }, +]; + +const get = { + data: { + data: modelList, + }, + status: 200, +}; + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/offline_prediction/index.ts b/web_console_v2/client/src/services/mocks/v2/offline_prediction/index.ts new file mode 100644 index 000000000..abdfdb4f8 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/offline_prediction/index.ts @@ -0,0 +1,31 @@ +const list = new Array(4).fill(undefined).map((_, index) => { + return { + id: index + 1, + name: 'mock工作流名称' + (index + 1), + state: Math.floor(Math.random() * 3), + dataset: 'test-dataset', + comment: '我是说明文案', + target: '模型1-V1', + extra: JSON.stringify({ + comment: '我是说明', + creator: '测试员', + }), + + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }; +}); + +const get = { + data: { + data: list, + }, + status: 200, +}; + +export const post = (config: any) => { + return { data: { data: config.data }, status: 200 }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/participant_candidates/index.ts b/web_console_v2/client/src/services/mocks/v2/participant_candidates/index.ts new file mode 100644 index 000000000..aa20b4a08 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/participant_candidates/index.ts @@ -0,0 +1,26 @@ +import { DomainName } from 'typings/participant'; + +const get = { + data: { + data: [ + { + domain_name: 'fl-aaa.com', + }, + { + domain_name: 'fl-alimama.com', + }, + { + domain_name: 'fl-aliyun-debug.com', + }, + { + domain_name: 'fl-aliyun-demo1.com', + }, + { + domain_name: 'fl-aliyun-test.com', + }, + ] as Array<DomainName>, + page_meta: {}, + }, + status: 200, +}; +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/participants/examples.ts b/web_console_v2/client/src/services/mocks/v2/participants/examples.ts new file mode 100644 index 000000000..12edca152 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/participants/examples.ts @@ -0,0 +1,68 @@ +import { Participant } from 'typings/participant'; + +export const participantList: Participant[] = [ + { + id: 1, + name: 'bytedance', + domain_name: 'fl-bytedance.com', + pure_domain_name: 'fl-byteddance', + host: '101.200.236.203', + port: 32443, + comment: 'migrate from projectconnection_test2', + extra: { + is_manual_configured: false, + }, + created_at: 1631519868, + updated_at: 1631519868, + num_project: 25, + type: 'PLATFORM', + }, + { + id: 2, + name: 'bytedance-test', + domain_name: 'fl-bytedance-test.com', + pure_domain_name: 'fl-bytedance-test', + host: 'xxx', + port: 443, + comment: 'migrate from projectxyx-test', + extra: { + is_manual_configured: false, + }, + created_at: 1631519868, + updated_at: 1631519868, + num_project: 1, + type: 'PLATFORM', + }, + { + id: 3, + name: 'Demo-test', + domain_name: 'fl-demo-test.com', + pure_domain_name: 'fl-demo-test', + host: 'xxx', + port: 443, + comment: 'migrate from projectDemo1', + extra: { + is_manual_configured: false, + }, + created_at: 1631519868, + updated_at: 1631785500, + num_project: 1, + type: 'PLATFORM', + }, + { + id: 4, + name: 'aliyun-test1', + domain_name: 'fl-aliyun-test.com', + pure_domain_name: 'fl-aliyun-test', + host: '11.11.11.11', + port: 443, + comment: null, + extra: { + is_manual_configured: false, + }, + created_at: 1632469805, + updated_at: 1632469805, + num_project: 1, + type: 'PLATFORM', + }, +]; diff --git a/web_console_v2/client/src/services/mocks/v2/participants/index.ts b/web_console_v2/client/src/services/mocks/v2/participants/index.ts new file mode 100644 index 000000000..f46678f14 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/participants/index.ts @@ -0,0 +1,10 @@ +import { participantList } from 'services/mocks/v2/participants/examples'; + +const get = { + data: { + data: participantList, + page_meta: {}, + }, + status: 200, +}; +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/project/__id__/participant_datasets/index.ts b/web_console_v2/client/src/services/mocks/v2/project/__id__/participant_datasets/index.ts new file mode 100644 index 000000000..71a5f3155 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/project/__id__/participant_datasets/index.ts @@ -0,0 +1,27 @@ +const get = { + data: { + data: [ + { + uuid: 'u26af7e549f30473382a', + project_id: 31, + name: 'e2e_test_dataset-20220411-043442', + participant_id: 1, + format: 'TABULAR', + file_size: 244662, + updated_at: 1649652778, + }, + { + uuid: 'u9e4cd8a42782465e93d', + project_id: 31, + name: 'e2e_test_dataset-20220411-023606', + participant_id: 1, + format: 'TABULAR', + file_size: 315206, + updated_at: 1649645562, + }, + ], + }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/serving_services/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/serving_services/__id__/index.ts new file mode 100644 index 000000000..0ae1733d3 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/serving_services/__id__/index.ts @@ -0,0 +1,73 @@ +import { AxiosRequestConfig } from 'axios'; + +import { ModelServing, ModelServingState, ModelServingInstanceState } from 'typings/modelServing'; + +const get = (config: AxiosRequestConfig) => ({ + data: { + data: { + id: Number(config._id! || 0) + 1, + project_id: 1, + name: 'mock模型serving' + Number(config._id! || 0) + 1, + comment: '备注', + instances: [ + { + name: 's-20210909112859-64nj6-79cff4cb57-696kr', + status: ModelServingInstanceState.AVAILABLE, + cpu: '90%', + memory: '60%', + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }, + { + name: 's-20210909112859-64nj6-79cff4cb57-999sr', + status: ModelServingInstanceState.UNAVAILABLE, + cpu: '20%', + memory: '30%', + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }, + ], + deployment_id: 1, + resource: { + cpu: '2000m', + memory: '10Gi', + replicas: 2, + }, + model_id: 1, + model_type: 'TREE_MODEL', + signature: JSON.stringify({ + inputs: { + examples: { + dtype: 'DT_STRING', + tensor_shape: { dim: [], unknown_rank: true }, + name: 'examples:0', + }, + }, + outputs: { + output: { + dtype: 'DT_FLOAT', + tensor_shape: { dim: [{ size: '2', name: '' }], unknown_rank: false }, + name: 'Softmax:0', + }, + }, + method_name: 'tensorflow/serving/predict', + }), + status: ModelServingState.AVAILABLE, + endpoint: 'https://api/v2/models/inference', + extra: '', + instance_num_status: '1/3', + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + } as ModelServing, + }, + + status: 200, +}); + +export const patch = { data: {}, status: 200 }; +export const DELETE = { data: {}, status: 200 }; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/serving_services/__id__/instances/__id__/log/index.ts b/web_console_v2/client/src/services/mocks/v2/serving_services/__id__/instances/__id__/log/index.ts new file mode 100644 index 000000000..241b6121c --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/serving_services/__id__/instances/__id__/log/index.ts @@ -0,0 +1,77 @@ +const get = () => ({ + data: { + data: [ + 'I0917 01:00:57.550960 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 404 Not Found in 1 milliseconds', + 'E0917 01:00:57.551087 51 event_handler.go:225] RegisterHandler name = u11c2a27793c443c0888-nn-train-job, role = Follower, err = flapps.fedlearner.k8s.io "u11c2a27793c443c0888-nn-train-job" not found', + 'I0917 01:00:57.549489 51 server.go:49] Register received, name = u11c2a27793c443c0888-nn-train-job, role = Follower', + 'E0917 01:01:02.525449 1796675 event_handler.go:225] RegisterHandler name = u11c2a27793c443c0888-nn-train-job, role = Follower, err = flapps.fedlearner.k8s.io "u11c2a27793c443c0888-nn-train-job" not found', + 'I0917 01:01:02.525343 1796675 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 404 Not Found in 1 milliseconds', + 'I0917 01:01:02.523937 1796675 server.go:49] Register received, name = u11c2a27793c443c0888-nn-train-job, role = Follower', + 'I0917 01:01:07.503363 1796675 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 404 Not Found in 1 milliseconds', + 'I0917 01:01:07.502001 1796675 server.go:49] Register received, name = u11c2a27793c443c0888-nn-train-job, role = Follower', + 'I0917 01:01:12.521849 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 404 Not Found in 1 milliseconds', + 'E0917 01:01:07.503472 1796675 event_handler.go:225] RegisterHandler name = u11c2a27793c443c0888-nn-train-job, role = Follower, err = flapps.fedlearner.k8s.io "u11c2a27793c443c0888-nn-train-job" not found', + 'I0917 01:01:12.520381 51 server.go:49] Register received, name = u11c2a27793c443c0888-nn-train-job, role = Follower', + 'E0917 01:01:12.521968 51 event_handler.go:225] RegisterHandler name = u11c2a27793c443c0888-nn-train-job, role = Follower, err = flapps.fedlearner.k8s.io "u11c2a27793c443c0888-nn-train-job" not found', + 'I0917 01:01:22.641118 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 404 Not Found in 1 milliseconds', + 'E0917 01:01:22.641228 51 event_handler.go:225] RegisterHandler name = u11c2a27793c443c0888-nn-train-job, role = Follower, err = flapps.fedlearner.k8s.io "u11c2a27793c443c0888-nn-train-job" not found', + 'I0917 01:01:22.639836 51 server.go:49] Register received, name = u11c2a27793c443c0888-nn-train-job, role = Follower', + 'I0917 01:01:17.559534 1796675 server.go:49] Register received, name = u11c2a27793c443c0888-nn-train-job, role = Follower', + 'I0917 01:01:17.560804 1796675 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 404 Not Found in 1 milliseconds', + 'E0917 01:01:17.560935 1796675 event_handler.go:225] RegisterHandler name = u11c2a27793c443c0888-nn-train-job, role = Follower, err = flapps.fedlearner.k8s.io "u11c2a27793c443c0888-nn-train-job" not found', + 'I0917 01:01:25.532047 51 controller.go:144] add new Pod u11c2a27793c443c0888-nn-train-job-leader-worker-0-e54b506a-b032-44f9-8919-247b781e8a24', + 'time="2021-09-17T01:01:25+08:00" level=info msg="Controller u11c2a27793c443c0888-nn-train-job created service u11c2a27793c443c0888-nn-train-job-leader-worker-0"', + 'I0917 01:01:25.545137 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + 'I0917 01:01:25.554689 51 round_trippers.go:443] PUT https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job/status 200 OK in 8 milliseconds', + 'I0917 01:01:30.721617 51 app_manager.go:229] sync new app, name = u11c2a27793c443c0888-nn-train-job', + 'I0917 01:01:25.475905 51 event.go:278] Event(v1.ObjectReference{Kind:"FLApp", Namespace:"fedlearner", Name:"u11c2a27793c443c0888-nn-train-job", UID:"3d5acdf9-9b45-470e-b8df-e6a70f28b8f1", APIVersion:"fedlearner.k8s.io/v1alpha1", ResourceVersion:"987444923", FieldPath:""}): type: \'Normal\' reason: \'SuccessfulCreatePod\' Created pod: u11c2a27793c443c0888-nn-train-job-leader-master-0-34e8d5d8-37ca-458a-8f41-11351b57bc01', + 'I0917 01:01:25.545524 51 status_updater.go:115] updating flapp u11c2a27793c443c0888-nn-train-job status, namespace = fedlearner, new state = FLStateNew', + 'I0917 01:01:30.757848 51 app_manager.go:446] sync bootstrapped app, name = u11c2a27793c443c0888-nn-train-job', + 'I0917 01:01:25.500923 51 controller.go:144] add new Pod u11c2a27793c443c0888-nn-train-job-leader-ps-0-10a6ed31-a2c2-4512-9c8e-19622ace3061', + 'I0917 01:01:30.726261 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + 'I0917 01:01:25.476417 51 controller.go:144] add new Pod u11c2a27793c443c0888-nn-train-job-leader-master-0-34e8d5d8-37ca-458a-8f41-11351b57bc01', + 'I0917 01:01:25.533899 51 event.go:278] Event(v1.ObjectReference{Kind:"FLApp", Namespace:"fedlearner", Name:"u11c2a27793c443c0888-nn-train-job", UID:"3d5acdf9-9b45-470e-b8df-e6a70f28b8f1", APIVersion:"fedlearner.k8s.io/v1alpha1", ResourceVersion:"987444923", FieldPath:""}): type: \'Normal\' reason: \'SuccessfulCreateService\' Created service: u11c2a27793c443c0888-nn-train-job-leader-master-0', + 'I0917 01:01:25.566288 51 status_updater.go:115] updating flapp u11c2a27793c443c0888-nn-train-job status, namespace = fedlearner, new state = FLStateNew', + 'I0917 01:01:25.581371 51 app_manager.go:229] sync new app, name = u11c2a27793c443c0888-nn-train-job', + 'I0917 01:01:30.735962 51 round_trippers.go:443] PUT https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job/status 200 OK in 9 milliseconds', + 'I0917 01:01:25.500580 51 pod_control.go:168] Controller u11c2a27793c443c0888-nn-train-job created pod u11c2a27793c443c0888-nn-train-job-leader-ps-0-10a6ed31-a2c2-4512-9c8e-19622ace3061', + 'I0917 01:01:25.565883 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + 'I0917 01:01:25.475794 51 pod_control.go:168] Controller u11c2a27793c443c0888-nn-train-job created pod u11c2a27793c443c0888-nn-train-job-leader-master-0-34e8d5d8-37ca-458a-8f41-11351b57bc01', + 'time="2021-09-17T01:01:25+08:00" level=info msg="Controller u11c2a27793c443c0888-nn-train-job created service u11c2a27793c443c0888-nn-train-job-leader-ps-0"', + 'I0917 01:01:25.531659 51 event.go:278] Event(v1.ObjectReference{Kind:"FLApp", Namespace:"fedlearner", Name:"u11c2a27793c443c0888-nn-train-job", UID:"3d5acdf9-9b45-470e-b8df-e6a70f28b8f1", APIVersion:"fedlearner.k8s.io/v1alpha1", ResourceVersion:"987444923", FieldPath:""}): type: \'Normal\' reason: \'SuccessfulCreatePod\' Created pod: u11c2a27793c443c0888-nn-train-job-leader-worker-0-e54b506a-b032-44f9-8919-247b781e8a24', + 'I0917 01:01:25.537846 51 event.go:278] Event(v1.ObjectReference{Kind:"FLApp", Namespace:"fedlearner", Name:"u11c2a27793c443c0888-nn-train-job", UID:"3d5acdf9-9b45-470e-b8df-e6a70f28b8f1", APIVersion:"fedlearner.k8s.io/v1alpha1", ResourceVersion:"987444923", FieldPath:""}): type: \'Normal\' reason: \'SuccessfulCreateService\' Created service: u11c2a27793c443c0888-nn-train-job-leader-worker-0', + 'I0917 01:01:30.757864 51 app_manager.go:456] sync bootstrapped leader app, name = u11c2a27793c443c0888-nn-train-job', + 'I0917 01:01:25.582708 51 round_trippers.go:443] PUT https://11.240.0.1:443/api/v1/namespaces/fedlearner/configmaps/u11c2a27793c443c0888-nn-train-job-leader-worker 200 OK in 1 milliseconds', + 'I0917 01:01:25.531550 51 pod_control.go:168] Controller u11c2a27793c443c0888-nn-train-job created pod u11c2a27793c443c0888-nn-train-job-leader-worker-0-e54b506a-b032-44f9-8919-247b781e8a24', + 'I0917 01:01:25.561049 51 app_manager.go:229] sync new app, name = u11c2a27793c443c0888-nn-train-job', + 'I0917 01:01:25.665674 51 app_manager.go:229] sync new app, name = u11c2a27793c443c0888-nn-train-job', + 'I0917 01:01:25.535827 51 event.go:278] Event(v1.ObjectReference{Kind:"FLApp", Namespace:"fedlearner", Name:"u11c2a27793c443c0888-nn-train-job", UID:"3d5acdf9-9b45-470e-b8df-e6a70f28b8f1", APIVersion:"fedlearner.k8s.io/v1alpha1", ResourceVersion:"987444923", FieldPath:""}): type: \'Normal\' reason: \'SuccessfulCreateService\' Created service: u11c2a27793c443c0888-nn-train-job-leader-ps-0', + 'I0917 01:01:25.670023 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + 'I0917 01:01:30.723174 51 round_trippers.go:443] PUT https://11.240.0.1:443/api/v1/namespaces/fedlearner/configmaps/u11c2a27793c443c0888-nn-train-job-leader-worker 200 OK in 1 milliseconds', + 'I0917 01:01:30.742418 51 status_updater.go:115] updating flapp u11c2a27793c443c0888-nn-train-job status, namespace = fedlearner, new state = FLStateBootstrapped', + 'I0917 01:01:30.757875 51 app_manager.go:465] still waiting for follower, name = u11c2a27793c443c0888-nn-train-job, rtype = Worker', + 'I0917 01:01:25.442789 51 app_manager.go:229] sync new app, name = u11c2a27793c443c0888-nn-train-job', + 'I0917 01:01:30.742349 51 app_manager.go:229] sync new app, name = u11c2a27793c443c0888-nn-train-job', + 'I0917 01:01:25.574974 51 round_trippers.go:443] PUT https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job/status 200 OK in 8 milliseconds', + 'I0917 01:01:30.751224 51 round_trippers.go:443] PUT https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job/status 200 OK in 8 milliseconds', + 'I0917 01:01:25.500690 51 event.go:278] Event(v1.ObjectReference{Kind:"FLApp", Namespace:"fedlearner", Name:"u11c2a27793c443c0888-nn-train-job", UID:"3d5acdf9-9b45-470e-b8df-e6a70f28b8f1", APIVersion:"fedlearner.k8s.io/v1alpha1", ResourceVersion:"987444923", FieldPath:""}): type: \'Normal\' reason: \'SuccessfulCreatePod\' Created pod: u11c2a27793c443c0888-nn-train-job-leader-ps-0-10a6ed31-a2c2-4512-9c8e-19622ace3061', + 'I0917 01:01:30.726664 51 status_updater.go:115] updating flapp u11c2a27793c443c0888-nn-train-job status, namespace = fedlearner, new state = FLStateNew', + 'time="2021-09-17T01:01:25+08:00" level=info msg="Controller u11c2a27793c443c0888-nn-train-job created service u11c2a27793c443c0888-nn-train-job-leader-master-0"', + 'I0917 01:01:25.585747 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + 'I0917 01:01:25.667049 51 round_trippers.go:443] PUT https://11.240.0.1:443/api/v1/namespaces/fedlearner/configmaps/u11c2a27793c443c0888-nn-train-job-leader-worker 200 OK in 1 milliseconds', + 'E0917 01:01:30.757881 51 controller.go:225] failed to sync FLApp fedlearner/u11c2a27793c443c0888-nn-train-job, err = still waiting for follower, name = u11c2a27793c443c0888-nn-train-job, rtype = Worker', + 'I0917 01:01:25.562610 51 round_trippers.go:443] PUT https://11.240.0.1:443/api/v1/namespaces/fedlearner/configmaps/u11c2a27793c443c0888-nn-train-job-leader-worker 200 OK in 1 milliseconds', + 'E0917 01:01:27.805979 1796675 event_handler.go:233] RegisterHandler leader is not bootstrapped, name = u11c2a27793c443c0888-nn-train-job, role = Follower, state = FLStateNew', + 'I0917 01:01:27.802569 1796675 server.go:49] Register received, name = u11c2a27793c443c0888-nn-train-job, role = Follower', + 'I0917 01:01:27.805507 1796675 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + 'I0917 01:01:33.123219 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + 'I0917 01:01:33.200786 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + 'I0917 01:01:33.226327 51 round_trippers.go:443] PUT https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job/status 200 OK in 9 milliseconds', + 'I0917 01:01:33.210341 51 round_trippers.go:443] PUT https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job/status 200 OK in 8 milliseconds', + 'I0917 01:01:33.242903 51 round_trippers.go:443] GET https://11.240.0.1:443/apis/fedlearner.k8s.io/v1alpha1/namespaces/fedlearner/flapps/u11c2a27793c443c0888-nn-train-job 200 OK in 2 milliseconds', + ], + }, + status: 200, +}); + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/serving_services/index.ts b/web_console_v2/client/src/services/mocks/v2/serving_services/index.ts new file mode 100644 index 000000000..fe71ea9bc --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/serving_services/index.ts @@ -0,0 +1,68 @@ +import { ResponseInfo } from 'typings/app'; +import { ModelServing, ModelServingState } from 'typings/modelServing'; + +const statusList = [ + ModelServingState.AVAILABLE, + ModelServingState.LOADING, + ModelServingState.UNLOADING, + ModelServingState.UNKNOWN, +]; + +const list: ModelServing[] = new Array(12).fill(undefined).map((_, index) => { + return { + id: index + 1, + project_id: 1, + name: 'mock模型serving' + index, + comment: '备注', + instances: [], + deployment_id: 1, + resource: { + cpu: '2000m', + memory: '10Gi', + replicas: 2, + }, + is_local: index % 2 === 0, + support_inference: index % 2 === 0, + model_id: 1, + model_type: 'TREE_MODEL', + signature: JSON.stringify({ + inputs: { + examples: { + dtype: 'DT_STRING', + tensor_shape: { dim: [], unknown_rank: true }, + name: 'examples:0', + }, + }, + outputs: { + output: { + dtype: 'DT_FLOAT', + tensor_shape: { dim: [{ size: '2', name: '' }], unknown_rank: false }, + name: 'Softmax:0', + }, + }, + method_name: 'tensorflow/serving/predict', + }), + status: statusList[index % 4], + endpoint: '', + extra: '', + instance_num_status: '1/3', + created_at: 1608582145, + updated_at: 1608582145, + deleted_at: 1608582145, + }; +}); + +const get = { + data: { + data: list, + page_meta: { + current_page: 1, + page_size: 10, + total_pages: 2, + total_items: 12, + }, + }, + status: 200, +} as ResponseInfo; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/settings/examples.ts b/web_console_v2/client/src/services/mocks/v2/settings/examples.ts new file mode 100644 index 000000000..cd059376f --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/settings/examples.ts @@ -0,0 +1,65 @@ +import { SystemVariable } from 'typings/settings'; + +export const fixed: SystemVariable = { + name: 'fixed', + value: 'testval', + value_type: 'STRING', + fixed: true, +}; + +export const noFixed: SystemVariable = { + name: 'noFixed', + value: 'test val', + value_type: 'STRING', + fixed: false, +}; + +export const int: SystemVariable = { + name: 'int', + value: 1, + value_type: 'INT', + fixed: false, +}; + +export const string: SystemVariable = { + name: 'string', + value: 'string val', + value_type: 'STRING', + fixed: false, +}; + +export const emptyObject: SystemVariable = { + name: 'emptyObject', + value: {}, + value_type: 'OBJECT', + fixed: false, +}; +export const object: SystemVariable = { + name: 'object', + value: { a: 1, b: 2, c: 3, d: { e: 4 } }, + value_type: 'OBJECT', + fixed: false, +}; +export const emptyList: SystemVariable = { + name: 'emptyList', + value: [], + value_type: 'LIST', + fixed: false, +}; +export const list: SystemVariable = { + name: 'list', + value: [{ a: 1 }, { a: 2 }, { a: 3 }], + value_type: 'LIST', + fixed: false, +}; + +export const variables: SystemVariable[] = [ + int, + string, + fixed, + noFixed, + emptyObject, + object, + emptyList, + list, +]; diff --git a/web_console_v2/client/src/services/mocks/v2/settings/system_info/index.ts b/web_console_v2/client/src/services/mocks/v2/settings/system_info/index.ts new file mode 100644 index 000000000..9993e245d --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/settings/system_info/index.ts @@ -0,0 +1,10 @@ +import { SystemInfo } from 'typings/settings'; + +const get = { + data: { + data: { name: '北京某某某公司', domain_name: 'fl-hahaha.com' } as SystemInfo, + status: 200, + }, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/versions/index.ts b/web_console_v2/client/src/services/mocks/v2/versions/index.ts new file mode 100644 index 000000000..f994c3693 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/versions/index.ts @@ -0,0 +1,12 @@ +const get = { + data: { + data: { + revision: '71ea30e1906e1fcab8ccd74ccc5fffbee40a9ea1', + branch_name: null, + version: '2.1.9.10', + pub_date: '2021-09-18 20:43:17', + }, + }, + status: 200, +}; +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/workflow_templates/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/workflow_templates/__id__/index.ts new file mode 100644 index 000000000..e833269d8 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/workflow_templates/__id__/index.ts @@ -0,0 +1,27 @@ +import { AxiosRequestConfig } from 'axios'; +import { + normalTemplate, + complexDepsTemplate, + xShapeTemplate, + localTpl, + withTypedValueTemplate, + noTypedValueTemplate, +} from '../examples'; + +const get = (config: AxiosRequestConfig) => { + const rets: Record<ID, any> = { + 1: normalTemplate, + 2: complexDepsTemplate, + 3: xShapeTemplate, + 4: localTpl, + 5: withTypedValueTemplate, + 6: noTypedValueTemplate, + }; + + return { + data: { data: rets[config._id!] }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/workflows/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/workflows/__id__/index.ts new file mode 100644 index 000000000..200002bf9 --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/workflows/__id__/index.ts @@ -0,0 +1,27 @@ +import { AxiosRequestConfig } from 'axios'; +import { pendingAcceptAndConfig, newlyCreated, completed } from '../examples'; + +const get = (config: AxiosRequestConfig) => { + const rets: Record<ID, any> = { + 1: pendingAcceptAndConfig, + 2: newlyCreated, + 3: completed, + }; + + return { + data: { data: rets[config._id!] }, + status: 200, + }; +}; + +export const put = { + data: { data: { success: true } }, + status: 200, +}; + +export const patch = { + data: { data: { success: true } }, + status: 200, +}; + +export default get; diff --git a/web_console_v2/client/src/services/mocks/v2/workflows/__id__/peer_workflows.ts b/web_console_v2/client/src/services/mocks/v2/workflows/__id__/peer_workflows.ts new file mode 100644 index 000000000..735cfbecc --- /dev/null +++ b/web_console_v2/client/src/services/mocks/v2/workflows/__id__/peer_workflows.ts @@ -0,0 +1,19 @@ +import { cloneDeep } from 'lodash-es'; +import { JobState } from 'typings/job'; +import { newlyCreated, withExecutionDetail } from '../examples'; + +const get = () => { + const modified = cloneDeep(withExecutionDetail); + + modified.jobs[1].state = JobState.COMPLETED; + modified.jobs[2].state = JobState.STARTED; + + return { + data: { + data: { peer_1: modified || newlyCreated }, + }, + status: 200, + }; +}; + +export default get; diff --git a/web_console_v2/client/src/services/modelCenter.ts b/web_console_v2/client/src/services/modelCenter.ts new file mode 100644 index 000000000..7bb5728d5 --- /dev/null +++ b/web_console_v2/client/src/services/modelCenter.ts @@ -0,0 +1,371 @@ +import request, { BASE_URL } from 'libs/request'; +import { APIResponse } from 'typings/app'; +import { + ModelJob, + ModelSet, + ModelSetCreatePayload, + ModelSetUpdatePayload, + Algorithm, + AlgorithmChangeLog, + FakeAlgorithm, + Model, + ModelJobQueryParams, + ModelJobType, + ModelJobGroup, + ModelJobGroupCreatePayload, + ModelJobGroupUpdatePayload, + ModelJobMetrics, + ModelJobPatchFormData, + PeerModelJobGroupUpdatePayload, + ModelJobQueryParams_new, + ModelJobDefinitionQueryParams, + ModelJobDefinitionResult, + ModelJobTrainCreateForm, + ModelJobGroupCreateForm, +} from 'typings/modelCenter'; +import { formatExtra } from 'shared/modelCenter'; + +export function fetchModelSetList(params?: { keyword?: string }): Promise<{ data: ModelSet[] }> { + return request('/v2/model_groups', { params, removeFalsy: true, snake_case: true }); +} +export function createModelSet(payload: ModelSetCreatePayload): Promise<{ data: ModelSet }> { + return request.post('/v2/model_groups', payload); +} +export function updateModelSet( + id: ID, + payload: ModelSetUpdatePayload, +): Promise<{ data: ModelSet }> { + return request.patch(`/v2/model_groups/${id}`, payload); +} +export function deleteModelSet(id: ID) { + return request.delete(`/v2/model_groups/${id}`); +} + +export function fetchModelList( + project_id: ID, + params?: { + group_id?: ID; + algorithm_type?: 'NN_HORIZONTAL' | 'TREE_VERTICAL' | 'NN_VERTICAL'; + keyword?: string; + }, +): Promise<{ data: Model[] }> { + return request(`/v2/projects/${project_id}/models`, { + params, + removeFalsy: true, + snake_case: true, + }); +} + +export function fetchModelDetail_new(project_id: ID, model_id: ID): Promise<{ data: Model }> { + return request(`/v2/projects/${project_id}/models/${model_id}`); +} + +export function updateModel(project_id: ID, id: ID, payload: Partial<Model>) { + return request.patch(`/v2/projects/${project_id}/models/${id}`, payload); +} +export function deleteModel(project_id: ID, id: ID) { + return request.delete(`/v2/projects/${project_id}/models/${id}`); +} + +export function fetchModelJobList(params?: { + project_id?: ID; + group_id?: ID; + types?: ModelJobType[]; +}): Promise<{ data: ModelJob[] }> { + return request('/v2/model_jobs', { params, removeFalsy: true, snake_case: true }); +} +export function fetchModelJobDetail(id: ID, params?: any): Promise<{ data: ModelJob }> { + return request(`/v2/model_jobs/${id}`, { params }); +} +export function updateModelJob(projectId: ID, id: ID, payload: Partial<ModelJob>) { + return request.patch(`/v2/projects/${projectId}/model_jobs/${id}`, payload); +} +export function deleteModelJob(id: ID) { + return request.delete(`/v2/model_jobs/${id}`); +} +export function stopModelJob(projectId: ID, modelJobId: ID) { + return request.post(`/v2/projects/${projectId}/model_jobs/${modelJobId}:stop`); +} + +export function fetchFavouriteModelList(params?: {}): Promise<{ data: ModelJob[] }> { + return request('/v2/models/favourite', { params, removeFalsy: true, snake_case: true }); +} + +export function fetchEvaluationList(params?: ModelJobQueryParams): Promise<{ data: ModelJob[] }> { + const types: ModelJobType[] = ['TREE_EVALUATION', 'NN_EVALUATION', 'EVALUATION']; + + return request('/v2/model_jobs', { + params: { + ...params, + types, + }, + removeFalsy: true, + snake_case: true, + }).then((resp) => { + resp.data = resp.data.map((item: ModelJob) => { + item.workflow = formatExtra(item.workflow); + return formatExtra(item); + }); + return resp; + }); +} + +export function fetchCompareModelReportList(params?: {}): Promise<{ data: ModelSet[] }> { + return request('/v2/model_groups', { params, removeFalsy: true, snake_case: true }).then( + (resp) => { + resp.data = resp.data.map((item: ModelSet) => { + return formatExtra(item); + }); + return resp; + }, + ); +} +export function fetchCompareModelReportDetail(id: ID) { + return request(`/v2/model_groups/${id}`); +} + +export function fetchOfflinePredictionList( + params?: ModelJobQueryParams, +): Promise<{ data: ModelJob[] }> { + const types: ModelJobType[] = ['TREE_PREDICTION', 'NN_PREDICTION', 'PREDICTION']; + + return request('/v2/model_jobs', { + params: { + ...params, + types, + }, + removeFalsy: true, + snake_case: true, + }).then((resp) => { + resp.data = resp.data.map((item: ModelJob) => { + item.workflow = formatExtra(item.workflow); + return formatExtra(item); + }); + return resp; + }); +} + +export function fetchMyAlgorithm(params?: { keyword?: string }): Promise<{ data: Algorithm[] }> { + return request('/v2/algorithm', { params, removeFalsy: true, snake_case: true }); +} +export function fetchFakeAlgorithmList(params?: { + keyword?: string; +}): Promise<{ data: FakeAlgorithm[] }> { + return request('/v2/fake_algorithms', { params, removeFalsy: true, snake_case: true }); +} +export function fetchBuiltInAlgorithm(params?: { + keyword?: string; +}): Promise<{ data: Algorithm[] }> { + return request('/v2/algorithm/built-in', { params, removeFalsy: true, snake_case: true }); +} +export function fetchMyAlgorithmDetail(id: ID, params?: any): Promise<{ data: Algorithm }> { + return request(`/v2/algorithm/${id}`, { params }); +} + +export function fetchAlgorithmChangeLog( + id: ID, + params?: any, +): Promise<{ data: AlgorithmChangeLog[] }> { + return request(`/v2/algorithm/change_log/${id}`, { params }); +} + +export function getModelJobDownloadHref(projectId: ID, modelJobId: ID): string { + return `/v2/projects/${projectId}/model_jobs/${modelJobId}/results`; +} +export function getFullModelJobDownloadHref(projectId: ID, modelJobId: ID): string { + return `${window.location.origin}${BASE_URL}/v2/projects/${projectId}/model_jobs/${modelJobId}/results`; +} + +export function fetchModelJobGroupList( + projectId: ID, + params?: { + keyword?: string; + page?: number; + pageSize?: number; + filter?: string; + configured?: boolean; + }, +): APIResponse<ModelJobGroup[]> { + return request(`/v2/projects/${projectId}/model_job_groups`, { + params, + removeFalsy: true, + snake_case: true, + }); +} +export function fetchModelJobGroupDetail( + projectId: ID, + modelJobGroupId: ID, +): Promise<{ data: ModelJobGroup }> { + return request(`/v2/projects/${projectId}/model_job_groups/${modelJobGroupId}`); +} +export function fetchPeerModelJobGroupDetail( + projectId: ID, + modelJobGroupId: ID, + participantId: ID, +): Promise<{ data: ModelJobGroup }> { + return request( + `/v2/projects/${projectId}/model_job_groups/${modelJobGroupId}/peers/${participantId}`, + ); +} + +export function createModelJobGroup( + projectId: ID, + payload: ModelJobGroupCreatePayload, +): Promise<{ data: ModelJobGroup }> { + return request.post(`/v2/projects/${projectId}/model_job_groups`, payload); +} + +export function updateModelJobGroup( + projectId: ID, + modelJobGroupId: ID, + payload: ModelJobGroupUpdatePayload, +): Promise<{ data: ModelJobGroup }> { + return request.put(`/v2/projects/${projectId}/model_job_groups/${modelJobGroupId}`, payload); +} + +export function deleteModelJobGroup( + projectId: ID, + modelJobGroupId: ID, + payload?: any, +): Promise<{ data: ModelJobGroup }> { + return request.delete(`/v2/projects/${projectId}/model_job_groups/${modelJobGroupId}`, payload); +} + +export function updatePeerModelJobGroup( + projectId: ID, + modelJobGroupId: ID, + participantId: ID, + payload: PeerModelJobGroupUpdatePayload, +): Promise<{ data: ModelJobGroup }> { + return request.patch( + `/v2/projects/${projectId}/model_job_groups/${modelJobGroupId}/peers/${participantId}`, + payload, + ); +} + +export function launchModelJobGroup( + projectId: ID, + modelJobGroupId: ID, + payload: any = {}, // For auto set request header 'Content-Type': 'application/json' +): Promise<{ data: ModelJobGroup }> { + return request.post( + `/v2/projects/${projectId}/model_job_groups/${modelJobGroupId}:launch`, + payload, + ); +} + +export function authorizeModelJobGroup( + projectId: ID, + modelJobGroupId: ID, + authorized: boolean, +): Promise<any> { + return request.put(`/v2/projects/${projectId}/model_job_groups/${modelJobGroupId}`, { + authorized, + }); +} + +export function fetchModelJobDefinition( + params: ModelJobDefinitionQueryParams, +): Promise<{ data: ModelJobDefinitionResult }> { + return request(`/v2/model_job_definitions`, { params }); +} + +/** + * + * new service functions + * + */ + +export function fetchModelJobList_new( + project_id: ID, + params: ModelJobQueryParams_new, +): APIResponse<ModelJob[]> { + return request(`/v2/projects/${project_id}/model_jobs`, { params }); +} + +export function fetchModelJob_new(project_id: ID, job_id: ID): Promise<{ data: ModelJob }> { + return request(`/v2/projects/${project_id}/model_jobs/${job_id}`); +} + +export function fetchModelJobDetail_new( + project_id: ID, + model_job_id: ID, + params?: any, +): Promise<{ data: ModelJob }> { + return request(`/v2/projects/${project_id}/model_jobs/${model_job_id}`, { params }); +} +export function fetchModelJobMetrics_new( + project_id: ID, + model_job_id: ID, + params?: any, +): Promise<{ data: ModelJobMetrics }> { + return request(`/v2/projects/${project_id}/model_jobs/${model_job_id}/metrics`, { params }); +} +export function fetchPeerModelJobDetail_new( + project_id: ID, + model_job_id: ID, + participant_id: ID, +): Promise<{ data: ModelJob }> { + return request(`/v2/projects/${project_id}/model_jobs/${model_job_id}/peers/${participant_id}`); +} +export function fetchPeerModelJobMetrics_new( + project_id: ID, + model_job_id: ID, + participant_id: ID, +): Promise<{ data: ModelJobMetrics }> { + return request( + `/v2/projects/${project_id}/model_jobs/${model_job_id}/peers/${participant_id}/metrics`, + ); +} + +export function createModelJob_new( + project_id: ID, + data: ModelJobPatchFormData, +): Promise<{ data: ModelJob }> { + return request.post(`/v2/projects/${project_id}/model_jobs`, data); +} + +export function updateModelJob_new(project_id: ID, job_id: ID, data: ModelJobPatchFormData) { + return request.put(`/v2/projects/${project_id}/model_jobs/${job_id}`, data); +} + +export function stopJob_new(project_id: ID, job_id: ID): Promise<any> { + return request.post(`/v2/projects/${project_id}/model_jobs/${job_id}:stop`); +} + +export function deleteJob_new(project_id: ID, job_id: ID): Promise<any> { + return request.delete(`/v2/projects/${project_id}/model_jobs/${job_id}`); +} + +export function fetchModelJobMetries_new(project_id: ID, job_id: ID) { + return request(`/v2/projects/${project_id}/model_jobs/${job_id}/metrics`); +} + +export function fetchModelJobResult_new(project_id: ID, job_id: ID) { + return request(`/v2/projects/${project_id}/model_jobs/${job_id}/result`); +} + +// 中心化 + +export function createModeJobGroupV2(project_id: ID, data: ModelJobGroupCreateForm) { + return request.post(`/v2/projects/${project_id}/model_job_groups_v2`, data); +} +export function createModelJob( + project_id: ID, + data: ModelJobTrainCreateForm, +): Promise<{ data: ModelJob }> { + return request.post(`/v2/projects/${project_id}/model_jobs`, data); +} + +export function stopAutoUpdateModelJob(project_id: ID, model_group_id: ID) { + return request.post( + `/v2/projects/${project_id}/model_job_groups/${model_group_id}:stop_auto_update`, + {}, // For auto set request header 'Content-Type': 'application/json' + ); +} + +export function fetchAutoUpdateModelJobDetail(project_id: ID, model_group_id: ID) { + return request( + `/v2/projects/${project_id}/model_job_groups/${model_group_id}/next_auto_update_model_job`, + ); +} diff --git a/web_console_v2/client/src/services/modelServing.ts b/web_console_v2/client/src/services/modelServing.ts new file mode 100644 index 000000000..9a341f3c9 --- /dev/null +++ b/web_console_v2/client/src/services/modelServing.ts @@ -0,0 +1,109 @@ +import request from 'libs/request'; + +import { APIResponse, PageQueryParams } from 'typings/app'; + +import { ModelServing, ModelServingInstance, ModelServingQueryParams } from 'typings/modelServing'; + +export function fetchModelServingList( + params?: ModelServingQueryParams, +): APIResponse<ModelServing[]> { + return request('/v2/serving_services', { params, removeFalsy: true, snake_case: true }); +} +export function fetchModelServingDetail( + modelServingId: ID, + params?: ModelServingQueryParams, +): APIResponse<ModelServing> { + return request(`/v2/serving_services/${modelServingId}`, { params }); +} + +export function fetchModelServingInstanceList( + modelServingId: ID, + params: PageQueryParams, +): APIResponse<ModelServingInstance[]> { + return request(`/v2/serving_services/${modelServingId}/instances`, { + params, + removeFalsy: true, + snake_case: true, + }); +} + +export function fetchModelServingInstanceLog( + modelServingId: ID, + instanceName: string, + params?: { tail_lines: number }, +): APIResponse<string[]> { + return request(`/v2/serving_services/${modelServingId}/instances/${instanceName}/log`, { + params, + removeFalsy: true, + snake_case: true, + }); +} +export function createModelServing(modelId: ID, payload: any): APIResponse<ModelServing> { + return request.post(`/v2/models/${modelId}/serving_services`, payload); +} +export function updateModelServing(modelServingId: ID, payload: any): APIResponse<ModelServing> { + return request.patch(`/v2/serving_services/${modelServingId}`, payload); +} +export function deleteModelServing(modelServingId: ID) { + return request.delete(`/v2/serving_services/${modelServingId}`); +} + +/** + * + * new service functions + * old and new service functions will coexist for a period of time + * + */ + +export function fetchModelServingList_new( + projectId: ID, + params?: ModelServingQueryParams, +): APIResponse<ModelServing[]> { + return request(`/v2/projects/${projectId}/serving_services`, { + params, + removeFalsy: true, + snake_case: true, + }); +} + +export function fetchModelServingDetail_new( + projectId: ID, + modelServingId: ID, + params?: ModelServingQueryParams, +): APIResponse<ModelServing> { + return request(`/v2/projects/${projectId}/serving_services/${modelServingId}`, { params }); +} + +export function createModelServing_new(projectId: ID, payload: any): APIResponse<ModelServing> { + return request.post(`/v2/projects/${projectId}/serving_services`, payload); +} + +export function updateModelServing_new( + projectId: ID, + modelServingId: ID, + payload: any, +): APIResponse<ModelServing> { + return request.patch(`/v2/projects/${projectId}/serving_services/${modelServingId}`, payload); +} +export function deleteModelServing_new(projectId: ID, modelServingId: ID) { + return request.delete(`/v2/projects/${projectId}/serving_services/${modelServingId}`); +} + +export function fetchModelServingInstanceLog_new( + projectId: ID, + modelServingId: ID, + instanceName: string, + params?: { tail_lines: number }, +): APIResponse<string[]> { + return request( + `/v2/projects/${projectId}/serving_services/${modelServingId}/instances/${instanceName}/log`, + { + params, + removeFalsy: true, + snake_case: true, + }, + ); +} +export function fetchUserTypeInfo(projectId: ID): APIResponse { + return request(`/v2/projects/${projectId}/serving_services/remote_platforms`); +} diff --git a/web_console_v2/client/src/services/operation.ts b/web_console_v2/client/src/services/operation.ts new file mode 100644 index 000000000..f8586cfaa --- /dev/null +++ b/web_console_v2/client/src/services/operation.ts @@ -0,0 +1,26 @@ +import request from 'libs/request'; +import { APIResponse } from 'typings/app'; +import { JobInfo, JobGroupFetchPayload, JobItem, Dashboard } from 'typings/operation'; +import { DatasetForceState } from '../typings/dataset'; + +export function fetchOperationList(payload: Partial<JobGroupFetchPayload>): APIResponse<JobItem[]> { + return request.post('/v2/e2e_jobs:initiate', payload); +} + +export function fetchOperationDetail(params?: { job_name: string }): Promise<{ data: JobInfo }> { + return request(`/v2/e2e_jobs/${params?.job_name}`); +} + +export function fetchDashboardList(): Promise<{ data: Dashboard[] }> { + return request('/v2/dashboards'); +} + +export function datasetFix(params: { + datasetId: ID; + force?: DatasetForceState; +}): Promise<{ data: any }> { + const { datasetId, force } = params; + return request.post(`v2/datasets/${datasetId}:state_fix`, { + force, + }); +} diff --git a/web_console_v2/client/src/services/participant.ts b/web_console_v2/client/src/services/participant.ts new file mode 100644 index 000000000..2c23686d6 --- /dev/null +++ b/web_console_v2/client/src/services/participant.ts @@ -0,0 +1,46 @@ +import request from 'libs/request'; +import { + CreateParticipantPayload, + Participant, + UpdateParticipantPayload, + Version, + DomainName, +} from 'typings/participant'; +import { APIResponse } from 'typings/app'; + +export function fetchParticipants(): Promise<{ data: Participant[] }> { + return request.get('/v2/participants'); +} + +export function createParticipant(payload: CreateParticipantPayload): Promise<Participant> { + return request.post('/v2/participants', payload); +} + +export function updateParticipant( + id: ID, + payload: UpdateParticipantPayload, +): Promise<{ data: Participant }> { + return request.patch(`/v2/participants/${id}`, payload); +} + +export function getParticipantDetailById(id: ID): Promise<{ data: Participant }> { + return request.get(`/v2/participants/${id}`); +} + +export function checkParticipantConnection( + id: ID, +): Promise<{ data: { success: boolean; message: string; application_version: Version } }> { + return request.get(`/v2/participants/${id}/connection_checks`); +} + +export function getParticipantByProjectId(id: ID): Promise<{ data: Participant[] }> { + return request.get(`/v2/projects/${id}/participants`); +} + +export function deleteParticipant(id: ID): Promise<{ id: ID }> { + return request.delete(`/v2/participants/${id}`); +} + +export function fetchDomainNameList(): APIResponse<DomainName[]> { + return request.get('/v2/participant_candidates'); +} diff --git a/web_console_v2/client/src/services/trustedCenter.ts b/web_console_v2/client/src/services/trustedCenter.ts new file mode 100644 index 000000000..5fcbeea31 --- /dev/null +++ b/web_console_v2/client/src/services/trustedCenter.ts @@ -0,0 +1,80 @@ +import request from 'libs/request'; +import { APIResponse } from 'typings/app'; +import { + AuthStatus, + NotificationItem, + TrustedJob, + TrustedJobGroup, + TrustedJobGroupItem, + TrustedJobGroupPayload, + TrustedJobListItem, + TrustedJobParamType, +} from 'typings/trustedCenter'; + +export function fetchTrustedJobGroupList( + projectId: ID, + params?: { + filter?: string; + page?: number; + pageSize?: number; + }, +): APIResponse<TrustedJobGroupItem[]> { + return request(`/v2/projects/${projectId}/trusted_job_groups`, { params }); +} + +export function fetchTrustedJobGroupById( + projectId: ID, + id: ID, +): Promise<{ data: TrustedJobGroup }> { + return request(`/v2/projects/${projectId}/trusted_job_groups/${id}`); +} + +export function createTrustedJobGroup(projectId: ID, payload: TrustedJobGroupPayload) { + return request.post(`/v2/projects/${projectId}/trusted_job_groups`, payload); +} + +export function updateTrustedJobGroup(projectId: ID, id: ID, payload: TrustedJobGroupPayload) { + return request.put(`/v2/projects/${projectId}/trusted_job_groups/${id}`, payload); +} + +export function deleteTrustedJobGroup(projectId: ID, id: ID) { + return request.delete(`/v2/projects/${projectId}/trusted_job_groups/${id}`); +} + +export function launchTrustedJobGroup(projectId: ID, id: ID, payload: { comment: string }) { + return request.post(`/v2/projects/${projectId}/trusted_job_groups/${id}:launch`, payload); +} + +export function fetchTrustedJobList( + projectId: ID, + params: { + trusted_job_group_id: ID; + type?: TrustedJobParamType; + }, +): APIResponse<TrustedJobListItem[]> { + return request(`/v2/projects/${projectId}/trusted_jobs`, { params }); +} + +export function fetchTrustedJob(projectId: ID, id: ID): Promise<{ data: TrustedJob }> { + return request(`/v2/projects/${projectId}/trusted_jobs/${id}`); +} + +export function updateTrustedJob( + projectId: ID, + id: ID, + payload: { comment: string; auth_status?: AuthStatus }, +) { + return request.put(`/v2/projects/${projectId}/trusted_jobs/${id}`, payload); +} + +export function exportTrustedJobResult(projectId: ID, id: ID) { + return request.post(`/v2/projects/${projectId}/trusted_jobs/${id}:export`); +} + +export function stopTrustedJob(projectId: ID, id: ID) { + return request.post(`/v2/projects/${projectId}/trusted_jobs/${id}:stop`); +} + +export function fetchTrustedNotifications(projectId: ID): APIResponse<NotificationItem[]> { + return request(`/v2/projects/${projectId}/trusted_notifications`); +} diff --git a/web_console_v2/client/src/shared/constants.ts b/web_console_v2/client/src/shared/constants.ts new file mode 100644 index 000000000..68afe5624 --- /dev/null +++ b/web_console_v2/client/src/shared/constants.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ + +export const TIME_INTERVAL = { + /** 1.5 min */ + LIST: 90 * 1000, + /** 10 min */ + FLAG: 10 * 60 * 1000, + /** 10 min */ + CONNECTION_CHECK: 10 * 60 * 1000, + /** 10s */ + EXPORT_STATE_CHECK: 10 * 1000, +}; + +export const CONSTANTS = { + TIME_INTERVAL, + DELETED_DATASET_NAME: 'deleted', + TEMPLATE_LIGHT_CLIENT_DATA_JOIN: 'sys-preset-light-psi-data-join', + EMPTY_PLACEHOLDER: '-', +}; + +export const TABLE_COL_WIDTH = { + NAME: 200, + ID: 100, + COORDINATOR: 200, + TIME: 150, + OPERATION: 200, + THIN: 100, + NORMAL: 150, + BIG_WIDTH: 200, +}; + +export default CONSTANTS; diff --git a/web_console_v2/client/src/shared/file.test.ts b/web_console_v2/client/src/shared/file.test.ts new file mode 100644 index 000000000..a356cf107 --- /dev/null +++ b/web_console_v2/client/src/shared/file.test.ts @@ -0,0 +1,149 @@ +import { + readAsJSONFromFile, + readAsBinaryStringFromFile, + readAsTextFromFile, + humanFileSize, + getFileInfoByFilePath, + buildRelativePath, +} from './file'; + +const testImageFile = new File(['xyz'], 'test.png', { type: 'image/png' }); +const testJSONFile = new File([JSON.stringify({ a: 1, b: 2 })], 'test.json', { + type: 'application/json', +}); + +describe('readAsJSONFromFile', () => { + it('image file', async () => { + return expect(readAsJSONFromFile(testImageFile)).rejects.toThrow('Unexpected token x in JSON'); + }); + it('json file', async () => { + const value = await readAsJSONFromFile(testJSONFile); + expect(value).toEqual({ + a: 1, + b: 2, + }); + }); +}); + +describe('readAsBinaryStringFromFile', () => { + it('image file', async () => { + const value = await readAsBinaryStringFromFile(testImageFile); + expect(value).toBe(btoa('xyz')); + }); + it('json file', async () => { + const value = await readAsBinaryStringFromFile(testJSONFile); + expect(value).toBe(btoa(JSON.stringify({ a: 1, b: 2 }))); + }); +}); + +describe('readAsTextFromFile', () => { + it('image file', async () => { + const value = await readAsTextFromFile(testImageFile); + expect(value).toBe('xyz'); + }); + it('json file', async () => { + const value = await readAsTextFromFile(testJSONFile); + expect(value).toBe(JSON.stringify({ a: 1, b: 2 })); + }); +}); + +it('humanFileSize', async () => { + expect(humanFileSize(100)).toBe('100 B'); + expect(humanFileSize(1024)).toBe('1.0 KB'); + expect(humanFileSize(1024 * 1000)).toBe('1.0 MB'); + expect(humanFileSize(1024 * 1000 * 1000)).toBe('1.0 GB'); + expect(humanFileSize(1024 * 1000 * 1000 * 1000)).toBe('1.0 TB'); + expect(humanFileSize(1024 * 1000 * 1000 * 1000 * 1000)).toBe('1.0 PB'); + + expect(humanFileSize(100, false)).toBe('100 B'); + expect(humanFileSize(1024, false)).toBe('1.0 KiB'); + expect(humanFileSize(1024 * 1000, false)).toBe('1000.0 KiB'); + expect(humanFileSize(1024 * 1000 * 1000, false)).toBe('976.6 MiB'); + expect(humanFileSize(1024 * 1000 * 1000 * 1000, false)).toBe('953.7 GiB'); + expect(humanFileSize(1024 * 1000 * 1000 * 1000 * 1000, false)).toBe('931.3 TiB'); +}); + +it('getFileInfoByFilePath', () => { + expect(getFileInfoByFilePath('main.js')).toEqual({ + parentPath: '', + fileName: 'main.js', + fileExt: 'js', + }); + expect(getFileInfoByFilePath('leader/main.js')).toEqual({ + parentPath: 'leader', + fileName: 'main.js', + fileExt: 'js', + }); + expect(getFileInfoByFilePath('leader/folder/main.js')).toEqual({ + parentPath: 'leader/folder', + fileName: 'main.js', + fileExt: 'js', + }); + expect(getFileInfoByFilePath('main')).toEqual({ + parentPath: '', + fileName: 'main', + fileExt: '', + }); + expect(getFileInfoByFilePath('')).toEqual({ + parentPath: '', + fileName: '', + fileExt: '', + }); +}); +it('buildRelativePath', () => { + expect( + buildRelativePath({ + path: 'folder', + filename: 'a.js', + }), + ).toBe('folder/a.js'); + expect( + buildRelativePath({ + path: '', + filename: 'a.js', + }), + ).toBe('a.js'); + expect( + buildRelativePath({ + path: '.', + filename: 'a.js', + }), + ).toBe('a.js'); + expect( + buildRelativePath({ + path: 'a/b/c/d', + filename: 'e.js', + }), + ).toBe('a/b/c/d/e.js'); + + expect( + buildRelativePath({ + path: 'a/b/c/d/', + filename: 'e.js', + }), + ).toBe('a/b/c/d/e.js'); + expect( + buildRelativePath({ + path: 'a/b/c/d//////', + filename: 'e.js', + }), + ).toBe('a/b/c/d/e.js'); + expect( + buildRelativePath({ + path: '//////', + filename: 'e.js', + }), + ).toBe('e.js'); + expect( + buildRelativePath({ + path: './/////', + filename: 'e.js', + }), + ).toBe('e.js'); + expect( + buildRelativePath({ + path: 'a//////', + filename: 'e.js', + }), + ).toBe('a/e.js'); +}); diff --git a/web_console_v2/client/src/shared/filter.test.ts b/web_console_v2/client/src/shared/filter.test.ts new file mode 100644 index 000000000..1a2162bca --- /dev/null +++ b/web_console_v2/client/src/shared/filter.test.ts @@ -0,0 +1,116 @@ +import { + constructExpressionTree, + serializeFilterExpression, + confirmValue, + operationMap, + expression2Filter, +} from './filter'; +import { FilterExpressionKind, FilterOp } from '../typings/filter'; + +describe('expression serialization', () => { + it('cnstruct expression tree', () => { + const nodes = [ + { + field: 'state', + op: FilterOp.EQUAL, + string_value: 'RUNNING', + }, + { + field: 'favour', + op: FilterOp.EQUAL, + bool_value: true, + }, + ]; + constructExpressionTree(nodes); + }); + + it('serialization', () => { + const expression = { + kind: FilterExpressionKind.AND, + exps: [ + { + kind: FilterExpressionKind.BASIC, + simple_exp: { + field: 'state', + op: FilterOp.EQUAL, + string_value: 'RUNNING', + }, + }, + { + kind: FilterExpressionKind.BASIC, + simple_exp: { + field: 'favour', + op: FilterOp.EQUAL, + bool_value: true, + }, + }, + { + kind: FilterExpressionKind.AND, + exps: [ + { + kind: FilterExpressionKind.BASIC, + simple_exp: { + field: 'state', + op: FilterOp.EQUAL, + string_value: 'RUNNING', + }, + }, + { + kind: FilterExpressionKind.BASIC, + simple_exp: { + field: 'count', + op: FilterOp.EQUAL, + num_value: 10, + }, + }, + ], + }, + ], + }; + serializeFilterExpression(expression); + }); + + const expressionList = [ + { + kind: FilterExpressionKind.BASIC, + simple_exp: { + field: 'state', + op: FilterOp.EQUAL, + string_value: 'RUNNING', + }, + }, + { + kind: FilterExpressionKind.BASIC, + simple_exp: { + field: 'count', + op: FilterOp.EQUAL, + num_value: 10, + }, + }, + { + kind: FilterExpressionKind.BASIC, + simple_exp: { + field: 'favour', + op: FilterOp.EQUAL, + bool_value: true, + }, + }, + ]; + + it('confim value', () => { + expressionList.forEach((item) => { + return confirmValue(item.simple_exp); + }); + }); + + it('operationMap', () => { + expressionList.forEach((item) => { + return operationMap(item.simple_exp.op); + }); + }); + + it('expression2Filter', () => { + const expression = '(and((state=["SUCCESS","FAILED"])(is_publish=true))'; + expression2Filter(expression); + }); +}); diff --git a/web_console_v2/client/src/shared/filter.ts b/web_console_v2/client/src/shared/filter.ts new file mode 100644 index 000000000..e5e67229e --- /dev/null +++ b/web_console_v2/client/src/shared/filter.ts @@ -0,0 +1,120 @@ +import { + FilterExpression, + FilterExpressionKind, + FilterOp, + SimpleExpression, +} from '../typings/filter'; + +export let serializedExpression = ''; + +export function constructExpressionTree(simpleNodes: SimpleExpression[]) { + if (simpleNodes.length === 0) return; + const exporessioNodes = simpleNodes.map((item) => { + return { + kind: FilterExpressionKind.BASIC, + simple_exp: item, + }; + }); + if (simpleNodes.length === 1) { + return serializeFilterExpression(exporessioNodes[0]); + } + const expressionTree = { + kind: FilterExpressionKind.AND, + exps: exporessioNodes, + }; + + return serializeFilterExpression(expressionTree); +} + +/** + * preorder traversal to build serialized expressions + * @param expression + */ +export function serializeFilterExpression(expression: FilterExpression) { + expressionSerialize(expression); + // clear serializedExpression + const tempSerializedExpression = serializedExpression; + serializedExpression = ''; + return tempSerializedExpression; +} + +export function expressionSerialize(expression: FilterExpression) { + if (expression.kind === FilterExpressionKind.BASIC) { + serializedExpression += `(${expression.simple_exp?.field}${operationMap( + expression.simple_exp?.op!, + )}${confirmValue(expression.simple_exp!)})`; + return; + } + if (expression.kind === FilterExpressionKind.AND || expression.kind === FilterExpressionKind.OR) { + serializedExpression += `(${expression.kind}`; + expression.exps?.forEach((item, index) => { + expressionSerialize(item); + if (expression.exps && expression.exps.length - 1 === index) { + serializedExpression += ')'; + } + }); + } +} + +export function confirmValue(simpleExp: SimpleExpression) { + if (simpleExp.string_value !== undefined) { + return `"${simpleExp.string_value}"`; + } + if (simpleExp.number_value !== undefined) { + return simpleExp.number_value; + } + if (simpleExp.bool_value !== undefined) { + return simpleExp.bool_value; + } + return; +} + +export function operationMap(op: string) { + const map = new Map<string, string>(); + map.set(FilterOp.EQUAL, '='); + map.set(FilterOp.IN, ':'); + map.set(FilterOp.CONTAIN, '~='); + map.set(FilterOp.GREATER_THAN, '>'); + map.set(FilterOp.LESS_THAN, '<'); + + return map.get(op); +} + +/** + * expression string -> filter object eg: + * (and(state=["SUCCESS","FAILED"])(is_publish=true)) --> + * { + * state: ["SUCCESS","FAILED"], + * is_publish: true, + * } + * @param expressionString + */ +export function expression2Filter(expressionString: string) { + const res: { [key: string]: any } = {}; + if (!expressionString || !expressionString.length) { + return res; + } + const pureFilterExpression = expressionString.startsWith('(and') + ? expressionString.substring(4, expressionString.length - 1) + : expressionString.substring(0, expressionString.length); + const filterRegex = /(?<=\()(.+?)(?=\))/g; + const operationRegex = /(:|=|~=|>|<)/; + const valRegex = /("\[")/; + const filterPairArray = pureFilterExpression.match(filterRegex); + if (Array.isArray(filterPairArray)) { + filterPairArray.forEach((pair) => { + const index = pair.search(operationRegex); + const [filterKey, filterVal] = [ + pair.substring(0, index).trim(), + pair.indexOf('~=') === -1 + ? pair.substring(index + 1).trim() + : pair.substring(index + 2).trim(), + ]; + // "["SUCCESS", "PENDING"]" needs to delete " " + res[filterKey] = valRegex.test(filterVal) + ? JSON.parse(filterVal.slice(1, filterVal.length - 1)) + : JSON.parse(filterVal); + }); + } + return res; +} diff --git a/web_console_v2/client/src/shared/helpers.tsx b/web_console_v2/client/src/shared/helpers.tsx new file mode 100644 index 000000000..7a30325fa --- /dev/null +++ b/web_console_v2/client/src/shared/helpers.tsx @@ -0,0 +1,765 @@ +import React from 'react'; +import { isNil } from 'lodash-es'; +import store from 'store2'; + +import LOCAL_STORAGE_KEYS from 'shared/localStorageKeys'; + +import { + Python, + Javascript, + Default, + Config, + Yaml, + Json, + GitIgnore, + Markdown, +} from 'components/IconPark'; +import { FileData, FileDataNode } from 'components/FileExplorer'; + +import { ValueType } from 'typings/settings'; +import { FileTreeNode } from 'typings/algorithm'; + +/** + * @param time time in ms + */ +export function sleep(time: number): Promise<null> { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +} + +/** + * Run callback at next event loop, + */ +export function nextTick(cb: Function) { + return setTimeout(() => { + cb(); + }, 0); +} + +/** + * Convert value to css acceptable stuffs + * @param val e.g. 10, '10%', '1.2em' + * @param unit e.g. px, %, em... + */ +export function convertToUnit(val: any, unit = 'px'): string { + if (isNil(val) || val === '') { + return '0'; + } else if (isNaN(val)) { + return String(val); + } else { + return `${Number(val)}${unit}`; + } +} + +/** + * Resolve promise inline + */ +export async function to<T, E = Error>(promise: Promise<T>): Promise<[T, E]> { + try { + const ret = await promise; + return [ret, (null as unknown) as E]; + } catch (e) { + return [(null as unknown) as T, e]; + } +} + +export const weakRandomKeyCache = new Map<Symbol, string>(); +/** + * Give a random string base on Math.random, + * cache mechanism will be involved if a symbol is provided + */ +export function giveWeakRandomKey(symbol?: Symbol): string { + if (symbol) { + if (weakRandomKeyCache.has(symbol)) { + return weakRandomKeyCache.get(symbol)!; + } + + const ret = giveWeakRandomKey(); + weakRandomKeyCache.set(symbol, ret); + return ret; + } + + return Math.random().toString(16).slice(2); +} + +type ScriptStatus = 'loading' | 'idle' | 'ready' | 'error'; +/* istanbul ignore next */ +export function loadScript(src: string): Promise<{ status: ScriptStatus; error?: Error }> { + return new Promise((resolve, reject) => { + if (!src) { + resolve({ status: 'idle' }); + return; + } + + // Fetch existing script element by src + // It may have been added by another intance of this util + let script = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement; + + if (!script) { + // Create script + script = document.createElement('script'); + script.src = src; + script.async = true; + script.setAttribute('data-status', 'loading'); + // Add script to document body + document.body.appendChild(script); + + // Store status in attribute on script + // This can be read by other instances of this hook + const setAttributeFromEvent = (event: Event) => { + const status = event.type === 'load' ? 'ready' : 'error'; + script.setAttribute('data-status', status); + }; + + script.addEventListener('load', setAttributeFromEvent); + script.addEventListener('error', setAttributeFromEvent); + } else { + // Grab existing script status from attribute and set to state. + resolve({ status: script.getAttribute('data-status') as any }); + } + + // Script event handler to update status in state + // Note: Even if the script already exists we still need to add + // event handlers to update the state for *this* hook instance. + const setStateFromEvent = (event: Event) => { + const status = event.type === 'load' ? 'ready' : 'error'; + if (event.type === 'load') { + resolve({ status }); + } else { + reject({ status, error: event }); + } + }; + + // Add event listeners + script.addEventListener('load', setStateFromEvent); + script.addEventListener('error', setStateFromEvent); + }); +} + +/** + * Copy to the clipboard (only for PC, no mobile adaptation processing has been done yet) \nstr \nThe string to be copied\nIs the copy successful? + * + * @param {String} str need copied + * @return {Boolean} is success? + */ +/* istanbul ignore next */ +export async function copyToClipboard(str: string) { + str = str.toString(); + + const inputEl = document.createElement('textArea') as HTMLTextAreaElement; + let copyOk = false; + + inputEl.value = str; + document.body.append(inputEl); + inputEl.select(); + + try { + copyOk = document.execCommand('Copy'); + } catch (e) { + return Promise.reject(e); + } + + document.body.removeChild(inputEl); + return Promise.resolve(copyOk); +} + +/* istanbul ignore next */ +export async function newCopyToClipboard(str: string) { + if (!navigator.clipboard) { + return await copyToClipboard(str); + } + return navigator.clipboard.writeText(str); +} + +export function saveBlob(blob: Blob, fileName: string) { + const a = document.createElement('a'); + a.href = window.URL.createObjectURL(blob); + a.download = fileName; + a.dispatchEvent(new MouseEvent('click')); +} + +/** + * Replace special characters in regular expressions, preceded by \ + */ +export const transformRegexSpecChar = (str: string) => { + const specCharList = ['\\', '*', '.', '?', '+', '$', '^', '[', ']', '(', ')', '{', '}', '|', '/']; + let resultString = str; + specCharList.forEach((char) => { + resultString = resultString.replace(char, `\\${char}`); + }); + return resultString; +}; + +/** + * Format object to array, support custom order + * @example + * from + * { + * 'key1': 1, + * 'key2': 2, + * } + * + * to + * [ + * { + * label: 'key1', + * value: 1, + * } + * { + * label: 'key2', + * value: 2, + * } + * ] + */ +export function formatObjectToArray<T = any>( + map: { + [key: string]: T; + }, + orderKeyList?: string[], +): Array<{ + label: string; + value: T | null; +}> { + const tempMap = { ...map }; + + const result: { + label: string; + value: T | null; + }[] = []; + + if (orderKeyList && orderKeyList.length > 0) { + orderKeyList.forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(tempMap, key)) return; + const value = tempMap[key]; + delete tempMap[key]; + result.push({ + label: key, + value, + }); + }); + } + + Object.keys(tempMap).forEach((key) => { + const value = map[key]; + result.push({ + label: key, + value, + }); + }); + + return result; +} + +/** + * Format object string to JSON with space + */ +export function formatJSONValue(str: string, space: string | number | undefined = 2) { + try { + const value = JSON.stringify(JSON.parse(str), null, space); + return value; + } catch (error) { + return str; + } +} + +/** + * Format value to string + */ +export const formatValueToString = (value: any, valueType?: ValueType) => { + if (isNil(value)) { + return ''; + } + + if (valueType === 'OBJECT' || valueType === 'LIST') { + if (value) { + return JSON.stringify(value); + } + } + + return String(value); +}; + +/** + * Parse value from string + */ +export const parseValueFromString = (value: string, valueType?: ValueType) => { + if (valueType === 'OBJECT' || valueType === 'LIST' || valueType === 'CODE') { + try { + let tempValue = value; + // Parse twice-JSON-stringified string, e.g. <FeatureSelect /> 's value + while (typeof tempValue === 'string') { + tempValue = JSON.parse(tempValue); + } + return tempValue; + } catch (error) { + throw error; + } + } + + if (valueType === 'INT' || valueType === 'NUMBER') { + if (value === '') { + return null; + } + const res = Number(value); + if (isNaN(res)) { + throw new Error('数字格式不正确'); + } + return res; + } + + if (valueType === 'BOOL' || valueType === 'BOOLEAN') { + if (['True', 'true', '1'].includes(value)) { + return true; + } + if (['False', 'false', '0'].includes(value)) { + return false; + } + throw new Error('BOOL格式不正确'); + } + + return value; +}; + +/** + * Get a random integer between min and max + */ +export function getRandomInt(min = 1, max = 10) { + if (min > max) { + throw new Error('Min is not allowed to be greater than max!!!'); + } + + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Is string is can be parse + */ +export const isStringCanBeParsed = (value: string) => { + try { + JSON.parse(value); + return true; + } catch (error) { + return false; + } +}; +/** + * Flatten array + * @param {array} array + */ +export function flatten<T extends Array<any>>(array: T): T { + return array.reduce( + (acc, cur) => (Array.isArray(cur) ? [...acc, ...flatten(cur)] : [...acc, cur]), + [], + ); +} + +/** + * Depth First Search + * @param {object} node node + * @param {function} filter filter function + * @param {string} childFieldName children field name + */ +export function dfs< + T extends { + [key: string]: any; + } +>( + node: T, + filter = (node: T, currentLevel: number) => true, + childFieldName = 'children', + currentLevel = 1, +): T[] { + let resultList = []; + + if (node[childFieldName] && node[childFieldName].length > 0) { + resultList = node[childFieldName].map((item: any) => + dfs(item, filter, childFieldName, currentLevel + 1), + ); + } + + if (filter(node, currentLevel)) { + resultList.unshift(node); + } + + return flatten(resultList); +} + +export const fileExtToIconMap: { + [ext: string]: React.ReactNode; +} = { + py: <Python />, + python: <Python />, + js: <Javascript />, + javascript: <Javascript />, + default: <Default />, + yml: <Yaml />, + yaml: <Yaml />, + json: <Json />, + config: <Config />, + md: <Markdown />, + gitignore: <GitIgnore />, +}; + +/** + * Format non-nested style to nested tree style + * @example + * from + * { + * 'main.py':'code...', + * 'leader/main.py':'code...', + * 'leader/test.py':'code...' + * } + * + * to + * [ + * { + * title:'main.py', + * key:'main.py', + * parentKey: '', + * isLeaf:true, + * code:'code...', + * icon:<Python />, + * fileExt:'py', + * children:[], + * isFolder:false + * } + * { + * title:'leader', + * key:'leader/', + * isFolder:true + * children:[ + * { + * title:'main.py', + * key:'leader/main.py', + * parentKey:'leader', + * isLeaf:true, + * code:'code...', + * icon:<Python />, + * fileExt:'py', + * children:[], + * isFolder:false + * }, + * ... + * ] + * } + * ] + */ +export function formatTreeData(filePathToCodeMap: FileData): FileDataNode[] { + const result: any = []; + const level = { result }; + + Object.keys(filePathToCodeMap).forEach((filePath) => { + const code = filePathToCodeMap[filePath]; + const filePathList = filePath.split('/'); + + let tempKey = ''; + filePathList.reduce((sum: any, fileNameOrFolderName: string, index: number) => { + const parentKey = tempKey; + tempKey += `${fileNameOrFolderName}/`; + if (!sum[fileNameOrFolderName]) { + sum[fileNameOrFolderName] = { result: [] }; + const isLastIndex = index === filePathList.length - 1; + const tempData: FileDataNode = { + title: fileNameOrFolderName, + key: tempKey.slice(0, -1), + children: sum[fileNameOrFolderName].result, + parentKey: parentKey.slice(0, -1), + isFolder: true, + isLeaf: isLastIndex + ? !Object.keys(filePathToCodeMap).some((innerPath) => { + // Case: filePathToCodeMap = { main: null, 'main/test.py': '1' } + // Because it is a file under 'main' folder, so 'main' folder isn't leaf node + return innerPath.startsWith(tempKey); + }) + : false, + }; + + // If code !== null, it will be treated as file + if (isLastIndex && code !== null) { + const extList = + fileNameOrFolderName.indexOf('.') > -1 ? fileNameOrFolderName.split('.') : []; + const fileExt = extList && extList.length > 0 ? extList[extList.length - 1] : ''; + tempData.icon = fileExtToIconMap[fileExt] || fileExtToIconMap['default']; + tempData.code = code; + tempData.isLeaf = true; + tempData.fileExt = fileExt; + tempData.isFolder = false; + } + sum.result.push(tempData); + } + + return sum[fileNameOrFolderName]; + }, level); + + tempKey = ''; + }); + return result; +} + +/** + * Get first file node,it will ignore folder + * @param {FileDataNode[]} formattedTreeData + */ +export function getFirstFileNode(formattedTreeData: FileDataNode[]): FileDataNode | null { + let node = null; + + for (let index = 0; index < formattedTreeData.length; index++) { + const currentNode = formattedTreeData[index]; + + if (!currentNode.isFolder) { + node = currentNode; + break; + } + + // Recursion, children + node = getFirstFileNode(currentNode.children ?? []); + + // If find target node,then stop recursion + if (node) { + break; + } + } + + return node; +} + +/** + * Format file tree node(Back-end format in algorithms api) to file data node(Front-end format in <FileExplorer/>) + * @example + * from + * { + filename: 'main.py', + path: 'main.py', + size: 96, + mtime: 1637141275, + is_directory: false, + files: [] + * } + * + * to + * { + title: 'main.py', + key: 'main.py', + parentKey: '', + isLeaf: true, + code: '', + icon: fileExtToIconMap['py'], + fileExt: 'py', + children: [], + isFolder: false + * } + */ +export function formatFileTreeNodeToFileDataNode(fileTreeNode: FileTreeNode): FileDataNode { + const pathList = fileTreeNode.path.split('/').filter((item) => item); + const extList = fileTreeNode.filename.indexOf('.') > -1 ? fileTreeNode.filename.split('.') : []; + const fileExt = extList.length > 0 ? extList[extList.length - 1] : ''; + + return { + title: fileTreeNode.filename, + key: fileTreeNode.path, + parentKey: pathList.length > 1 ? pathList.slice(0, -1).join('/') : '', + isFolder: fileTreeNode.is_directory, + isLeaf: fileTreeNode.files.length === 0, + code: fileTreeNode.is_directory ? null : '', + fileExt, + icon: fileExtToIconMap[fileExt] || fileExtToIconMap['default'], + children: fileTreeNode.files.map((item) => formatFileTreeNodeToFileDataNode(item)), + }; +} +export function formatFileTreeNodeListToFileDataNodeList( + fileTreeNodeList: FileTreeNode[], +): FileDataNode[] { + return fileTreeNodeList.map((item) => formatFileTreeNodeToFileDataNode(item)); +} + +/** + * Format file tree node(Back-end format in algorithms api) to file data (Front-end format in <FileExplorer/>) + * @example + * from + * { + title: 'follower', + key: 'follower', + parentKey: '', + isFolder: true, + isLeaf: false, + code: null, + icon: fileExtToIconMap['default'], + fileExt: '', + children: [ + { + title: 'main.py', + key: 'follower/main.py', + parentKey: 'follower', + isLeaf: true, + code: '', + icon: fileExtToIconMap['py'], + fileExt: 'py', + children: [], + isFolder: false, + }, + { + title: 'subfolder', + key: 'follower/subfolder', + parentKey: 'follower', + isLeaf: false, + code: null, + icon: fileExtToIconMap['default'], + fileExt: '', + isFolder: true, + children: [ + { + title: 'test.js', + key: 'follower/subfolder/test.js', + parentKey: 'follower/subfolder', + isLeaf: true, + code: '', + icon: fileExtToIconMap['js'], + fileExt: 'js', + children: [], + isFolder: false, + }, + ], + }, + ], + * } + * + * to + * { + 'follower/main.py': '', + 'follower/subfolder/test.js': '', + * } + */ +export function formatFileTreeNodeToFileData(fileTreeNode: FileTreeNode): FileData { + const finalFileData: FileData = {}; + dfs( + fileTreeNode, + (node) => { + if (node.files.length === 0) { + finalFileData[node.path] = node.is_directory ? null : ''; + } + return false; + }, + 'files', + ); + return finalFileData; +} +export function formatFileTreeNodeListToFileData(fileTreeNodeList: FileTreeNode[]): FileData { + const finalFileData: FileData = {}; + fileTreeNodeList.forEach((fileTreeNode) => { + dfs( + fileTreeNode, + (node) => { + // 将所有的pathKey都进行储存,防止一次性删除多层级空目录的情况 + // if (node.files.length === 0) { + // finalFileData[node.path] = node.is_directory ? null : ''; + // } + finalFileData[node.path] = node.is_directory ? null : ''; + return false; + }, + 'files', + ); + }); + + return finalFileData; +} + +/** + * Format file ext to full language that Monaco Editor will be receive + * @param language + * @returns formatted language + */ +export function formatLanguage(language: string) { + if (language === 'py') { + return 'python'; + } + if (language === 'js') { + return 'javascript'; + } + if (language === 'yml') { + return 'yaml'; + } + + if (language === 'sh') { + return 'shell'; + } + + return language; +} + +/** + * Get jwt headers + * @returns {object} headers { 'Authorization': string, 'x-pc-auth': string } + */ +export function getJWTHeaders() { + const accessToken = store.get(LOCAL_STORAGE_KEYS.current_user)?.access_token; + const tempAccessToken = store.get(LOCAL_STORAGE_KEYS.temp_access_token); + const { ssoName, ssoType } = store.get(LOCAL_STORAGE_KEYS.sso_info) ?? {}; + + let finalAccessToken = accessToken; + + // 1. If tempAccessToken is existed, then override accessToken + // 2. tempAccessToken will be assigned in views/TokenCallback/index.tsx + if (tempAccessToken) { + finalAccessToken = tempAccessToken; + } + + const headers: { + Authorization?: string; + 'x-pc-auth'?: string; + } = {}; + + if (finalAccessToken) { + headers.Authorization = `Bearer ${finalAccessToken}`; + } + + if (ssoName && ssoType && finalAccessToken) { + // Custom HTTP header, format like x-pc-auth: <sso_name> <type> <credentials> + headers['x-pc-auth'] = `${ssoName} ${ssoType} ${finalAccessToken}`; + } + return headers; +} + +/** + * Convert CPU unit, Core to M + * @example convertCpuCoreToM(1, false) => 1000, convertCpuCoreToM("2Core", true) => "2000m" + */ +export function convertCpuCoreToM(core: number | string, withUnit: true): string; +export function convertCpuCoreToM(core: number | string, withUnit?: false): number; +export function convertCpuCoreToM(core: number | string, withUnit = false): number | string { + let numberCore = 0; + + if (typeof core === 'number') { + numberCore = core; + } + if (typeof core === 'string') { + numberCore = parseFloat(core); + } + + const result = numberCore * 1000; + + return withUnit ? result + 'm' : result; +} + +/** + * Convert CPU unit, M to Core + * @example convertCpuCoreToM(1000, false) => 1, convertCpuCoreToM("2000m", true) => "2Core" + */ +export function convertCpuMToCore(core: number | string, withUnit: true): string; +export function convertCpuMToCore(core: number | string, withUnit?: false): number; +export function convertCpuMToCore(core: number | string, withUnit = false): number | string { + let number = 0; + + if (typeof core === 'number') { + number = core; + } + if (typeof core === 'string') { + number = parseFloat(core); + } + + const result = number / 1000; + + return withUnit ? result + 'Core' : result; +} diff --git a/web_console_v2/client/src/shared/modelCenter.test.ts b/web_console_v2/client/src/shared/modelCenter.test.ts new file mode 100644 index 000000000..c037541f5 --- /dev/null +++ b/web_console_v2/client/src/shared/modelCenter.test.ts @@ -0,0 +1,514 @@ +import { + formatExtra, + formatListWithExtra, + formatMetrics, + formatIntersectionDatasetName, + getDefaultVariableValue, +} from './modelCenter'; + +import { + readyToRun, + invalid, + running, + completed, + stopped, + failed, +} from 'services/mocks/v2/intersection_datasets/examples'; +import { completed as workflowCompletedItem } from 'services/mocks/v2/workflows/examples'; +import { modelJobMetric, modelJobMetric2 } from 'services/mocks/v2/model_jobs/examples'; +import { CONSTANTS } from 'shared/constants'; + +const testItem1 = { + name: 'item1', + extra: JSON.stringify({ e1: 1, e2: 2, e3: 3 }), +}; +const testItem2 = { + name: 'item2', + extra: JSON.stringify({ name: 'extraName2' }), +}; + +const testItem3 = { + ...testItem1, + name: 'item3', + local_extra: JSON.stringify({ le1: 1, le2: 2, le3: 3 }), +}; +const testItem4 = { + ...testItem2, + name: 'item4', + local_extra: JSON.stringify({ name: 'localExtraName4' }), +}; + +const testItem5 = { + name: 'name5', + extra: JSON.stringify({ name: 'extraName5', e1: 1, e2: 2 }), + local_extra: JSON.stringify({ name: 'localExtraName5', e1: 3 }), +}; +describe('Model center helpers', () => { + describe('FormatExtra', () => { + it('Empty input', () => { + expect(formatExtra({})).toEqual({}); + }); + + it('Own extra field, but no local_extra field', () => { + expect(formatExtra(testItem1)).toEqual({ + ...testItem1, + e1: 1, + e2: 2, + e3: 3, + }); + expect(formatExtra(testItem2)).toEqual({ + ...testItem2, + name: 'extraName2', + }); + }); + it('Own extra field and local_extra field', () => { + expect(formatExtra(testItem3)).toEqual({ + ...testItem3, + e1: 1, + e2: 2, + e3: 3, + le1: 1, + le2: 2, + le3: 3, + }); + }); + it('Own extra field and local_extra field, local_extra will override extra', () => { + expect(formatExtra(testItem4)).toEqual({ + ...testItem4, + name: 'localExtraName4', + }); + expect(formatExtra(testItem5)).toEqual({ + ...testItem5, + name: 'localExtraName5', + e1: 3, + e2: 2, + }); + }); + it('Override extra field', () => { + expect(formatExtra(testItem4, true)).toEqual({ + ...testItem4, + }); + expect(formatExtra(testItem5, true)).toEqual({ + e1: 3, + e2: 2, + ...testItem5, + }); + }); + }); + + describe('FormatListWithExtra', () => { + it('Empty input', () => { + expect(formatListWithExtra([])).toEqual([]); + }); + it('Own extra field, but no local_extra field', () => { + expect(formatListWithExtra([testItem1, testItem2])).toEqual([ + { + ...testItem1, + e1: 1, + e2: 2, + e3: 3, + }, + { + ...testItem2, + name: 'extraName2', + }, + ]); + }); + it('Own extra field and local_extra field', () => { + expect(formatListWithExtra([testItem3])).toEqual([ + { + ...testItem3, + e1: 1, + e2: 2, + e3: 3, + le1: 1, + le2: 2, + le3: 3, + }, + ]); + }); + it('Own extra field and local_extra field, local_extra will override extra', () => { + expect(formatListWithExtra([testItem4, testItem5])).toEqual([ + { + ...testItem4, + name: 'localExtraName4', + }, + { + ...testItem5, + name: 'localExtraName5', + e1: 3, + e2: 2, + }, + ]); + }); + + it('Override extra field', () => { + expect(formatListWithExtra([testItem4, testItem5], true)).toEqual([ + { + ...testItem4, + }, + { + e1: 3, + e2: 2, + ...testItem5, + }, + ]); + }); + }); + + it('FormatIntersectionDatasetName', () => { + expect(formatIntersectionDatasetName(running)).toBe(running.name); + expect(formatIntersectionDatasetName(completed)).toBe(completed.name); + expect(formatIntersectionDatasetName(stopped)).toBe(stopped.name); + expect(formatIntersectionDatasetName(invalid)).toBe(invalid.name); + expect(formatIntersectionDatasetName(failed)).toBe(failed.name); + expect(formatIntersectionDatasetName(readyToRun)).toBe(readyToRun.name); + expect(formatIntersectionDatasetName({} as any)).toBe(CONSTANTS.EMPTY_PLACEHOLDER); + }); + + it('FormatMetrics', () => { + expect(formatMetrics({} as any)).toEqual({ + confusion_matrix: [], + eval: [], + evalMaxValue: 0, + featureImportanceMaxValue: 0, + feature_importance: [], + train: [], + trainMaxValue: 0, + }); + + expect( + formatMetrics({ + train: { + acc: null, + }, + eval: { + acc: null, + }, + } as any), + ).toEqual({ + confusion_matrix: [], + eval: [{ label: 'acc', value: null }], + evalMaxValue: 0, + featureImportanceMaxValue: 0, + feature_importance: [], + train: [{ label: 'acc', value: null }], + trainMaxValue: 0, + }); + + expect( + formatMetrics({ + train: { + acc: { + steps: [1, 2, 3], + values: [4, 5, 6], + }, + auc: { + steps: [1, 2, 3], + values: [7, 8, 9], + }, + }, + eval: { + acc: { + steps: [1, 2, 3], + values: [14, 15, 16], + }, + auc: { + steps: [1, 2, 3], + values: [17, 18, 19], + }, + }, + } as any), + ).toEqual({ + confusion_matrix: [], + eval: [ + { label: 'acc', value: 16 }, + { label: 'auc', value: 19 }, + ], + evalMaxValue: 19, + featureImportanceMaxValue: 0, + feature_importance: [], + train: [ + { label: 'acc', value: 6 }, + { label: 'auc', value: 9 }, + ], + trainMaxValue: 9, + }); + + expect( + formatMetrics({ + train: { + acc: { + steps: [1], + values: [4], + }, + auc: { + steps: [1], + values: [7], + }, + }, + eval: { + acc: { + steps: [1], + values: [14], + }, + auc: { + steps: [1], + values: [17], + }, + }, + } as any), + ).toEqual({ + confusion_matrix: [], + eval: [ + { label: 'acc', value: 14 }, + { label: 'auc', value: 17 }, + ], + evalMaxValue: 17, + featureImportanceMaxValue: 0, + feature_importance: [], + train: [ + { label: 'acc', value: 4 }, + { label: 'auc', value: 7 }, + ], + trainMaxValue: 7, + }); + + expect( + formatMetrics({ + confusion_matrix: { + fp: null, + fn: undefined, + tn: 40, + tp: 0, + }, + } as any), + ).toEqual({ + confusion_matrix: [ + { label: 'tp', value: 0, percentValue: '0%' }, + { label: 'fn', value: 0, percentValue: '0%' }, + { label: 'fp', value: 0, percentValue: '0%' }, + { label: 'tn', value: 40, percentValue: '100%' }, + ], + eval: [], + evalMaxValue: 0, + featureImportanceMaxValue: 0, + feature_importance: [], + train: [], + trainMaxValue: 0, + }); + + expect( + formatMetrics({ + confusion_matrix: { + fp: null, + fn: undefined, + tn: 0, + tp: 0, + }, + } as any), + ).toEqual({ + confusion_matrix: [ + { label: 'tp', value: 0, percentValue: CONSTANTS.EMPTY_PLACEHOLDER }, + { label: 'fn', value: 0, percentValue: CONSTANTS.EMPTY_PLACEHOLDER }, + { label: 'fp', value: 0, percentValue: CONSTANTS.EMPTY_PLACEHOLDER }, + { label: 'tn', value: 0, percentValue: CONSTANTS.EMPTY_PLACEHOLDER }, + ], + eval: [], + evalMaxValue: 0, + featureImportanceMaxValue: 0, + feature_importance: [], + train: [], + trainMaxValue: 0, + }); + + expect(formatMetrics(modelJobMetric)).toEqual({ + confusion_matrix: [ + { label: 'tp', value: 30, percentValue: '30%' }, + { label: 'fn', value: 22, percentValue: '22%' }, + { label: 'fp', value: 8, percentValue: '8%' }, + { label: 'tn', value: 40, percentValue: '40%' }, + ], + eval: [ + { label: 'acc', value: 0.9 }, + { label: 'auc', value: 0.8 }, + { label: 'precision', value: 0.7 }, + { label: 'recall', value: 0.2 }, + { label: 'f1', value: 0.1 }, + { label: 'ks', value: 0.7 }, + ], + evalMaxValue: 0.9, + featureImportanceMaxValue: 0.7, + feature_importance: [ + { label: 'test_13', value: 0.7 }, + { label: 'test_14', value: 0.6 }, + { label: 'test_15', value: 0.5 }, + { label: 'test_16', value: 0.4 }, + { label: 'peer-1', value: 0.3 }, + { label: 'peer-2', value: 0.3 }, + { label: 'age', value: 0.3 }, + { label: 'overall_score', value: 0.3 }, + { label: 'test_17', value: 0.3 }, + { label: 'salary', value: 0.2 }, + { label: 'test_19', value: 0.2 }, + { label: 'peer-3', value: 0.1 }, + { label: 'education', value: 0.1 }, + { label: 'height', value: 0.1 }, + { label: 'peer-0', value: 0.08 }, + ], + train: [ + { label: 'acc', value: 0.9 }, + { label: 'auc', value: 0.8 }, + { label: 'precision', value: 0.7 }, + { label: 'recall', value: 0.2 }, + { label: 'f1', value: 0.1 }, + { label: 'ks', value: 0.7 }, + ], + trainMaxValue: 0.9, + }); + + expect(formatMetrics(modelJobMetric2)).toEqual({ + confusion_matrix: [ + { label: 'tp', value: 22, percentValue: '21.56%' }, + { label: 'fn', value: 22, percentValue: '21.56%' }, + { label: 'fp', value: 8, percentValue: '7.84%' }, + { label: 'tn', value: 50, percentValue: '49.01%' }, + ], + eval: [ + { label: 'acc', value: 0.1 }, + { label: 'auc', value: 0.2 }, + { label: 'precision', value: 0.3 }, + { label: 'recall', value: 0.4 }, + { label: 'f1', value: 0.5 }, + { label: 'ks', value: 0.4 }, + ], + evalMaxValue: 0.5, + featureImportanceMaxValue: 0.7, + feature_importance: [ + { label: 'test_13', value: 0.7 }, + { label: 'test_14', value: 0.6 }, + { label: 'test_15', value: 0.5 }, + { label: 'test_16', value: 0.4 }, + { label: 'peer-1', value: 0.3 }, + { label: 'peer-2', value: 0.3 }, + { label: 'age', value: 0.3 }, + { label: 'overall_score', value: 0.3 }, + { label: 'test_17', value: 0.3 }, + { label: 'salary', value: 0.2 }, + { label: 'test_19', value: 0.2 }, + { label: 'peer-3', value: 0.1 }, + { label: 'education', value: 0.1 }, + { label: 'height', value: 0.1 }, + { label: 'peer-0', value: 0.08 }, + ], + train: [ + { label: 'acc', value: 0.1 }, + { label: 'auc', value: 0.2 }, + { label: 'precision', value: 0.3 }, + { label: 'recall', value: 0.4 }, + { label: 'f1', value: 0.5 }, + { label: 'ks', value: 0.4 }, + ], + trainMaxValue: 0.5, + }); + }); + + it('getDefaultVariableValue', () => { + expect(getDefaultVariableValue(workflowCompletedItem)).toBe(undefined); + expect(getDefaultVariableValue(workflowCompletedItem, 'image_version')).toBe('v1.5-rc3'); + expect(getDefaultVariableValue({} as any)).toBe(undefined); + expect( + getDefaultVariableValue({ + config: { + variables: [], + job_definitions: [], + }, + } as any), + ).toBe(undefined); + expect( + getDefaultVariableValue({ + config: { + variables: [], + job_definitions: [ + { + variables: [{ name: 'image', value: '123' }], + }, + ], + }, + } as any), + ).toBe('123'); + expect( + getDefaultVariableValue({ + config: { + variables: [{ name: 'image', value: '456' }], + job_definitions: [ + { + variables: [{ name: 'image', value: '123' }], + }, + ], + }, + } as any), + ).toBe('456'); + expect( + getDefaultVariableValue({ + config: { + variables: [], + job_definitions: [ + { + variables: [ + { name: 'image', value: '123' }, + { name: 'image', value: '456' }, + ], + }, + ], + }, + } as any), + ).toBe('123'); + expect( + getDefaultVariableValue( + { + config: { + variables: [{ name: 'image', value: '456' }], + job_definitions: [ + { + variables: [ + { name: 'image', value: '123' }, + { name: 'image_version', value: '789' }, + ], + }, + ], + }, + } as any, + 'image_version', + ), + ).toBe('789'); + + expect( + getDefaultVariableValue({ + config: { + variables: [{ name: 'image', value: undefined }], + job_definitions: [ + { + variables: [{ name: 'image', value: '123' }], + }, + ], + }, + } as any), + ).toBe(undefined); + expect( + getDefaultVariableValue({ + config: { + variables: [], + job_definitions: [ + { + variables: [ + { name: 'image', value: undefined }, + { name: 'image', value: '456' }, + ], + }, + ], + }, + } as any), + ).toBe(undefined); + }); +}); diff --git a/web_console_v2/client/src/shared/modelCenter.ts b/web_console_v2/client/src/shared/modelCenter.ts new file mode 100644 index 000000000..2d28ed10f --- /dev/null +++ b/web_console_v2/client/src/shared/modelCenter.ts @@ -0,0 +1,177 @@ +import { floor, isPlainObject } from 'lodash-es'; +import { formatObjectToArray } from 'shared/helpers'; +import { ModelJobMetrics } from 'typings/modelCenter'; +import { IntersectionDataset } from 'typings/dataset'; +import { WorkflowExecutionDetails } from 'typings/workflow'; +import { CONSTANTS } from 'shared/constants'; + +export type WithExtra = { + extra?: any; + local_extra?: any; +}; + +type Item = { + label: string; + value: number | null; +}; + +export function formatExtra<T extends WithExtra>( + originModelSet: T, + isOverrideExtraField = false, +): T { + let tempExtra = {} as T; + let tempLocalExtra = {} as T; + try { + tempExtra = JSON.parse(originModelSet.extra); + } catch (error) {} + + try { + tempLocalExtra = JSON.parse(originModelSet.local_extra); + } catch (error) {} + + if (isOverrideExtraField) return { ...tempExtra, ...tempLocalExtra, ...originModelSet }; + + return { ...originModelSet, ...tempExtra, ...tempLocalExtra }; +} + +export function formatListWithExtra<T extends WithExtra>( + originModelSetList: T[], + isOverrideExtraField = false, +): T[] { + return originModelSetList.map((item) => formatExtra(item, isOverrideExtraField)); +} + +export function formatMetrics<T extends ModelJobMetrics>(metrics: T, maxFeatureCount = 15) { + let trainMaxValue = 0; + let evalMaxValue = 0; + + // train + const trainList = Object.keys(metrics.train ?? {}).reduce((sum, key) => { + const value = metrics.train[key]; + + let finalValue = null; + if (Array.isArray(value)) { + finalValue = + (value && value.length > 1 ? floor(value[1][value[1].length - 1], 3) : null) ?? null; // get last value from array + } + if (isPlainObject(value)) { + const values = value.values as number[]; + + finalValue = + (values && values.length > 0 ? floor(values[values.length - 1], 3) : null) ?? null; // get last value from array + } + + trainMaxValue = Math.max(Number(finalValue), trainMaxValue); + + sum.push({ + label: key, + value: finalValue, + }); + + return sum; + }, [] as Item[]); + + // eval + const evalList = Object.keys(metrics.eval ?? {}).reduce((sum, key) => { + const value = metrics.eval[key] || []; + + let finalValue = null; + if (Array.isArray(value)) { + finalValue = + (value && value.length > 1 ? floor(value[1][value[1].length - 1], 3) : null) ?? null; // get last value from array + } + if (isPlainObject(value)) { + const values = value.values as number[]; + + finalValue = + (values && values.length > 0 ? floor(values[values.length - 1], 3) : null) ?? null; // get last value from array + } + + evalMaxValue = Math.max(Number(finalValue), evalMaxValue); + + sum.push({ + label: key, + value: finalValue, + }); + + return sum; + }, [] as Item[]); + + // confusion_matrix + let confusion_matrix = formatObjectToArray(metrics.confusion_matrix ?? {}, [ + 'tp', + 'fn', + 'fp', + 'tn', + ]) as Array<{ + label: string; + value: number | null; + percentValue: string; + }>; + + const total = + Number(metrics?.confusion_matrix?.tp ?? 0) + + Number(metrics?.confusion_matrix?.fn ?? 0) + + Number(metrics?.confusion_matrix?.fp ?? 0) + + Number(metrics?.confusion_matrix?.tn ?? 0); + + // calc each confusion_matrix item percent + confusion_matrix = confusion_matrix.map((item) => { + return { + ...item, + value: item.value || 0, + percentValue: + total <= 0 + ? CONSTANTS.EMPTY_PLACEHOLDER + : `${floor(((item.value || 0) / total) * 100, 2)}%`, + }; + }); + + // feature_importance + let feature_importance = formatObjectToArray(metrics.feature_importance ?? {}); + + // order by value + feature_importance.sort((a, b) => Number(b.value) - Number(a.value)); + + // slice feature_importance, default Top 15 + feature_importance = feature_importance.slice(0, maxFeatureCount); + + return { + train: trainList, + eval: evalList, + confusion_matrix, + feature_importance, + trainMaxValue, + evalMaxValue, + featureImportanceMaxValue: feature_importance?.[0]?.value ?? 0, + }; +} + +export function formatIntersectionDatasetName(dataset: IntersectionDataset) { + return dataset.name || CONSTANTS.EMPTY_PLACEHOLDER; +} + +export function getDefaultVariableValue( + workflow: WorkflowExecutionDetails, + imageField = 'image', +): any { + // variables + if (workflow.config && workflow.config.variables) { + const imageVariable = workflow.config.variables.find((item) => item.name === imageField); + if (imageVariable) { + return imageVariable.value; + } + } + // job_definitions + if (workflow.config && workflow.config.job_definitions && workflow.config.job_definitions) { + for (let i = 0; i < workflow.config.job_definitions.length; i++) { + for (let j = 0; j < workflow.config.job_definitions[i].variables.length; j++) { + if (workflow.config.job_definitions[i].variables[j].name === imageField) { + return workflow.config.job_definitions[i].variables[j].value; + } + } + } + } + + return undefined; +} diff --git a/web_console_v2/client/src/shared/operationRecord.test.ts b/web_console_v2/client/src/shared/operationRecord.test.ts new file mode 100644 index 000000000..34f414910 --- /dev/null +++ b/web_console_v2/client/src/shared/operationRecord.test.ts @@ -0,0 +1,531 @@ +import { + getOperationRecordList, + addOperationRecord, + deleteOperationRecord, + editOperationRecord, + renameOperationRecord, +} from './operationRecord'; + +import { OperationRecord, OperationType } from 'typings/algorithm'; + +const testNameList = ['main.py', 'folder1', 'folder1/f1.py', 'folder2']; +const testContent = '# coding: utf-8\n'; + +const addRecordList: OperationRecord[] = []; +const deleteRecordList: OperationRecord[] = []; +const editRecordList: OperationRecord[] = []; +const renameRecordList: OperationRecord[] = []; + +testNameList.forEach((name) => { + const isFolder = name.indexOf('.') === -1; + + addRecordList.push({ + path: name, + content: '', + type: OperationType.ADD, + isFolder, + }); + deleteRecordList.push({ + path: name, + content: testContent, + type: OperationType.DELETE, + isFolder, + }); + editRecordList.push({ + path: name, + content: testContent, + type: OperationType.EDIT, + isFolder, + }); + renameRecordList.push({ + path: name, + content: '', + type: OperationType.RENAME, + isFolder, + newPath: 'no-main.py', + }); +}); + +describe('AddOperationRecord', () => { + it('Empty operation record list', () => { + expect(addOperationRecord([], addRecordList[0], false)).toEqual([addRecordList[0]]); + expect(addOperationRecord([], addRecordList[0], true)).toEqual([ + { ...addRecordList[0], type: OperationType.EDIT }, + ]); + }); + + it('Operation record list with same path record that type is OperationType.DELETE', () => { + expect(addOperationRecord([deleteRecordList[0]], addRecordList[0], false)).toEqual([ + { ...addRecordList[0], type: OperationType.EDIT }, + ]); + expect(addOperationRecord([deleteRecordList[0]], addRecordList[0], true)).toEqual([ + { ...addRecordList[0], type: OperationType.EDIT }, + ]); + }); + + it('Operation record list with same path record but different isFolder type', () => { + expect( + addOperationRecord([{ ...addRecordList[0], isFolder: true }], addRecordList[0], false), + ).toEqual([{ ...addRecordList[0], isFolder: true }, addRecordList[0]]); + + expect( + addOperationRecord([{ ...addRecordList[0], isFolder: true }], addRecordList[0], true), + ).toEqual([ + { ...addRecordList[0], isFolder: true }, + { ...addRecordList[0], type: OperationType.EDIT }, + ]); + }); + + it('Operation record list with no same path record', () => { + expect( + addOperationRecord( + [addRecordList[1], addRecordList[2], addRecordList[3], editRecordList[1]], + addRecordList[0], + false, + ), + ).toEqual([ + addRecordList[1], + addRecordList[2], + addRecordList[3], + editRecordList[1], + addRecordList[0], + ]); + }); + + it('Add existed folder', () => { + expect(addOperationRecord([], addRecordList[1], true)).toEqual([]); + expect(addOperationRecord([deleteRecordList[1]], addRecordList[1], true)).toEqual([]); + }); +}); + +describe('DeleteOperationRecord', () => { + it('Empty operation record list', () => { + expect(deleteOperationRecord([], deleteRecordList[0], false)).toEqual([]); + expect(deleteOperationRecord([], deleteRecordList[0], true)).toEqual([deleteRecordList[0]]); + }); + + it('Operation record list with same path record', () => { + expect( + deleteOperationRecord([addRecordList[0], editRecordList[0]], deleteRecordList[0], false), + ).toEqual([]); + expect( + deleteOperationRecord([addRecordList[0], editRecordList[0]], deleteRecordList[0], true), + ).toEqual([deleteRecordList[0]]); + }); + + it('Delete folder', () => { + expect( + deleteOperationRecord([addRecordList[1], addRecordList[2]], deleteRecordList[1], false), + ).toEqual([]); + expect( + deleteOperationRecord([addRecordList[1], addRecordList[2]], deleteRecordList[1], true), + ).toEqual([deleteRecordList[1]]); + }); +}); + +describe('EditOperationRecord', () => { + it('Empty operation record list', () => { + expect(editOperationRecord([], editRecordList[0], false)).toEqual([ + { ...editRecordList[0], type: OperationType.ADD }, + ]); + expect(editOperationRecord([], editRecordList[0], true)).toEqual([editRecordList[0]]); + }); + + it('Operation record list with same path record that type is OperationType.ADD', () => { + expect(editOperationRecord([addRecordList[0]], editRecordList[0], false)).toEqual([ + { ...editRecordList[0], type: OperationType.ADD }, + ]); + expect(editOperationRecord([addRecordList[0]], editRecordList[0], true)).toEqual([ + editRecordList[0], + ]); + }); + + it('Operation record list with no same path record', () => { + expect( + editOperationRecord( + [addRecordList[1], addRecordList[2], addRecordList[3], editRecordList[3]], + editRecordList[0], + false, + ), + ).toEqual([ + addRecordList[1], + addRecordList[2], + addRecordList[3], + editRecordList[3], + { ...editRecordList[0], type: OperationType.ADD }, + ]); + + expect( + editOperationRecord( + [addRecordList[1], addRecordList[2], addRecordList[3], editRecordList[3]], + editRecordList[0], + true, + ), + ).toEqual([ + addRecordList[1], + addRecordList[2], + addRecordList[3], + editRecordList[3], + editRecordList[0], + ]); + }); + + it('Edit folder', () => { + expect(editOperationRecord([], editRecordList[1], false)).toEqual([]); + expect(editOperationRecord([], editRecordList[1], true)).toEqual([]); + expect(editOperationRecord([addRecordList[1]], editRecordList[1], false)).toEqual([ + addRecordList[1], + ]); + expect(editOperationRecord([addRecordList[1]], editRecordList[1], true)).toEqual([ + addRecordList[1], + ]); + }); +}); + +describe('RenameOperationRecord', () => { + it('Empty operation record list', () => { + try { + renameOperationRecord([], renameRecordList[0], false); + } catch (error) { + expect(error.message).toMatch( + 'Required type === OperationType.ADD record in prev operation record list', + ); + } + expect(renameOperationRecord([], renameRecordList[0], true)).toEqual([renameRecordList[0]]); + }); + + it('Rename existed node in Back-end', () => { + expect(renameOperationRecord([], renameRecordList[0], true)).toEqual([renameRecordList[0]]); + expect(renameOperationRecord([], renameRecordList[1], true)).toEqual([renameRecordList[1]]); + expect(renameOperationRecord([editRecordList[0]], renameRecordList[0], true)).toEqual([ + editRecordList[0], + renameRecordList[0], + ]); + expect( + renameOperationRecord( + [addRecordList[1], addRecordList[2], addRecordList[3], editRecordList[3]], + renameRecordList[0], + true, + ), + ).toEqual([ + addRecordList[1], + addRecordList[2], + addRecordList[3], + editRecordList[3], + renameRecordList[0], + ]); + }); + + it('Rename file when isExistedNode = false', () => { + expect(renameOperationRecord([addRecordList[0]], renameRecordList[0], false)).toEqual([ + { + ...addRecordList[0], + path: renameRecordList[0].newPath, + type: OperationType.ADD, + }, + ]); + }); + it('Rename folder when isExistedNode = false', () => { + expect( + renameOperationRecord([addRecordList[1], addRecordList[2]], renameRecordList[1], false), + ).toEqual( + [addRecordList[1], addRecordList[2]].map((item) => { + const oldPathReg = new RegExp(`^${renameRecordList[1].path}`); + return { + ...item, + path: item.path.replace(oldPathReg, renameRecordList[1].newPath!), + }; + }), + ); + }); +}); + +describe('GetOperationRecordList', () => { + it('When isExistedNode = false', () => { + let prevOperationRecordList: OperationRecord[] = []; + let resultOperationRecordList: OperationRecord[] = []; + + // Add file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + addRecordList[0], + false, + ); + resultOperationRecordList = [addRecordList[0]]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Then add empty folder + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + addRecordList[1], + false, + ); + resultOperationRecordList = [resultOperationRecordList[0], addRecordList[1]]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Then add file in the folder + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + addRecordList[2], + false, + ); + resultOperationRecordList = [ + resultOperationRecordList[0], + resultOperationRecordList[1], + addRecordList[2], + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Edit first file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + editRecordList[0], + false, + ); + resultOperationRecordList = [ + { ...editRecordList[0], type: OperationType.ADD }, + resultOperationRecordList[1], + resultOperationRecordList[2], + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Edit second file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + editRecordList[2], + false, + ); + resultOperationRecordList = [ + resultOperationRecordList[0], + resultOperationRecordList[1], + { ...editRecordList[2], type: OperationType.ADD }, + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Rename first file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + renameRecordList[0], + false, + ); + resultOperationRecordList = [ + { + ...renameRecordList[0], + type: OperationType.ADD, + path: renameRecordList[0].newPath!, + content: editRecordList[0].content, + newPath: undefined, + }, + resultOperationRecordList[1], + resultOperationRecordList[2], + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Edit first file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + { + path: renameRecordList[0].newPath!, + content: '#123', + type: OperationType.EDIT, + isFolder: false, + }, + false, + ); + resultOperationRecordList = [ + { + path: renameRecordList[0].newPath!, + content: '#123', + type: OperationType.ADD, + isFolder: false, + }, + resultOperationRecordList[1], + resultOperationRecordList[2], + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Delete first file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + { + path: renameRecordList[0].newPath!, + content: '', + type: OperationType.DELETE, + isFolder: false, + }, + false, + ); + resultOperationRecordList = [resultOperationRecordList[1], resultOperationRecordList[2]]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Rename folder + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + { + ...renameRecordList[1], + newPath: 'newFolderName', + }, + false, + ); + const oldPathReg = new RegExp(`^${renameRecordList[1].path}`); + resultOperationRecordList = [ + { ...resultOperationRecordList[0], path: 'newFolderName' }, + { + ...resultOperationRecordList[1], + path: editRecordList[2].path.replace(oldPathReg, 'newFolderName'), + }, + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Delete folder + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + { + ...deleteRecordList[1], + path: 'newFolderName', + }, + false, + ); + resultOperationRecordList = []; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + }); + + it('When isExistedNode = true', () => { + let prevOperationRecordList: OperationRecord[] = []; + let resultOperationRecordList: OperationRecord[] = []; + + // Add file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + addRecordList[0], + true, + ); + resultOperationRecordList = [{ ...addRecordList[0], type: OperationType.EDIT }]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Then add empty folder + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + addRecordList[1], + true, + ); + expect(prevOperationRecordList).toEqual(resultOperationRecordList); // If it is existed same folder node in Back-end, don't add record + + // Then add file in the folder + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + addRecordList[2], + true, + ); + resultOperationRecordList = [ + resultOperationRecordList[0], + { ...addRecordList[2], type: OperationType.EDIT }, + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Edit first file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + editRecordList[0], + true, + ); + resultOperationRecordList = [resultOperationRecordList[1], editRecordList[0]]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Edit second file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + editRecordList[2], + true, + ); + resultOperationRecordList = [resultOperationRecordList[1], editRecordList[2]]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Rename first file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + renameRecordList[0], + true, + ); + resultOperationRecordList = [ + resultOperationRecordList[0], + resultOperationRecordList[1], + renameRecordList[0], + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Edit first file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + { + path: renameRecordList[0].newPath!, + content: '#123', + type: OperationType.EDIT, + isFolder: false, + }, + true, + ); + resultOperationRecordList.push({ + path: renameRecordList[0].newPath!, + content: '#123', + type: OperationType.EDIT, + isFolder: false, + }); + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Delete first file + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + { + path: renameRecordList[0].newPath!, + content: '', + type: OperationType.DELETE, + isFolder: false, + }, + true, + ); + resultOperationRecordList = [ + resultOperationRecordList[0], + resultOperationRecordList[1], + resultOperationRecordList[2], + { + path: renameRecordList[0].newPath!, + content: '', + type: OperationType.DELETE, + isFolder: false, + }, + ]; + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Rename folder + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + { + ...renameRecordList[1], + newPath: 'newFolderName', + }, + true, + ); + resultOperationRecordList.push({ + ...renameRecordList[1], + newPath: 'newFolderName', + }); + + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + + // Delete folder + prevOperationRecordList = getOperationRecordList( + prevOperationRecordList, + { + ...deleteRecordList[1], + path: 'newFolderName', + }, + true, + ); + resultOperationRecordList.push({ + ...deleteRecordList[1], + path: 'newFolderName', + }); + expect(prevOperationRecordList).toEqual(resultOperationRecordList); + }); +}); diff --git a/web_console_v2/client/src/shared/operationRecord.ts b/web_console_v2/client/src/shared/operationRecord.ts new file mode 100644 index 000000000..aa4e71fcd --- /dev/null +++ b/web_console_v2/client/src/shared/operationRecord.ts @@ -0,0 +1,182 @@ +import { OperationRecord, OperationType } from 'typings/algorithm'; + +function _removePrevNode(arr: OperationRecord[], obj: OperationRecord) { + return arr.filter((item) => { + return item.path !== obj.path || item.isFolder !== obj.isFolder; + }); +} + +function _removeSubNode(arr: OperationRecord[], obj: OperationRecord) { + const pathReg = new RegExp(`^${obj.path}/`); + return arr.filter((item) => { + return !pathReg.test(item.path); + }); +} + +export function getOperationRecordList( + prev: OperationRecord[], + cur: OperationRecord, + isExistedNode = false, +): OperationRecord[] { + let finalOperationRecordList: OperationRecord[] = []; + + switch (cur.type) { + case OperationType.ADD: + finalOperationRecordList = addOperationRecord(prev, cur, isExistedNode); + break; + case OperationType.DELETE: + finalOperationRecordList = deleteOperationRecord(prev, cur, isExistedNode); + break; + case OperationType.EDIT: + finalOperationRecordList = editOperationRecord(prev, cur, isExistedNode); + break; + case OperationType.RENAME: + finalOperationRecordList = renameOperationRecord(prev, cur, isExistedNode); + break; + default: + break; + } + + return finalOperationRecordList; +} + +export function addOperationRecord( + prev: OperationRecord[], + cur: OperationRecord, + isExistedNode = false, +): OperationRecord[] { + // If it is existed same folder node in Back-end, don't add record + if (isExistedNode && cur.isFolder) { + return _removePrevNode(prev, cur); + } + + const prevDeleteFileRecord = prev.find( + (item) => + item.path === cur.path && + item.type === OperationType.DELETE && + item.isFolder === cur.isFolder && + !item.isFolder, // file node + ); + + /** + * Note: change type to OperationType.EDIT + * + * case 1: + * 1. delete one file + * 2. add same name file + * + * case2: + * If it is existed same file node in Back-end + */ + if (prevDeleteFileRecord || (isExistedNode && !cur.isFolder)) { + return _removePrevNode(prev, cur).concat([ + { + ...cur, + type: OperationType.EDIT, + content: '', // clear file content + }, + ]); + } + + // Just insert new record + return [...prev, cur]; +} + +export function deleteOperationRecord( + prev: OperationRecord[], + cur: OperationRecord, + isExistedNode = false, +): OperationRecord[] { + // TODO: How to handle rename node? + + if (cur.isFolder) { + // Delete all record in the folder and delete all same path record, and insert new one record if it is existed node in Back-end + return _removeSubNode(_removePrevNode(prev, cur), cur).concat(isExistedNode ? [cur] : []); + } + + // Delete all same path record, and insert new one record if it is existed node in Back-end + return _removePrevNode(prev, cur).concat(isExistedNode ? [cur] : []); +} + +export function editOperationRecord( + prev: OperationRecord[], + cur: OperationRecord, + isExistedNode = false, +): OperationRecord[] { + // Can't edit folder content + if (cur.isFolder) { + return prev; + } + + const prevAddRecordIndex = prev.findIndex( + (item) => + item.path === cur.path && item.type === OperationType.ADD && item.isFolder === cur.isFolder, + ); + + /** + * Note: change type to OperationType.ADD + * 1. add one file + * 2. re-edit this file many times + */ + if (prevAddRecordIndex > -1) { + return [ + ...prev.slice(0, prevAddRecordIndex), + { + ...prev[prevAddRecordIndex], + content: cur.content, // change content = cur.content + type: isExistedNode ? OperationType.EDIT : OperationType.ADD, + }, + ...prev.slice(prevAddRecordIndex + 1), + ]; + } + + // TODO: It is necessary to call _removePrevNode? + return _removePrevNode(prev, cur).concat([ + { ...cur, type: isExistedNode ? OperationType.EDIT : OperationType.ADD }, + ]); +} +export function renameOperationRecord( + prev: OperationRecord[], + cur: OperationRecord, + isExistedNode = false, +): OperationRecord[] { + if (isExistedNode) { + // Just insert new record + return [...prev, cur]; + } + + if (!Object.prototype.hasOwnProperty.call(cur, 'newPath')) { + throw new Error('Required newPath!!!'); + } + + if (cur.isFolder) { + const oldPathReg = new RegExp(`^${cur.path}`); + + // Replace all old path to new path in the folder + return prev.map((item) => { + return { ...item, path: item.path.replace(oldPathReg, cur.newPath!) }; + }); + } + + const prevAddRecordIndex = prev.findIndex( + (item) => + item.path === cur.path && + item.type === OperationType.ADD && + item.isFolder === cur.isFolder && + !cur.isFolder, + ); + + if (prevAddRecordIndex === -1) { + throw new Error('Required type === OperationType.ADD record in prev operation record list!!!'); + } + + return [ + ...prev.slice(0, prevAddRecordIndex), + { + ...prev[prevAddRecordIndex], + path: cur.newPath!, // change old path to new path + type: OperationType.ADD, + }, + ...prev.slice(prevAddRecordIndex + 1), + ]; +} diff --git a/web_console_v2/client/src/shared/router.ts b/web_console_v2/client/src/shared/router.ts new file mode 100644 index 000000000..ea404dc37 --- /dev/null +++ b/web_console_v2/client/src/shared/router.ts @@ -0,0 +1,44 @@ +import { ProjectBaseAbilitiesType, ProjectTaskType } from 'typings/project'; + +type routePathName = + | 'projects' + | 'datasets' + | 'modelCenter' + | 'workflowCenter' + | 'trustedCenter' + | 'algorithmManagement' + | 'modelServing'; +// 当用户没有选择工作区只保留工作区管理和工作流中心 +export const ABILITIES_SIDEBAR_MENU_MAPPER: Record< + routePathName, + (ProjectBaseAbilitiesType | ProjectTaskType)[] +> = { + projects: [ + ProjectBaseAbilitiesType.BASE, + ProjectTaskType.ALIGN, + ProjectTaskType.HORIZONTAL, + ProjectTaskType.TRUSTED, + ProjectTaskType.VERTICAL, + ], + datasets: [ + ProjectTaskType.ALIGN, + ProjectTaskType.HORIZONTAL, + ProjectTaskType.TRUSTED, + ProjectTaskType.VERTICAL, + ], + modelCenter: [ProjectTaskType.HORIZONTAL, ProjectTaskType.VERTICAL], + workflowCenter: [ + ProjectBaseAbilitiesType.BASE, + ProjectTaskType.ALIGN, + ProjectTaskType.HORIZONTAL, + ProjectTaskType.TRUSTED, + ProjectTaskType.VERTICAL, + ], + trustedCenter: [ProjectTaskType.TRUSTED], + algorithmManagement: [ + ProjectTaskType.HORIZONTAL, + ProjectTaskType.TRUSTED, + ProjectTaskType.VERTICAL, + ], + modelServing: [ProjectTaskType.HORIZONTAL, ProjectTaskType.TRUSTED, ProjectTaskType.VERTICAL], +}; diff --git a/web_console_v2/client/src/shared/testUtils.ts b/web_console_v2/client/src/shared/testUtils.ts new file mode 100644 index 000000000..16903f7ba --- /dev/null +++ b/web_console_v2/client/src/shared/testUtils.ts @@ -0,0 +1,19 @@ +/* istanbul ignore file */ + +import { waitFor, RenderResult, fireEvent } from '@testing-library/react'; + +export async function waitForLoadingEnd(wrapper: RenderResult) { + // Start fetch async data, so loading should be displayed + await waitFor(() => expect(wrapper.container.querySelector('.arco-spin-icon')).toBeVisible()); + + // End fetch async data, so loading should be removed + await waitFor(() => + expect(wrapper.container.querySelector('.arco-spin-icon')).not.toBeInTheDocument(), + ); +} + +export function typeInput(input: HTMLElement, value: string | number) { + fireEvent.change(input, { + target: { value }, + }); +} diff --git a/web_console_v2/client/src/shared/trustedCenter.test.ts b/web_console_v2/client/src/shared/trustedCenter.test.ts new file mode 100644 index 000000000..f0ffb9d5e --- /dev/null +++ b/web_console_v2/client/src/shared/trustedCenter.test.ts @@ -0,0 +1,346 @@ +import { + AuthStatus, + TicketAuthStatus, + TicketStatus, + TrustedJobStatus, +} from 'typings/trustedCenter'; +import { + getLatestJobStatus, + getTicketAuthStatus, + getTrustedJobGroupAuthStatus, + getTrustedJobStatus, +} from './trustedCenter'; + +describe('trusted center shared function', () => { + const groupData: any = { + id: 1, + name: 'Trusted Computing', + created_at: Date.now(), + is_creator: true, + creator_id: 323, + auth_status: AuthStatus.AUTHORIZED, + ticket_status: TicketStatus.PENDING, + latest_job_status: undefined, + }; + + const jobData: any = { + id: 1, + name: 'Trusted Job', + job_id: 12, + comment: 'useful', + started_at: Date.now(), + finished_at: Date.now(), + status: undefined, + }; + + const tempJobData: any = undefined; + + /** getTrustedJobStatus */ + + it('get job no data status', () => { + expect(getTrustedJobStatus(tempJobData)).toEqual({ + type: 'default', + text: 'trusted_center.state_trusted_job_unknown', + tip: '', + }); + }); + + it('get job data undefined status', () => { + expect(getTrustedJobStatus(jobData)).toEqual({ + type: 'default', + text: 'trusted_center.state_trusted_job_unknown', + tip: '', + }); + }); + + it('get trusted job new status', () => { + expect(getTrustedJobStatus({ ...jobData, status: TrustedJobStatus.NEW })).toEqual({ + type: 'default', + text: '发起方创建成功', + tip: '', + }); + }); + + it('get trusted job created status', () => { + expect(getTrustedJobStatus({ ...jobData, status: TrustedJobStatus.CREATED })).toEqual({ + type: 'default', + text: '多方创建成功', + tip: '', + }); + }); + + it('get trusted job pending status', () => { + expect(getTrustedJobStatus({ ...jobData, status: TrustedJobStatus.PENDING })).toEqual({ + type: 'default', + text: 'trusted_center.state_trusted_job_pending', + tip: '', + }); + }); + + it('get trusted job running status', () => { + expect(getTrustedJobStatus({ ...jobData, status: TrustedJobStatus.RUNNING })).toEqual({ + type: 'processing', + text: 'trusted_center.state_trusted_job_running', + tip: '', + }); + }); + + it('get trusted job succeeded status', () => { + expect(getTrustedJobStatus({ ...jobData, status: TrustedJobStatus.SUCCEEDED })).toEqual({ + type: 'success', + text: 'trusted_center.state_trusted_job_succeeded', + tip: '', + }); + }); + + it('get trusted job failed status', () => { + expect(getTrustedJobStatus({ ...jobData, status: TrustedJobStatus.FAILED })).toEqual({ + type: 'error', + text: 'trusted_center.state_trusted_job_failed', + tip: '', + }); + }); + + it('get trusted job stopped status', () => { + expect(getTrustedJobStatus({ ...jobData, status: TrustedJobStatus.STOPPED })).toEqual({ + type: 'error', + text: 'trusted_center.state_trusted_job_stopped', + tip: '', + }); + }); + + it('get trusted job undefined status', () => { + expect( + getTrustedJobGroupAuthStatus({ + ...groupData, + auth_status: undefined, + }), + ).toEqual({ + type: 'default', + text: 'trusted_center.state_trusted_job_unknown', + tip: '', + }); + }); + + it('get trusted job authorization status', () => { + expect(getTrustedJobGroupAuthStatus(groupData)).toEqual({ + type: 'success', + text: 'trusted_center.state_auth_status_authorized', + tip: '', + }); + }); + + it('get trusted job unauthorization status', () => { + expect( + getTrustedJobGroupAuthStatus({ + ...groupData, + auth_status: AuthStatus.PENDING, + }), + ).toEqual({ + type: 'error', + text: 'trusted_center.state_auth_status_unauthorized', + tip: '', + }); + }); + + const exportJobData: any = { + id: 1, + name: 'Trusted Job', + job_id: 12, + comment: 'useful', + started_at: Date.now(), + finished_at: Date.now(), + status: TrustedJobStatus.PENDING, + ticket_auth_status: undefined, + }; + + const tempExportJobData: any = undefined; + /** getTicketAuthStatus */ + + it('get export job no data status', () => { + expect(getTicketAuthStatus(tempExportJobData)).toEqual({ + type: 'normal', + text: '未知', + percent: 0, + tip: '', + }); + }); + + it('get export job undefined status', () => { + expect(getTicketAuthStatus(exportJobData)).toEqual({ + type: 'normal', + text: '未知', + percent: 0, + tip: '', + }); + }); + + it('get export job create pending status', () => { + expect( + getTicketAuthStatus({ + ...exportJobData, + ticket_auth_status: TicketAuthStatus.CREATE_PENDING, + }), + ).toEqual({ + type: 'normal', + text: '创建中', + percent: 10, + tip: '', + }); + }); + + it('get export job create failed status', () => { + expect( + getTicketAuthStatus({ + ...exportJobData, + ticket_auth_status: TicketAuthStatus.CREATE_FAILED, + }), + ).toEqual({ + type: 'error', + text: '创建失败', + percent: 100, + tip: '', + }); + }); + + it('get export job ticket pending status', () => { + expect( + getTicketAuthStatus({ + ...exportJobData, + ticket_auth_status: TicketAuthStatus.TICKET_PENDING, + }), + ).toEqual({ + type: 'normal', + text: '待审批', + percent: 20, + tip: '', + }); + }); + + it('get export job ticket declined status', () => { + expect( + getTicketAuthStatus({ + ...exportJobData, + ticket_auth_status: TicketAuthStatus.TICKET_DECLINED, + }), + ).toEqual({ + type: 'error', + text: '审批失败', + percent: 100, + tip: '', + }); + }); + + it('get export job ticket auth pending status', () => { + expect( + getTicketAuthStatus({ + ...exportJobData, + ticket_auth_status: TicketAuthStatus.AUTH_PENDING, + }), + ).toEqual({ + type: 'normal', + text: '待授权', + percent: 80, + tip: '', + }); + }); + + it('get export job ticket authorized status', () => { + expect( + getTicketAuthStatus({ + ...exportJobData, + ticket_auth_status: TicketAuthStatus.AUTHORIZED, + }), + ).toEqual({ + type: 'normal', + text: '已授权', + percent: 100, + tip: '', + }); + }); + + /** getLatestJobStatus */ + it('get job no data status', () => { + expect(getLatestJobStatus(tempJobData)).toEqual({ + type: 'default', + text: '未知', + tip: '', + }); + }); + + it('get job data undefined status', () => { + expect(getLatestJobStatus(groupData)).toEqual({ + type: 'default', + text: '未知', + tip: '', + }); + }); + + it('get trusted job new status', () => { + expect(getLatestJobStatus({ ...groupData, latest_job_status: TrustedJobStatus.NEW })).toEqual({ + type: 'success', + text: '发起方创建成功', + tip: '', + }); + }); + + it('get trusted job created status', () => { + expect( + getLatestJobStatus({ ...groupData, latest_job_status: TrustedJobStatus.CREATED }), + ).toEqual({ + type: 'success', + text: '多方创建成功', + tip: '', + }); + }); + + it('get trusted job pending status', () => { + expect( + getLatestJobStatus({ ...groupData, latest_job_status: TrustedJobStatus.PENDING }), + ).toEqual({ + type: 'default', + text: '待执行', + tip: '', + }); + }); + + it('get trusted job running status', () => { + expect( + getLatestJobStatus({ ...groupData, latest_job_status: TrustedJobStatus.RUNNING }), + ).toEqual({ + type: 'processing', + text: '执行中', + tip: '', + }); + }); + + it('get trusted job succeeded status', () => { + expect( + getLatestJobStatus({ ...groupData, latest_job_status: TrustedJobStatus.SUCCEEDED }), + ).toEqual({ + type: 'success', + text: '已成功', + tip: '', + }); + }); + + it('get trusted job failed status', () => { + expect( + getLatestJobStatus({ ...groupData, latest_job_status: TrustedJobStatus.FAILED }), + ).toEqual({ + type: 'error', + text: '已失败', + tip: '', + }); + }); + + it('get trusted job stopped status', () => { + expect( + getLatestJobStatus({ ...groupData, latest_job_status: TrustedJobStatus.STOPPED }), + ).toEqual({ + type: 'error', + text: '已终止', + tip: '', + }); + }); +}); diff --git a/web_console_v2/client/src/shared/trustedCenter.ts b/web_console_v2/client/src/shared/trustedCenter.ts new file mode 100644 index 000000000..e8c587521 --- /dev/null +++ b/web_console_v2/client/src/shared/trustedCenter.ts @@ -0,0 +1,208 @@ +import { ProgressType, StateTypes } from 'components/StateIndicator'; +import i18n from 'i18n'; +import { + TrustedJobGroupItem, + TrustedJobStatus, + AuthStatus, + TrustedJobListItem, + TrustedJobGroup, + TicketAuthStatus, + TrustedJob, +} from 'typings/trustedCenter'; + +// ------- judge trusted job status ------ +export function getTrustedJobStatus( + data: TrustedJobListItem | TrustedJob, +): { type: StateTypes; text: string; tip?: string } { + let type: StateTypes = 'default'; + let text = i18n.t('trusted_center.state_trusted_job_unknown'); + const tip = ''; + + if (!data) { + return { + type, + text, + tip, + }; + } + + switch (data.status) { + case TrustedJobStatus.NEW: + type = 'default'; + text = '发起方创建成功'; + break; + case TrustedJobStatus.CREATED: + type = 'default'; + text = '多方创建成功'; + break; + case TrustedJobStatus.PENDING: + type = 'default'; + text = i18n.t('trusted_center.state_trusted_job_pending'); + break; + case TrustedJobStatus.RUNNING: + type = 'processing'; + text = i18n.t('trusted_center.state_trusted_job_running'); + break; + case TrustedJobStatus.SUCCEEDED: + type = 'success'; + text = i18n.t('trusted_center.state_trusted_job_succeeded'); + break; + case TrustedJobStatus.FAILED: + type = 'error'; + text = i18n.t('trusted_center.state_trusted_job_failed'); + break; + case TrustedJobStatus.STOPPED: + type = 'error'; + text = i18n.t('trusted_center.state_trusted_job_stopped'); + break; + default: + break; + } + return { + type, + text, + tip, + }; +} + +// ------ judge trusted job authorization status ------ +export function getTrustedJobGroupAuthStatus( + data: TrustedJobGroupItem, +): { type: StateTypes; text: string; tip?: string } { + let type: StateTypes = 'default'; + let text = i18n.t('trusted_center.state_trusted_job_unknown'); + const tip = ''; + switch (data.auth_status) { + case AuthStatus.AUTHORIZED: + type = 'success'; + text = i18n.t('trusted_center.state_auth_status_authorized'); + break; + case AuthStatus.PENDING: + type = 'error'; + text = i18n.t('trusted_center.state_auth_status_unauthorized'); + break; + default: + break; + } + + return { + type, + text, + tip, + }; +} + +// ------ judge ticket auth authorization status ------ +export function getTicketAuthStatus( + data: TrustedJobListItem | TrustedJob | TrustedJobGroupItem | TrustedJobGroup, +): { type: ProgressType; text: string; tip?: string; percent: number } { + let type: ProgressType = 'normal'; + let text = '未知'; + const tip = ''; + let percent = 0; + + if (!data) { + return { + type, + text, + tip, + percent, + }; + } + + switch (data.ticket_auth_status) { + case TicketAuthStatus.CREATE_PENDING: + type = 'normal'; + text = '创建中'; + percent = 10; + break; + case TicketAuthStatus.CREATE_FAILED: + type = 'error'; + text = '创建失败'; + percent = 100; + break; + case TicketAuthStatus.TICKET_PENDING: + type = 'normal'; + text = '待审批'; + percent = 20; + break; + case TicketAuthStatus.TICKET_DECLINED: + type = 'error'; + text = '审批失败'; + percent = 100; + break; + case TicketAuthStatus.AUTH_PENDING: + type = 'normal'; + text = '待授权'; + percent = 80; + break; + case TicketAuthStatus.AUTHORIZED: + type = 'normal'; + text = '已授权'; + percent = 100; + break; + default: + break; + } + return { + type, + text, + tip, + percent, + }; +} + +// ------ judge latest job status ------ +export function getLatestJobStatus( + data: TrustedJobGroupItem | TrustedJobGroup, +): { type: StateTypes; text: string; tip?: string } { + let type: StateTypes = 'default'; + let text = '未知'; + const tip = ''; + + if (!data) { + return { + type, + text, + tip, + }; + } + + switch (data.latest_job_status) { + case TrustedJobStatus.NEW: + type = 'success'; + text = '发起方创建成功'; + break; + case TrustedJobStatus.CREATED: + type = 'success'; + text = '多方创建成功'; + break; + case TrustedJobStatus.PENDING: + type = 'default'; + text = '待执行'; + break; + case TrustedJobStatus.RUNNING: + type = 'processing'; + text = '执行中'; + break; + case TrustedJobStatus.SUCCEEDED: + type = 'success'; + text = '已成功'; + break; + case TrustedJobStatus.FAILED: + type = 'error'; + text = '已失败'; + break; + case TrustedJobStatus.STOPPED: + type = 'error'; + text = '已终止'; + break; + default: + break; + } + return { + type, + text, + tip, + }; +} diff --git a/web_console_v2/client/src/shared/url.ts b/web_console_v2/client/src/shared/url.ts new file mode 100644 index 000000000..b231a2016 --- /dev/null +++ b/web_console_v2/client/src/shared/url.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ + +import History from 'history'; + +export function parseSearch(location: History.Location | Location) { + return new URLSearchParams(location.search); +} diff --git a/web_console_v2/client/src/stores/algorithm.ts b/web_console_v2/client/src/stores/algorithm.ts new file mode 100644 index 000000000..811c297a9 --- /dev/null +++ b/web_console_v2/client/src/stores/algorithm.ts @@ -0,0 +1,31 @@ +import { atom } from 'recoil'; +import { + AlgorithmProject, + AlgorithmReleaseStatus, + AlgorithmStatus, + EnumAlgorithmProjectSource, + EnumAlgorithmProjectType, +} from 'typings/algorithm'; + +export const AlgorithmProjectDetail = atom<AlgorithmProject>({ + key: 'AlgorithmProjectDetail', + default: { + id: '', + name: '', + project_id: '', + type: EnumAlgorithmProjectType.NN_LOCAL, + source: EnumAlgorithmProjectSource.PRESET, + creator_id: null, + username: '', + participant_id: null, + path: '', + publish_status: AlgorithmStatus.PUBLISHED, + release_status: AlgorithmReleaseStatus.RELEASED, + parameter: null, + comment: null, + latest_version: 1, + created_at: Date.now(), + updated_at: Date.now(), + deleted_at: Date.now(), + }, +}); diff --git a/web_console_v2/client/src/stores/app.ts b/web_console_v2/client/src/stores/app.ts new file mode 100644 index 000000000..a38a3a5a8 --- /dev/null +++ b/web_console_v2/client/src/stores/app.ts @@ -0,0 +1,113 @@ +import { atom, DefaultValue, selector } from 'recoil'; +import LOCAL_STORAGE_KEYS from 'shared/localStorageKeys'; +import store from 'store2'; +import { DisplayType } from 'typings/component'; +import { Flag } from 'typings/flag'; +import { FedLoginWay } from 'typings/auth'; +import { fetchSysInfo } from 'services/settings'; +import { SystemInfo } from 'typings/settings'; + +export const appPreference = atom({ + key: 'AppPreference', + default: { + language: store.get(LOCAL_STORAGE_KEYS.language) as string, + sidebarFolded: store.get(LOCAL_STORAGE_KEYS.sidebar_folded) as boolean, + projectsDisplay: + (store.get(LOCAL_STORAGE_KEYS.projects_display) as DisplayType) || DisplayType.Card, + sysEmailGroup: store.get(LOCAL_STORAGE_KEYS.sys_email_group) as string, + }, + effects_UNSTABLE: [ + // LocalStorage persistence + ({ onSet }) => { + onSet((newValue) => { + if (newValue instanceof DefaultValue) { + // Do nothing + } else { + store.set(LOCAL_STORAGE_KEYS.sidebar_folded, newValue.sidebarFolded); + store.set(LOCAL_STORAGE_KEYS.language, newValue.language); + store.set(LOCAL_STORAGE_KEYS.projects_display, newValue.projectsDisplay); + store.set(LOCAL_STORAGE_KEYS.sys_email_group, newValue.sysEmailGroup); + } + }); + }, + ], +}); + +export const appState = atom({ + key: 'AppState', + default: { + hideSidebar: false, + }, +}); + +export const appGetters = selector({ + key: 'AppGetters', + get({ get }) { + const isSideBarHidden = get(appState).hideSidebar; + + return { + sidebarWidth: isSideBarHidden ? 0 : get(appPreference).sidebarFolded ? 48 : 200, + }; + }, +}); + +export const appEmailGetters = selector({ + key: 'AppEmailGetters', + get({ get }) { + return get(appPreference).sysEmailGroup; + }, +}); + +export const appFlag = atom<Flag>({ + key: 'AppFlag', + default: store.get(LOCAL_STORAGE_KEYS.app_flags) ?? {}, + effects_UNSTABLE: [ + // LocalStorage persistence + ({ onSet }) => { + onSet((newValue) => { + if (newValue instanceof DefaultValue) { + // Do nothing + } else { + store.set(LOCAL_STORAGE_KEYS.app_flags, newValue ?? {}); + } + }); + }, + ], +}); + +export const appLoginWayList = atom<FedLoginWay[]>({ + key: 'AppLoginWayList', + default: store.get(LOCAL_STORAGE_KEYS.app_login_way_list) ?? [], + effects_UNSTABLE: [ + // LocalStorage persistence + ({ onSet }) => { + onSet((newValue) => { + if (newValue instanceof DefaultValue) { + // Do nothing + } else { + store.set(LOCAL_STORAGE_KEYS.app_login_way_list, newValue); + } + }); + }, + ], +}); + +export const systemInfoState = atom<{ current?: SystemInfo }>({ + key: 'SystemInfoState', + default: { + current: undefined, + }, +}); + +export const systemInfoQuery = selector({ + key: 'FetchSystemInfoState', + get: async ({ get }) => { + try { + const res = await fetchSysInfo(); + + return res.data; + } catch (error) { + throw error; + } + }, +}); diff --git a/web_console_v2/client/src/stores/modelCenter.ts b/web_console_v2/client/src/stores/modelCenter.ts new file mode 100644 index 000000000..65cee376f --- /dev/null +++ b/web_console_v2/client/src/stores/modelCenter.ts @@ -0,0 +1,508 @@ +import { atom, selector } from 'recoil'; +import i18n from 'i18n'; +import { formatExtra } from 'shared/modelCenter'; + +import { fetchWorkflowTemplateList, fetchTemplateById } from 'services/workflow'; + +import { + ResourceTemplateType, + FederationType, + UploadType, + LossType, + Role, + RoleUppercase, + ModelSet, +} from 'typings/modelCenter'; +import { WorkflowConfig, WorkflowExecutionDetails, WorkflowTemplate } from 'typings/workflow'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; + +export const TREE_TRAIN_MODEL_TEMPLATE_NAME = 'sys-preset-tree-model'; +export const NN_TRAIN_MODEL_TEMPLATE_NAME = 'sys-preset-nn-model'; +export const NN_TRAIN_HORIZONTAL_MODEL_TEMPLATE_NAME = 'sys-preset-nn-horizontal-model'; +export const NN_EVAL_HORIZONTAL_MODEL_TEMPLATE_NAME = 'sys-preset-nn-horizontal-eval-model'; + +export const treeTemplateId = atom<number | null>({ + key: 'TreeTemplateId', + default: null, +}); +export const treeTemplateIdQuery = selector<number | null>({ + key: 'TreeTemplateIdQuery', + get: async ({ get }) => { + try { + const prevTemplateId = get(treeTemplateId); + if (prevTemplateId !== null) { + return prevTemplateId; + } + const { data } = await fetchWorkflowTemplateList(); + const templateItem = + data?.find((item) => item.name === TREE_TRAIN_MODEL_TEMPLATE_NAME) ?? null; + + if (!templateItem) { + throw new Error(i18n.t('error.no_tree_train_model_template')); + } + + return templateItem?.id ?? null; + } catch (error) { + throw error; + } + }, + set: ({ set }, newValue: any) => { + set(treeTemplateId, newValue); + }, +}); +export const treeTemplateDetailQuery = selector<WorkflowTemplate | null>({ + key: 'TreeTemplateDetailQuery', + get: async ({ get }) => { + try { + const templateId = get(treeTemplateIdQuery); + + if (!templateId) { + return null; + } + const { data } = await fetchTemplateById(templateId); + return data; + } catch (error) { + throw error; + } + }, +}); + +export const nnTemplateId = atom<number | null>({ + key: 'NNTemplateId', + default: null, +}); +export const nnTemplateIdQuery = selector<number | null>({ + key: 'NNTemplateIdQuery', + get: async ({ get }) => { + try { + const prevTemplateId = get(nnTemplateId); + if (prevTemplateId !== null) { + return prevTemplateId; + } + const { data } = await fetchWorkflowTemplateList(); + + const templateItem = data?.find((item) => item.name === NN_TRAIN_MODEL_TEMPLATE_NAME) ?? null; + if (!templateItem) { + throw new Error(i18n.t('error.no_nn_train_model_template')); + } + return templateItem?.id ?? null; + } catch (error) { + throw error; + } + }, + set: ({ set }, newValue: any) => { + set(nnTemplateId, newValue); + }, +}); +export const nnTemplateDetailQuery = selector<WorkflowTemplate | null>({ + key: 'NNTemplateDetailQuery', + get: async ({ get }) => { + try { + const templateId = get(nnTemplateIdQuery); + + if (!templateId) { + return null; + } + const { data } = await fetchTemplateById(templateId); + return data; + } catch (error) { + throw error; + } + }, +}); + +export const nnHorizontalTemplateId = atom<number | null>({ + key: 'NNHorizontalTemplateId', + default: null, +}); +export const nnHorizontalTemplateIdQuery = selector<number | null>({ + key: 'NNHorizontalTemplateIdQuery', + get: async ({ get }) => { + try { + const prevTemplateId = get(nnHorizontalTemplateId); + if (prevTemplateId !== null) { + return prevTemplateId; + } + const { data } = await fetchWorkflowTemplateList(); + + const templateItem = + data?.find((item) => item.name === NN_TRAIN_HORIZONTAL_MODEL_TEMPLATE_NAME) ?? null; + if (!templateItem) { + throw new Error(i18n.t('error.no_nn_horizontal_train_model_template')); + } + return templateItem?.id ?? null; + } catch (error) { + throw error; + } + }, + set: ({ set }, newValue: any) => { + set(nnHorizontalTemplateId, newValue); + }, +}); +export const nnHorizontalTemplateDetailQuery = selector<WorkflowTemplate | null>({ + key: 'NNHorizontalTemplateDetailQuery', + get: async ({ get }) => { + try { + const templateId = get(nnHorizontalTemplateIdQuery); + + if (!templateId) { + return null; + } + const { data } = await fetchTemplateById(templateId); + return data; + } catch (error) { + throw error; + } + }, +}); + +export const nnHorizontalEvalTemplateId = atom<number | null>({ + key: 'NNHorizontalEvalTemplateId', + default: null, +}); +export const nnHorizontalEvalTemplateIdQuery = selector<number | null>({ + key: 'NNHorizontalEvalTemplateIdQuery', + get: async ({ get }) => { + try { + const prevTemplateId = get(nnHorizontalEvalTemplateId); + if (prevTemplateId !== null) { + return prevTemplateId; + } + const { data } = await fetchWorkflowTemplateList(); + + const templateItem = + data?.find((item) => item.name === NN_EVAL_HORIZONTAL_MODEL_TEMPLATE_NAME) ?? null; + if (!templateItem) { + throw new Error(i18n.t('error.no_nn_horizontal_eval_model_template')); + } + return templateItem?.id ?? null; + } catch (error) { + throw error; + } + }, + set: ({ set }, newValue: any) => { + set(nnHorizontalEvalTemplateId, newValue); + }, +}); +export const nnHorizontalEvalTemplateDetailQuery = selector<WorkflowTemplate | null>({ + key: 'NNHorizontalEvalTemplateDetailQuery', + get: async ({ get }) => { + try { + const templateId = get(nnHorizontalEvalTemplateIdQuery); + + if (!templateId) { + return null; + } + const { data } = await fetchTemplateById(templateId); + return data; + } catch (error) { + throw error; + } + }, +}); + +export const existedPeerModelSet = atom<ModelSet | null>({ + key: 'ExistedPeerModelSet', + default: null, +}); + +export const currentWorkflow = atom<WorkflowExecutionDetails | null>({ + key: 'CurrentWorkflow', + default: null, +}); +export const formattedExtraCurrentWorkflow = selector<WorkflowExecutionDetails | null>({ + key: 'FormattedExtraCurrentWorkflow', + get: ({ get }) => { + const baseCurrentWorkflow = get(currentWorkflow); + if (baseCurrentWorkflow) { + return formatExtra(baseCurrentWorkflow); + } + return null; + }, +}); + +export const peerWorkflow = atom<WorkflowExecutionDetails | null>({ + key: 'PeerWorkflow', + default: null, +}); +export const formattedExtraPeerWorkflow = selector<WorkflowExecutionDetails | null>({ + key: 'FormattedExtraPeerWorkflow', + get: ({ get }) => { + const baseCurrentWorkflow = get(peerWorkflow); + if (baseCurrentWorkflow) { + return formatExtra(baseCurrentWorkflow); + } + return null; + }, +}); + +export const currentEnvWorkflow = atom<WorkflowExecutionDetails | null>({ + key: 'CurrentEnvWorkflow', + default: null, +}); +export const formattedExtraCurrentEnvWorkflow = selector<WorkflowExecutionDetails | null>({ + key: 'FormattedExtraCurrentEnvWorkflow', + get: ({ get }) => { + const baseCurrentEnvWorkflow = get(currentEnvWorkflow); + if (baseCurrentEnvWorkflow) { + return formatExtra(baseCurrentEnvWorkflow); + } + return null; + }, +}); +export const currentEnvWorkflowConfig = selector<WorkflowConfig | null>({ + key: 'CurrentEnvWorkflowConfig', + get: ({ get }) => { + const baseCurrentEnvWorkflow = get(currentEnvWorkflow); + if (baseCurrentEnvWorkflow) { + return baseCurrentEnvWorkflow.config; + } + return null; + }, +}); + +export const trainModelForm = atom({ + key: 'TrainModelForm', + default: { + project_id: undefined, + model_name: '', + train_comment: '', + dataset_id: undefined, + dataset_name: '', + + modelset_name: undefined, + modelset_comment: undefined, + + mode: 'train', + image: '', + num_partitions: '', + namespace: 'default', + send_metrics_to_follower: false, + send_scores_to_follower: false, + is_allow_coordinator_parameter_tuning: false, + is_share_model_evaluation_index: false, + algorithm_type: EnumAlgorithmProjectType.TREE_VERTICAL, + resource_template_type: ResourceTemplateType.LOW, + worker_role: Role.LEADER, + role: RoleUppercase.LEADER, + peer_role: RoleUppercase.FOLLOWER, + + // Tree model + loss_type: LossType.LOGISTIC, + learning_rate: 0.3, + max_iters: 10, + max_depth: 5, + l2_regularization: 1, + max_bins: 33, + num_parallel: 5, + validation_data_path: '', + + // Train info + label: 'label', + // ignore_fields: '', + + // NN model + algorithm: { algorithmId: undefined, algorithmProjectId: undefined, config: [], path: [] }, + save_checkpoint_steps: 1000, + save_checkpoint_secs: 600, + epoch_num: 1, + code_tar: {}, + code_key: '', + ps_replicas: '1', + master_replicas: '1', + worker_replicas: '1', + batch_size: '', + shuffle_data_block: '', + load_checkpoint_filename: '', + load_checkpoint_filename_with_path: '', + checkpoint_path: '', + sparse_estimator: '', + load_checkpoint_from: '', + + // resource + master_cpu: '', + master_mem: '', + ps_cpu: '', + ps_mem: '', + worker_cpu: '16m', + worker_mem: '64m', + ps_num: 1, + worker_num: 1, + + // temp + data_source: '', + data_path: '', + file_ext: '.data', + file_type: 'tfrecord', + load_model_name: '', + enable_packing: '1', + ignore_fields: '', + cat_fields: '', + verify_example_ids: '', + use_streaming: '', + no_data: '', + verbosity: '1', + }, +}); + +export const evaluationModelForm = atom({ + key: 'EvaluationModelForm', + default: { + project_id: undefined, + report_name: '', + comment: '', + dataset_id: undefined, + dataset_name: '', + targetList: [ + { + model_set_id: undefined, + model_id: undefined, + }, + ], + mode: 'eval', + is_share: false, + image: '', + num_partitions: '', + resource_template_type: ResourceTemplateType.LOW, + worker_role: Role.LEADER, + role: RoleUppercase.LEADER, + peer_role: RoleUppercase.FOLLOWER, + + // Tree model + loss_type: LossType.LOGISTIC, + learning_rate: 0.3, + max_iters: 10, + max_depth: 5, + l2_regularization: 1, + max_bins: 33, + num_parallel: 5, + validation_data_path: '', + + // Train info + label: 'label', + // ignore_fields: '', + + // NN model + save_checkpoint_steps: 1000, + save_checkpoint_secs: 600, + epoch_num: 1, + code_tar: {}, + code_key: '', + ps_replicas: '1', + master_replicas: '1', + worker_replicas: '1', + batch_size: '', + shuffle_data_block: '', + load_checkpoint_filename: '', + load_checkpoint_filename_with_path: '', + checkpoint_path: '', + sparse_estimator: '', + load_checkpoint_from: '', + + // resource + master_cpu: '', + master_mem: '', + ps_cpu: '', + ps_mem: '', + worker_cpu: '16m', + worker_mem: '64m', + ps_num: 1, + worker_num: 1, + + // temp + data_source: '', + data_path: '', + file_ext: '.data', + file_type: 'tfrecord', + load_model_name: '', + enable_packing: '1', + ignore_fields: '', + cat_fields: '', + verify_example_ids: '', + use_streaming: '', + no_data: '', + verbosity: '1', + }, +}); + +export const offlinePredictionModelForm = atom({ + key: 'OfflinePredictionModelForm', + default: { + project_id: undefined, + name: '', + comment: '', + dataset_id: undefined, + dataset_name: '', + model_set: { + model_set_id: undefined, + model_id: undefined, + }, + mode: 'eval', + image: '', + num_partitions: '', + resource_template_type: ResourceTemplateType.LOW, + worker_role: Role.LEADER, + role: RoleUppercase.LEADER, + peer_role: RoleUppercase.FOLLOWER, + + // Train info + label: 'label', + // ignore_fields: '', + + // NN model + save_checkpoint_steps: 1000, + save_checkpoint_secs: 600, + epoch_num: 1, + code_tar: {}, + code_key: '', + ps_replicas: '1', + master_replicas: '1', + worker_replicas: '1', + batch_size: '', + shuffle_data_block: '', + load_checkpoint_filename: '', + load_checkpoint_filename_with_path: '', + checkpoint_path: '', + sparse_estimator: '', + load_checkpoint_from: '', + + // resource + master_cpu: '', + master_mem: '', + ps_cpu: '', + ps_mem: '', + worker_cpu: '16m', + worker_mem: '64m', + ps_num: 1, + worker_num: 1, + + // temp + data_source: '', + data_path: '', + file_ext: '.data', + file_type: 'tfrecord', + load_model_name: '', + enable_packing: '1', + ignore_fields: '', + cat_fields: '', + verify_example_ids: '', + use_streaming: '', + no_data: '', + verbosity: '1', + }, +}); + +export const algorithmlForm = atom({ + key: 'AlgorithmlForm', + default: { + project_id: undefined, + name: '', + comment: '', + algorithm_type: undefined, + federation_type: FederationType.CROSS_SAMPLE, + + import_type: UploadType.PATH, + import_type_label: UploadType.PATH, + import_type_no_label: UploadType.PATH, + }, +}); diff --git a/web_console_v2/client/src/stores/operation.ts b/web_console_v2/client/src/stores/operation.ts new file mode 100644 index 000000000..0421ee3a7 --- /dev/null +++ b/web_console_v2/client/src/stores/operation.ts @@ -0,0 +1,22 @@ +import { atom, selector } from 'recoil'; +import { fetchDashboardList } from '../services/operation'; +import { Message } from '@arco-design/web-react'; + +export const forceReloadDashboard = atom({ + key: 'ForceReloadDashboard', + default: 0, +}); + +export const DashboardListQuery = selector({ + key: 'fetchDashboardList', + + get: async ({ get }) => { + get(forceReloadDashboard); + try { + const res = await fetchDashboardList(); + return res?.data ?? []; + } catch (error) { + Message.error(error.message); + } + }, +}); diff --git a/web_console_v2/client/src/stores/participant.ts b/web_console_v2/client/src/stores/participant.ts new file mode 100644 index 000000000..56dd74051 --- /dev/null +++ b/web_console_v2/client/src/stores/participant.ts @@ -0,0 +1,29 @@ +import { Message } from '@arco-design/web-react'; +import { atom, selector, atomFamily } from 'recoil'; +import { fetchParticipants } from 'services/participant'; +import { ConnectionStatus, ConnectionStatusType } from 'typings/participant'; + +export const forceReloadParticipantList = atom({ + key: 'ForceReloadParticipantList', + default: 0, +}); + +export const participantListQuery = selector({ + key: 'FetchParticipantList', + get: async ({ get }) => { + get(forceReloadParticipantList); + + try { + const res = await fetchParticipants(); + + return res.data; + } catch (error: any) { + Message.info(error.message); + } + }, +}); + +export const participantConnectionState = atomFamily<ConnectionStatus, ID>({ + key: 'ParticipantConnectionState', + default: { success: ConnectionStatusType.Fail, message: '', application_version: {} }, +}); diff --git a/web_console_v2/client/src/stores/trustedCenter.ts b/web_console_v2/client/src/stores/trustedCenter.ts new file mode 100644 index 000000000..37d8c43ce --- /dev/null +++ b/web_console_v2/client/src/stores/trustedCenter.ts @@ -0,0 +1,26 @@ +import { atom } from 'recoil'; +import { ParticipantDataset } from 'typings/dataset'; +import { AuthStatus, TrustedJobResource } from 'typings/trustedCenter'; + +export type TrustedJbGroupForm = { + name: string; + comment: string; + algorithm_id?: ID; + dataset_id?: ID; + participant_datasets?: ParticipantDataset[]; + auth_status?: AuthStatus; + resource?: TrustedJobResource; +}; + +export const trustedJobGroupForm = atom<TrustedJbGroupForm>({ + key: 'TrustedJbGroupForm', + default: { + name: '', + comment: '', + algorithm_id: undefined, + dataset_id: undefined, + participant_datasets: undefined, + auth_status: undefined, + resource: undefined, + }, +}); diff --git a/web_console_v2/client/src/styles/arco-overrides.less b/web_console_v2/client/src/styles/arco-overrides.less new file mode 100644 index 000000000..7da3e6a1a --- /dev/null +++ b/web_console_v2/client/src/styles/arco-overrides.less @@ -0,0 +1,41 @@ +@body-background: #eff2f7; +@btn-default-color: rgb(var(--gray-8)); +@btn-default-bg: rgb(var(--gray-2)); +@btn-default-border: rgb(var(--gray-2)); +@btn-height-lg: 36px; +@btn-padding-horizontal-lg: 20px; +@menu-item-active-bg: rgb(var(--gray-2)); +@menu-item-active-border-width: 0; +@select-background: var(--componentBackgroundColorGray); +@select-border-color: transparent; +@input-placeholder-color: rgb(var(--gray-7)); +@picker-bg: var(--componentBackgroundColorGray); +@picker-border-color: transparent; +@keyframes spinWith50Translate { + 100% { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +.anticon { + > svg { + width: 1em; + fill: currentColor; + } +} + +.arco-spin { + display: block; +} + +.arco-btn { + border: none; + + > .anticon { + // Remove sturt + display: inline-flex; + justify-content: center; + height: 1em; + vertical-align: middle; + } +} diff --git a/web_console_v2/client/src/styles/global.less b/web_console_v2/client/src/styles/global.less new file mode 100644 index 000000000..c8058c41a --- /dev/null +++ b/web_console_v2/client/src/styles/global.less @@ -0,0 +1,320 @@ +@custom-header-font: 12px; + +body, +html { + width: 100%; + height: 100%; +} + +body { + margin: 0; + color: #4e5969; + font-size: 14px; + font-family: 'nunito_for_arco', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; + font-variant: tabular-nums; + line-height: 1.5715; + background-color: #eff2f7; + font-feature-settings: 'tnum', 'tnum'; + overflow: hidden; +} + +input::-ms-clear, +input::-ms-reveal { + display: none; +} + +*, +*::before, +*::after { + box-sizing: border-box; // 1 +} + +hr { + box-sizing: content-box; // 1 + height: 0; // 1 + overflow: visible; // 2 +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 0; + margin-bottom: 0.5em; + color: rgba(0, 0, 0, 0.85); + font-weight: 500; +} + +p { + margin-top: 0; + margin-bottom: 1em; +} + +address { + margin-bottom: 1em; + font-style: normal; + line-height: inherit; +} + +input[type='text'], +input[type='password'], +input[type='number'], +textarea { + -webkit-appearance: none; +} + +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1em; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 500; +} + +dd { + margin-bottom: 0.5em; + margin-left: 0; // Undo browser default +} + +blockquote { + margin: 0 0 1em; +} + +dfn { + font-style: italic; // Add the correct font style in Android 4.3- +} + +b, +strong { + font-weight: bolder; // Add the correct font weight in Chrome, Edge, and Safari +} + +small { + font-size: 80%; // Add the correct font size in all browsers +} + +a { + color: #1664ff; + text-decoration: none; + background-color: initial; + outline: none; + cursor: pointer; + transition: color 0.3s; +} + +pre { + // remove browser default top margin + margin-top: 0; + // Reset browser default of `1em` to use `em`s + margin-bottom: 1em; + // Don't allow content to break outside + overflow: auto; +} + +img { + vertical-align: middle; + border-style: none; // remove the border on images inside links in IE 10-. +} + +output { + display: inline-block; +} + +summary { + display: list-item; // Add the correct display in all browsers +} + +template { + display: none; // Add the correct display in IE +} + +// Always hide an element with the `hidden` HTML attribute (from PureCSS). +// Needed for proper display in IE 10-. +[hidden] { + display: none !important; +} + +// 解决 arco drawer 被平台 header 遮住的问题 +.arco-drawer-wrapper { + top: var(--headerHeight); + z-index: 1001 !important; +} +// Arco Modal +// --- +.custom-modal { + .arco-modal-content { + text-align: center; + } + .arco-modal-title { + word-break: break-all; + } +} + +.arco-modal-simple:not(.custom-modal) { + padding: 0 !important; + .arco-modal-header, + .arco-modal-content, + .arco-modal-footer { + padding: 16px 20px !important; + } + + .arco-modal-header { + border-bottom: 1px solid var(--color-border-2) !important; + padding: 12px 20px !important; + margin-bottom: 20px; + .arco-modal-title { + line-height: 22px; + } + } + .arco-modal-content { + padding-top: 0 !important; + } + .arco-modal-footer { + border-top: 1px solid var(--color-border-2) !important; + text-align: right !important; + .arco-btn { + min-width: 72px!important; + } + } +} + +// Arco Input +// --- + +// make clear icon always visible, otherwise it would make e2e test fail +.arco-input-clear-wrapper { + .arco-input-clear-icon { + display: inline-block; + margin-right: 5px; + } +} + +// button +.custom-text-button { + padding: 0; + border: none; + font-size: 12px; + background: transparent; + color: var(--primaryColor); + cursor: pointer; + &:hover { + color: var(--newPrimaryHover); + background-color: transparent !important; + } + &:disabled { + cursor: not-allowed; + color: var(--color-text-4); + } +} + +.custom-operation-button { + font-size: @custom-header-font; + .arco-btn-size-default { + font-size: @custom-header-font; + } +} + +// table + +.custom-table { + .arco-table-cell-wrap-value, + .arco-table-th-item-title { + font-size: var(--textFontSizePrimary); + } + .arco-pagination { + width: 100%; + display: flex; + justify-content: space-between; + } +} + +// make the filter icon on the left side +.custom-table-left-side-filter { + --custom-table-th-height: 40px; + .arco-table-col-has-filter { + padding-top: 0; + padding-bottom: 0; + height: var(--custom-table-th-height); + line-height: var(--custom-table-th-height); + } + .arco-table-filters { + position: unset; + display: inline-block; + margin-left: 2px; + height: var(--custom-table-th-height); + line-height: var(--custom-table-th-height); + text-align: center; + vertical-align: top; + & > svg { + vertical-align: -4px; + } + } +} + +// arco tabs with textFontSizePrimary +.custom-tabs { + .arco-tabs-header-title-text { + font-size: var(--textFontSizePrimary); + } +} + +// typography +.custom-typography { + &.arco-typography { + font-size: var(--textFontSizePrimary); + } +} + +// popover +.custom-popover { + &.arco-popover { + max-width: 400px; + width: 400px; + } + .arco-popover-content { + max-height: 600px; + border: 1px solid #e5e6e8; + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: auto; + padding: 0; + .arco-popover-inner { + border-radius: 0; + } + } + + .arco-popover-arrow { + display: none !important; + } +} + +// select + +.custom-select { + font-size: 12px; +} + +// input + +.custom-input { + font-size: 12px; + input::-webkit-input-placeholder { + font-size: 12px; + } + input::-moz-placeholder { + font-size: 12px; + } +} diff --git a/web_console_v2/client/src/styles/index.ts b/web_console_v2/client/src/styles/index.ts new file mode 100644 index 000000000..c2514a02c --- /dev/null +++ b/web_console_v2/client/src/styles/index.ts @@ -0,0 +1,8 @@ +/* eslint-disable */ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/.env.development or src/.env.production is the file you should go + */ +import defaultTheme from './themes/normal'; + +export { defaultTheme }; diff --git a/web_console_v2/client/src/styles/mixins.less b/web_console_v2/client/src/styles/mixins.less new file mode 100644 index 000000000..79391113b --- /dev/null +++ b/web_console_v2/client/src/styles/mixins.less @@ -0,0 +1,30 @@ +.MixinSquare(@size: 0px) { + width: @size; + height: @size; +} + +.MixinCircle(@size: 0px) { + .MixinSquare(@size); + border-radius: 50%; +} + +.MixinEllipsis(@maxWidth: 'auto') { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: @maxWidth; +} + +.MixinFontClarity() { + font-family: 'ClarityMono', sans-serif; +} + +.MixinFlexAlignCenter() { + justify-content: center; + align-items: center; +} + +.MixinFlexAlignCenter() { + justify-content: center; + align-items: center; +} diff --git a/web_console_v2/client/src/styles/theme.ts b/web_console_v2/client/src/styles/theme.ts new file mode 100644 index 000000000..d9fa1e94b --- /dev/null +++ b/web_console_v2/client/src/styles/theme.ts @@ -0,0 +1,8 @@ +/* eslint-disable */ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/.env.development or src/.env.production is the file you should go + */ +import defaultTheme from './themes/normal/normal'; + +export default defaultTheme; diff --git a/web_console_v2/client/src/styles/themes/bioland/bioland.css b/web_console_v2/client/src/styles/themes/bioland/bioland.css new file mode 100644 index 000000000..bf2729245 --- /dev/null +++ b/web_console_v2/client/src/styles/themes/bioland/bioland.css @@ -0,0 +1,62 @@ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/styles/variables/bioland.less is the file you should go + */ +:root { + --red1: #ffece8; + --red2: #fdcdc5; + --red5: #f76560; + --red6: #f53f3f; + --orange6: #ff7d00; + --green6: #00b42a; + --blue4: #7bc0fc; + --blue6: #3491fa; + --gray1: #f7f8fa; + --gray2: #f2f3f5; + --gray3: #e5e6eb; + --gray4: #c9cdd4; + --gray6: #86909c; + --gray8: #4e5969; + --gray9: #272e3b; + --gray10: #1d2129; + --newPrimaryDefault: #4450dc; + --newPrimaryHover: #6171e3; + --newPrimaryActive: #2b32b8; + --newPrimaryDisable: #a1b0f1; + --newPrimaryHoverGray: #c4cff8; + --primaryColor: #4450dc; + --infoColor: #4450dc; + --successColor: #00b42a; + --processingColor: #3491fa; + --errorColor: #f53f3f; + --errorColorLight: #ffece8; + --highlightColor: #f76560; + --warningColor: #ff7d00; + --normalColor: #272e3b; + --textColor: #4e5969; + --textColorStrong: #1d2129; + --textColorStrongSecondary: #4e5969; + --textColorSecondary: #86909c; + --textColorDisabled: #c9cdd4; + --textColorInverse: white; + --componentBackgroundColorGray: #f2f3f5; + --backgroundColor: #f7f8fa; + --backgroundColorGray: #e5e6eb; + --backgroundColorError: #ffece8; + --backgroundColorErrorHover: #fdcdc5; + --lineColor: #e5e8ef; + --headerBackground: #4450dc; + --headerBorderBottomWidth: 0; + --headerLogoHeight: 42px; + --headerProjectColor: #fff; + --headerPaddingLeft: 0; + --headerPaddingRight: 30px; + --commonTiming: cubic-bezier(0.4, 0, 0.2, 1); + --fontFamily: 'nunito_for_arco', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; + --headerHeight: 60px; + --pageHeaderHeight: 44px; + --contentOuterPadding: 16px; + --contentMinHeight: calc(100vh - 60px - 16px * 2); + --textFontSizePrimary: 12px; + --zIndexLessThanModal: 999; +} diff --git a/web_console_v2/client/src/styles/themes/bioland/bioland.less b/web_console_v2/client/src/styles/themes/bioland/bioland.less new file mode 100644 index 000000000..ba64e5c0c --- /dev/null +++ b/web_console_v2/client/src/styles/themes/bioland/bioland.less @@ -0,0 +1,11 @@ +/* eslint-disable */ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/styles/variables/bioland.less is the file you should go + */ +@import 'assets/fonts/ClarityMono/index.less'; +@import './bioland.css'; + +@import 'styles/variables/bioland.less'; +@import 'styles/arco-overrides.less'; +@import 'styles/global.less'; diff --git a/web_console_v2/client/src/styles/themes/bioland/bioland.ts b/web_console_v2/client/src/styles/themes/bioland/bioland.ts new file mode 100644 index 000000000..d0bc9c200 --- /dev/null +++ b/web_console_v2/client/src/styles/themes/bioland/bioland.ts @@ -0,0 +1,67 @@ +/* istanbul ignore file */ + +/* eslint-disable */ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/styles/variables/bioland.less is the file you should go + */ +const defaultTheme = { + red1: '#ffece8', + red2: '#fdcdc5', + red5: '#f76560', + red6: '#f53f3f', + orange6: '#ff7d00', + green6: '#00b42a', + blue4: '#7bc0fc', + blue6: '#3491fa', + gray1: '#f7f8fa', + gray2: '#f2f3f5', + gray3: '#e5e6eb', + gray4: '#c9cdd4', + gray6: '#86909c', + gray8: '#4e5969', + gray9: '#272e3b', + gray10: '#1d2129', + newPrimaryDefault: '#4450dc', + newPrimaryHover: '#6171e3', + newPrimaryActive: '#2b32b8', + newPrimaryDisable: '#a1b0f1', + newPrimaryHoverGray: '#c4cff8', + primaryColor: '#4450dc', + infoColor: '#4450dc', + successColor: '#00b42a', + processingColor: '#3491fa', + errorColor: '#f53f3f', + errorColorLight: '#ffece8', + highlightColor: '#f76560', + warningColor: '#ff7d00', + normalColor: '#272e3b', + textColor: '#4e5969', + textColorStrong: '#1d2129', + textColorStrongSecondary: '#4e5969', + textColorSecondary: '#86909c', + textColorDisabled: '#c9cdd4', + textColorInverse: 'white', + componentBackgroundColorGray: '#f2f3f5', + backgroundColor: '#f7f8fa', + backgroundColorGray: '#e5e6eb', + backgroundColorError: '#ffece8', + backgroundColorErrorHover: '#fdcdc5', + lineColor: '#e5e8ef', + headerBackground: '#4450dc', + headerBorderBottomWidth: '0', + headerLogoHeight: '42px', + headerProjectColor: '#fff', + headerPaddingLeft: '0', + headerPaddingRight: '30px', + commonTiming: 'cubic-bezier(0.4, 0, 0.2, 1)', + fontFamily: "'nunito_for_arco', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif", + headerHeight: '60px', + pageHeaderHeight: '44px', + contentOuterPadding: '16px', + contentMinHeight: 'calc(100vh - 60px - 16px * 2)', + textFontSizePrimary: '12px', + zIndexLessThanModal: '999', +} + +export default defaultTheme diff --git a/web_console_v2/client/src/styles/themes/bioland/index.ts b/web_console_v2/client/src/styles/themes/bioland/index.ts new file mode 100644 index 000000000..25185f23e --- /dev/null +++ b/web_console_v2/client/src/styles/themes/bioland/index.ts @@ -0,0 +1,9 @@ +/* eslint-disable */ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/styles/variables/bioland.less is the file you should go + */ +import './bioland.less'; +import defaultTheme from './bioland'; + +export default defaultTheme; diff --git a/web_console_v2/client/src/styles/themes/normal/index.ts b/web_console_v2/client/src/styles/themes/normal/index.ts new file mode 100644 index 000000000..1f82d3bdd --- /dev/null +++ b/web_console_v2/client/src/styles/themes/normal/index.ts @@ -0,0 +1,9 @@ +/* eslint-disable */ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/styles/variables/normal.less is the file you should go + */ +import './normal.less'; +import defaultTheme from './normal'; + +export default defaultTheme; diff --git a/web_console_v2/client/src/styles/themes/normal/normal.css b/web_console_v2/client/src/styles/themes/normal/normal.css new file mode 100644 index 000000000..cd3d17a33 --- /dev/null +++ b/web_console_v2/client/src/styles/themes/normal/normal.css @@ -0,0 +1,62 @@ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/styles/variables/normal.less is the file you should go + */ +:root { + --red1: #ffece8; + --red2: #fdcdc5; + --red5: #f76560; + --red6: #f53f3f; + --orange6: #ff7d00; + --green6: #00b42a; + --blue4: #7bc0fc; + --blue6: #3491fa; + --gray1: #f7f8fa; + --gray2: #f2f3f5; + --gray3: #e5e6eb; + --gray4: #c9cdd4; + --gray6: #86909c; + --gray8: #4e5969; + --gray9: #272e3b; + --gray10: #1d2129; + --newPrimaryDefault: #1664ff; + --newPrimaryHover: #4086ff; + --newPrimaryActive: #0e49d2; + --newPrimaryDisable: #94c2ff; + --newPrimaryHoverGray: #bedcff; + --primaryColor: #1664ff; + --infoColor: #1664ff; + --successColor: #00b42a; + --processingColor: #3491fa; + --errorColor: #f53f3f; + --errorColorLight: #ffece8; + --highlightColor: #f76560; + --warningColor: #ff7d00; + --normalColor: #272e3b; + --textColor: #4e5969; + --textColorStrong: #1d2129; + --textColorStrongSecondary: #4e5969; + --textColorSecondary: #86909c; + --textColorDisabled: #c9cdd4; + --textColorInverse: white; + --componentBackgroundColorGray: #f2f3f8; + --backgroundColor: #f7f8fa; + --backgroundColorGray: #e5e6eb; + --backgroundColorError: #ffece8; + --backgroundColorErrorHover: #fdcdc5; + --lineColor: #e5e8ef; + --headerBackground: #1d2129; + --headerBorderBottomWidth: 0; + --headerLogoHeight: 28px; + --headerProjectColor: #fff; + --headerPaddingLeft: 30px; + --headerPaddingRight: 30px; + --commonTiming: cubic-bezier(0.4, 0, 0.2, 1); + --fontFamily: 'nunito_for_arco', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; + --headerHeight: 60px; + --pageHeaderHeight: 44px; + --contentOuterPadding: 16px; + --contentMinHeight: calc(100vh - 60px - 16px * 2); + --textFontSizePrimary: 12px; + --zIndexLessThanModal: 999; +} diff --git a/web_console_v2/client/src/styles/themes/normal/normal.less b/web_console_v2/client/src/styles/themes/normal/normal.less new file mode 100644 index 000000000..c592d00e2 --- /dev/null +++ b/web_console_v2/client/src/styles/themes/normal/normal.less @@ -0,0 +1,11 @@ +/* eslint-disable */ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/styles/variables/normal.less is the file you should go + */ +@import 'assets/fonts/ClarityMono/index.less'; +@import './normal.css'; + +@import 'styles/variables/normal.less'; +@import 'styles/arco-overrides.less'; +@import 'styles/global.less'; diff --git a/web_console_v2/client/src/styles/themes/normal/normal.ts b/web_console_v2/client/src/styles/themes/normal/normal.ts new file mode 100644 index 000000000..81c7fcc12 --- /dev/null +++ b/web_console_v2/client/src/styles/themes/normal/normal.ts @@ -0,0 +1,67 @@ +/* istanbul ignore file */ + +/* eslint-disable */ +/** + * WARNING: This file is auto-generated + * DO NOT modify it directly, src/styles/variables/normal.less is the file you should go + */ +const defaultTheme = { + red1: '#ffece8', + red2: '#fdcdc5', + red5: '#f76560', + red6: '#f53f3f', + orange6: '#ff7d00', + green6: '#00b42a', + blue4: '#7bc0fc', + blue6: '#3491fa', + gray1: '#f7f8fa', + gray2: '#f2f3f5', + gray3: '#e5e6eb', + gray4: '#c9cdd4', + gray6: '#86909c', + gray8: '#4e5969', + gray9: '#272e3b', + gray10: '#1d2129', + newPrimaryDefault: '#1664ff', + newPrimaryHover: '#4086ff', + newPrimaryActive: '#0e49d2', + newPrimaryDisable: '#94c2ff', + newPrimaryHoverGray: '#bedcff', + primaryColor: '#1664ff', + infoColor: '#1664ff', + successColor: '#00b42a', + processingColor: '#3491fa', + errorColor: '#f53f3f', + errorColorLight: '#ffece8', + highlightColor: '#f76560', + warningColor: '#ff7d00', + normalColor: '#272e3b', + textColor: '#4e5969', + textColorStrong: '#1d2129', + textColorStrongSecondary: '#4e5969', + textColorSecondary: '#86909c', + textColorDisabled: '#c9cdd4', + textColorInverse: 'white', + componentBackgroundColorGray: '#f2f3f8', + backgroundColor: '#f7f8fa', + backgroundColorGray: '#e5e6eb', + backgroundColorError: '#ffece8', + backgroundColorErrorHover: '#fdcdc5', + lineColor: '#e5e8ef', + headerBackground: '#1d2129', + headerBorderBottomWidth: '0', + headerLogoHeight: '28px', + headerProjectColor: '#fff', + headerPaddingLeft: '30px', + headerPaddingRight: '30px', + commonTiming: 'cubic-bezier(0.4, 0, 0.2, 1)', + fontFamily: "'nunito_for_arco', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif", + headerHeight: '60px', + pageHeaderHeight: '44px', + contentOuterPadding: '16px', + contentMinHeight: 'calc(100vh - 60px - 16px * 2)', + textFontSizePrimary: '12px', + zIndexLessThanModal: '999', +} + +export default defaultTheme diff --git a/web_console_v2/client/src/styles/variables/bioland.less b/web_console_v2/client/src/styles/variables/bioland.less new file mode 100644 index 000000000..f10d3c7a2 --- /dev/null +++ b/web_console_v2/client/src/styles/variables/bioland.less @@ -0,0 +1,75 @@ +// -------- Colors palette ----------- +@red-1: #ffece8; +@red-2: #fdcdc5; +@red-5: #f76560; +@red-6: #f53f3f; +@orange-6: #ff7d00; +@green-6: #00b42a; +@blue-4: #7bc0fc; +@blue-6: #3491fa; +@gray-1: #f7f8fa; +@gray-2: #f2f3f5; +@gray-3: #e5e6eb; +@gray-4: #c9cdd4; +@gray-6: #86909c; +@gray-8: #4e5969; +@gray-9: #272e3b; +@gray-10: #1d2129; + +// -------- New Colors ----------- +@new-primary-default: #4450dc; +@new-primary-hover: #6171e3; +@new-primary-active: #2b32b8; +@new-primary-disable: #a1b0f1; +@new-primary-hover-gray: #c4cff8; + +// -------- Colors ----------- +@primary-color: @new-primary-default; +@info-color: @primary-color; +@success-color: @green-6; +@processing-color: @blue-6; +@error-color: @red-6; +@error-color-light: @red-1; +@highlight-color: @red-5; +@warning-color: @orange-6; +@normal-color: @gray-9; + +@text-color: @gray-8; +@text-color-strong: @gray-10; +@text-color-strong-secondary: #4e5969; +@text-color-secondary: @gray-6; +@text-color-disabled: @gray-4; +@text-color-inverse: white; + +@component-background-color-gray: @gray-2; + +@background-color: @gray-1; +@background-color-gray: @gray-3; +@background-color-error: @error-color-light; +@background-color-error-hover: @red-2; + +@line-color: #e5e8ef; + +@header-background: @primary-color; +@header-border-bottom-width: 0; +@header-logo-height: 42px; +@header-project-color: #fff; +@header-padding-left: 0; +@header-padding-right: 30px; + +// ----------- Transitions ---------------------- +@common-timing: cubic-bezier(0.4, 0, 0.2, 1); + +// ----------- Fonts ----------------------- +@font-family: 'nunito_for_arco', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; + +// ---------- Spacing ---------------------- +@header-height: 60px; +@page-header-height: 44px; +@content-outer-padding: 16px; // padding between real content and header&sidebar +@content-min-height: calc(100vh - @header-height - @content-outer-padding * 2); +@text-font-size-primary: 12px; + +// ---------- ZIndex ---------------------- +@z-index-less-than-modal: 999; diff --git a/web_console_v2/client/src/styles/variables/normal.less b/web_console_v2/client/src/styles/variables/normal.less new file mode 100644 index 000000000..668797422 --- /dev/null +++ b/web_console_v2/client/src/styles/variables/normal.less @@ -0,0 +1,75 @@ +// -------- Colors palette ----------- +@red-1: #ffece8; +@red-2: #fdcdc5; +@red-5: #f76560; +@red-6: #f53f3f; +@orange-6: #ff7d00; +@green-6: #00b42a; +@blue-4: #7bc0fc; +@blue-6: #3491fa; +@gray-1: #f7f8fa; +@gray-2: #f2f3f5; +@gray-3: #e5e6eb; +@gray-4: #c9cdd4; +@gray-6: #86909c; +@gray-8: #4e5969; +@gray-9: #272e3b; +@gray-10: #1d2129; + +// -------- New Colors ----------- +@new-primary-default: #1664ff; +@new-primary-hover: #4086ff; +@new-primary-active: #0e49d2; +@new-primary-disable: #94c2ff; +@new-primary-hover-gray: #bedcff; + +// -------- Colors ----------- +@primary-color: @new-primary-default; +@info-color: @primary-color; +@success-color: @green-6; +@processing-color: @blue-6; +@error-color: @red-6; +@error-color-light: @red-1; +@highlight-color: @red-5; +@warning-color: @orange-6; +@normal-color: @gray-9; + +@text-color: @gray-8; +@text-color-strong: @gray-10; +@text-color-strong-secondary: #4e5969; +@text-color-secondary: @gray-6; +@text-color-disabled: @gray-4; +@text-color-inverse: white; + +@component-background-color-gray: #f2f3f8; + +@background-color: @gray-1; +@background-color-gray: @gray-3; +@background-color-error: @error-color-light; +@background-color-error-hover: @red-2; + +@line-color: #e5e8ef; + +@header-background: @gray-10; +@header-border-bottom-width: 0; +@header-logo-height: 28px; +@header-project-color: #fff; +@header-padding-left: 30px; +@header-padding-right: 30px; + +// ----------- Transitions ---------------------- +@common-timing: cubic-bezier(0.4, 0, 0.2, 1); + +// ----------- Fonts ----------------------- +@font-family: 'nunito_for_arco', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', '微软雅黑', Arial, sans-serif; + +// ---------- Spacing ---------------------- +@header-height: 60px; +@page-header-height: 44px; +@content-outer-padding: 16px; // padding between real content and header&sidebar +@content-min-height: calc(100vh - @header-height - @content-outer-padding * 2); +@text-font-size-primary: 12px; + +// ---------- ZIndex ---------------------- +@z-index-less-than-modal: 999; diff --git a/web_console_v2/client/src/typings/algorithm.ts b/web_console_v2/client/src/typings/algorithm.ts new file mode 100644 index 000000000..0d6a9eb6c --- /dev/null +++ b/web_console_v2/client/src/typings/algorithm.ts @@ -0,0 +1,147 @@ +import { DateTimeInfo } from 'typings/app'; +import { ValueType } from 'typings/settings'; + +export interface FileQueryParams { + path: string; +} +export interface FileTreeNode { + filename: string; + path: string; + /** File size */ + size: number; + /** Last Time Modified */ + mtime: number; + is_directory: boolean; + files: FileTreeNode[]; +} + +export enum EnumAlgorithmProjectType { + UNSPECIFIED = 'UNSPECIFIED', + TREE_VERTICAL = 'TREE_VERTICAL', + TREE_HORIZONTAL = 'TREE_HORIZONTAL', + NN_VERTICAL = 'NN_VERTICAL', + NN_HORIZONTAL = 'NN_HORIZONTAL', + TRUSTED_COMPUTING = 'TRUSTED_COMPUTING', + NN_LOCAL = 'NN_LOCAL', +} + +export enum EnumAlgorithmProjectSource { + PRESET = 'PRESET', + USER = 'USER', + THIRD_PARTY = 'THIRD_PARTY', +} + +export enum AlgorithmStatus { + UNPUBLISHED = 'UNPUBLISHED', + PUBLISHED = 'PUBLISHED', +} + +export enum AlgorithmReleaseStatus { + UNRELEASED = 'UNRELEASED', + RELEASED = 'RELEASED', +} + +export enum AlgorithmVersionStatus { + UNPUBLISHED = 'UNPUBLISHED', + PUBLISHED = 'PUBLISHED', + PENDING = 'PENDING', + DECLINED = 'DECLINED', + APPROVED = 'APPROVED', +} + +export type AlgorithmParameter = { + name: string; + value: string; + required: boolean; + display_name: string; + comment: string; + value_type: ValueType; +}; + +export type AlgorithmProject = { + id: ID; + name: string; + project_id: ID; + type: EnumAlgorithmProjectType; + source?: EnumAlgorithmProjectSource | `${EnumAlgorithmProjectSource}`; + creator_id: ID | null; + username: string; + participant_id: ID | null; + participant_name?: string; + path: string; + publish_status: AlgorithmStatus | `${AlgorithmStatus}`; + release_status: AlgorithmReleaseStatus | `${AlgorithmReleaseStatus}`; + algorithms?: Algorithm[]; + parameter: { + variables: AlgorithmParameter[]; + } | null; + favorite?: boolean; + comment: string | null; + latest_version: number; + uuid?: ID; +} & DateTimeInfo; + +export type Algorithm = Omit< + AlgorithmProject, + 'publish_status' | 'release_status' | 'latest_version' +> & { + version: number; + algorithm_project_id: ID; + algorithm_project_uuid?: ID; + status: AlgorithmVersionStatus; + uuid?: ID; + participant_id?: ID | null; +}; + +export interface UploadFileQueryParams { + /** Parent path */ + path: string; + /** File name */ + filename: string; + /** File */ + file: File; +} +export interface UpdateFileQueryParams { + /** Parent path */ + path: string; + /** File name */ + filename: string; + is_directory: boolean; + /** File Content */ + file?: string; +} +export interface RenameFileQueryParams { + /** Old full file Path */ + path: string; + /** New full file path */ + dest: string; +} +export interface DeleteFileQueryParams { + /** Full File path */ + path: string; +} + +export interface FileContent { + path: string; + filename: string; + content: string; +} +export enum OperationType { + ADD = 'ADD', + EDIT = 'EDIT', + DELETE = 'DELETE', + RENAME = 'RENAME', +} + +export interface OperationRecord { + type: `${OperationType}`; + path: string; + isFolder: boolean; + content?: string; + newPath?: string; +} +export interface AlgorithmProjectQuery { + keyword?: string; + type?: string; + sources?: string; +} diff --git a/web_console_v2/client/src/typings/audit.ts b/web_console_v2/client/src/typings/audit.ts new file mode 100644 index 000000000..b7ceffcee --- /dev/null +++ b/web_console_v2/client/src/typings/audit.ts @@ -0,0 +1,85 @@ +import { FedUserInfo } from './auth'; + +export interface Audit { + id: number; + uuid: string; + name: string; + user: FedUserInfo; + user_id: number; + resource_type: string; + resource_name: string; + /** operation type */ + op_type: string; + /** event result */ + result: string; + /** event source */ + source: string; + extra: string | 'null' | null; + request_id: string; + access_key_id: string; + error_code: string; + source_ip: string; + created_at: DateTime; + updated_at: DateTime; + deleted_at: DateTime; + coordinator_pure_domain_name?: string; + event_id?: number; + project_id?: number; + result_code?: string; +} + +export interface AuditQueryParams { + username?: string; + /** event name */ + // name?: string; + // resource_type?: string; + // resource_name?: string; + /** operation type */ + filter?: string; + op_type?: string; + // start_time: DateTime; + // end_time: DateTime; + /** current page, default: 1 */ + page?: number; + /** each page count, default: 10 */ + page_size?: number; +} + +export interface AuditDeleteParams { + event_type?: string; +} + +export enum EventType { + INNER = 'inner', + CROSS_DOMAIN = 'cross_domain', +} + +export type QueryParams = { + keyword?: string; + selectType?: SelectType; + startTime?: string; + endTime?: string; + radioType?: RadioType; + crossDomainSelectType?: CrossDomainSelectType; + eventType?: EventType; +}; +export enum RadioType { + ALL = 'all', + WEEK = 'week', + ONE_MONTH = 'one_month', + THREE_MONTHS = 'three_month', +} + +export enum SelectType { + EVENT_NAME = 'name', + RESOURCE_TYPE = 'resource_type', + USER_NAME = 'username', + RESOURCE_NAME = 'resource_name', +} +export enum CrossDomainSelectType { + EVENT_NAME = 'name', + RESOURCE_TYPE = 'resource_type', + COORDINATOR_PURE_DOMAIN_NAME = 'coordinator_pure_domain_name', + RESOURCE_NAME = 'resource_name', + OP_TYPE = 'op_type', +} diff --git a/web_console_v2/client/src/typings/cleanup.ts b/web_console_v2/client/src/typings/cleanup.ts new file mode 100644 index 000000000..f6ec5f901 --- /dev/null +++ b/web_console_v2/client/src/typings/cleanup.ts @@ -0,0 +1,26 @@ +export enum CleanupState { + WAITING = 'WAITING', + RUNNING = 'RUNNING', + SUCCEEDED = 'SUCCEEDED', + FAILED = 'FAILED', + CANCELED = 'CANCELED', +} +export interface Cleanup { + id: ID; + payload: { + paths: string[]; + }; + resource_id: ID; + resource_type: string; + state: CleanupState; + target_start_at: DateTime; + updated_at: DateTime; + completed_at: DateTime; + created_at: DateTime; +} + +export interface CleanupQueryParams { + filter?: string; + page?: number; + pageSize?: number; +} diff --git a/web_console_v2/client/src/typings/composer.ts b/web_console_v2/client/src/typings/composer.ts new file mode 100644 index 000000000..710da6431 --- /dev/null +++ b/web_console_v2/client/src/typings/composer.ts @@ -0,0 +1,44 @@ +export enum ItemStatus { + ON = 'ON', + OFF = 'OFF', +} + +export enum RunnerStatus { + INIT = 'INIT', + RUNNING = 'RUNNING', + DONE = 'DONE', + FAILED = 'FAILED', +} + +export interface SchedulerItem { + id: ID; + name: string; + status: ItemStatus; + cron_config?: string; + last_run_at: DateTime; + retry_cnt: number; + created_at: DateTime; + updated_at: DateTime; + deleted_at?: DateTime; + pipeline: Object; +} + +export interface SchedulerRunner { + id: ID; + item_id: ID; + status: RunnerStatus; + start_at: DateTime; + end_at: DateTime; + created_at: DateTime; + updated_at: DateTime; + deleted_at?: DateTime; + pipeline: Object; + context: Object; + output: Object; +} + +export interface SchedulerQueryParams { + filter?: string; + page?: number; + pageSize?: number; +} diff --git a/web_console_v2/client/src/typings/filter.ts b/web_console_v2/client/src/typings/filter.ts new file mode 100644 index 000000000..cd3a0b752 --- /dev/null +++ b/web_console_v2/client/src/typings/filter.ts @@ -0,0 +1,28 @@ +export enum FilterOp { + UNKNOWN = 'UNKNOWN', // 0 + EQUAL = 'EQUAL', // = + IN = 'IN', // : + CONTAIN = 'CONTAIN', // ~= + GREATER_THAN = 'GREATER_THAN', // > + LESS_THAN = 'LESS_THAN', // < +} + +export enum FilterExpressionKind { + BASIC = 'basic', + AND = 'and', + OR = 'or', +} + +export interface SimpleExpression { + field: string; + op: FilterOp; + bool_value?: boolean; + string_value?: string; + number_value?: number; +} + +export interface FilterExpression { + kind: FilterExpressionKind; + simple_exp?: SimpleExpression; + exps?: FilterExpression[]; +} diff --git a/web_console_v2/client/src/typings/flag.ts b/web_console_v2/client/src/typings/flag.ts new file mode 100644 index 000000000..fc942f2f3 --- /dev/null +++ b/web_console_v2/client/src/typings/flag.ts @@ -0,0 +1,28 @@ +export enum FlagKey { + USER_MANAGEMENT_ENABLED = 'user_management_enabled', + LOGO_URL = 'logo_url', + PRESET_TEMPLATE_EDIT_ENABLED = 'preset_template_edit_enabled', + BCS_SUPPORT_ENABLED = 'bcs_support_enabled', + TRUSTED_COMPUTING_ENABLED = 'trusted_computing_enabled', + DASHBOARD_ENABLED = 'dashboard_enabled', + DATASET_STATE_FIX_ENABLED = 'dataset_state_fix_enabled', + HASH_DATA_JOIN_ENABLED = 'hash_data_join_enabled', + HELP_DOC_URL = 'help_doc_url', + REVIEW_CENTER_CONFIGURATION = 'review_center_configuration', + MODEL_JOB_GLOBAL_CONFIG_ENABLED = 'model_job_global_config_enabled', +} + +export interface Flag { + [FlagKey.USER_MANAGEMENT_ENABLED]: boolean; + [FlagKey.LOGO_URL]: string; + [FlagKey.PRESET_TEMPLATE_EDIT_ENABLED]: boolean; + [FlagKey.BCS_SUPPORT_ENABLED]: boolean; + [FlagKey.TRUSTED_COMPUTING_ENABLED]: boolean; + [FlagKey.DASHBOARD_ENABLED]: boolean; + [FlagKey.DATASET_STATE_FIX_ENABLED]: boolean; + [FlagKey.HASH_DATA_JOIN_ENABLED]: boolean; + [FlagKey.MODEL_JOB_GLOBAL_CONFIG_ENABLED]: boolean; + [FlagKey.HELP_DOC_URL]: string; + [FlagKey.REVIEW_CENTER_CONFIGURATION]: string; + [key: string]: boolean | string; +} diff --git a/web_console_v2/client/src/typings/modelCenter.ts b/web_console_v2/client/src/typings/modelCenter.ts new file mode 100644 index 000000000..a6fac4dca --- /dev/null +++ b/web_console_v2/client/src/typings/modelCenter.ts @@ -0,0 +1,471 @@ +import { DateTimeInfo } from 'typings/app'; +import { Workflow, WorkflowConfig, WorkflowState } from 'typings/workflow'; +import { JobExecutionDetalis } from 'typings/job'; +import { EnumAlgorithmProjectType } from './algorithm'; +import { Variable, VariableWidgetSchema } from './variable'; + +export enum ModelManagementTabType { + SET = 'model-set', + FAVOURITE = 'model-favourit', +} +export enum ModelEvaluationTabType { + EVALUATION = 'evaluation', + COMPARE = 'compare', +} + +export enum AlgorithmManagementTabType { + MY = 'my', + BUILT_IN = 'built-in', + PARTICIPANT = 'participant', +} + +export enum AlgorithmDetailTabType { + PREVIEW = 'preview', + CHANGE_LOG = 'change-log', +} + +export enum ResourceTemplateType { + HIGH = 'high', + MEDIUM = 'medium', + LOW = 'low', + CUSTOM = 'custom', +} + +export enum FederationType { + CROSS_SAMPLE = 'cross_sample', + CROSS_FEATURE = 'cross_feature', +} +export enum UploadType { + PATH = 'path', + LOCAL = 'local', +} + +export enum LossType { + LOGISTIC = 'logistic', + MSE = 'mse', +} + +export enum AlgorithmType { + TREE = 'tree', + NN = 'nn', +} + +export enum FederalType { + VERTICAL = 'vertical', + HORIZONTAL = 'horizontal', +} + +export enum Role { + LEADER = 'leader', + FOLLOWER = 'follower', +} +export enum RoleUppercase { + LEADER = 'Leader', + FOLLOWER = 'Follower', +} +export enum TrainRoleType { + LABEL = RoleUppercase.LEADER, + FEATURE = RoleUppercase.FOLLOWER, +} + +export { WorkflowState as ModelJobState }; + +export enum ModelJobRole { + COORDINATOR = 'COORDINATOR', + PARTICIPANT = 'PARTICIPANT', +} + +export type ModelJobType = + | 'UNSPECIFIED' + | 'NN_TRAINING' + | 'TREE_TRAINING' + | 'NN_EVALUATION' + | 'TREE_EVALUATION' + | 'NN_PREDICTION' + | 'TREE_PREDICTION' + | 'TRAINING' + | 'EVALUATION' + | 'PREDICTION'; + +export type ModelSet = { + id: number; + name: string; + comment: string | null; + created_at: DateTime; + updated_at: DateTime; + deleted_at: DateTime; + extra: any; + + algorithm?: string; + version?: string; + creator?: string; + project_id?: string; + isCompareReport?: boolean; + selectedModelIdList?: string[]; +}; + +export type ModelSetCreatePayload = { + name: string; + project_id: ID; + comment?: string; + extra?: any; +}; +export type ModelSetUpdatePayload = { + comment?: string; + extra?: any; +}; + +export type ModelJobGroup = { + id: ID; + uuid: ID; + project_id: ID; + name: string; + comment: string | null; + role: ModelJobRole | `${ModelJobRole}`; + authorized: boolean; + intersection_dataset_id: ID; + dataset_id: ID; + algorithm_type: EnumAlgorithmProjectType | `${EnumAlgorithmProjectType}`; + algorithm_project_id: ID; + algorithm_id: ID; + configured: boolean; + config?: WorkflowConfig; + latest_job_state: ModelJobStatus; + latest_version: number; + model_jobs?: ModelJob[]; + creator_username: string; + coordinator_id?: ID; + auth_frontend_status?: ModelGroupStatus; + participants_info?: { participants_map: Record<string, any> }; + auto_update_status?: AutoModelJobStatus; + + // TODO: explicit type + extra: unknown; + + /** TODO: custom field, will be deleted soon */ + creator: string; + cron_config: string; + algorithm_project_uuid_list: + | { algorithm_projects: { [pure_domain_name: string]: ID } } + | undefined; +} & DateTimeInfo; +export type ModelJobGroupCreatePayload = { + name: string; + algorithm_type: EnumAlgorithmProjectType | `${EnumAlgorithmProjectType}`; + dataset_id?: ID; +}; +export type ModelJobGlobalConfig = { + dataset_uuid?: ID; + global_config: { + [pure_domain_name: string]: { + algorithm_uuid?: ID; + //TODO:support param algorithm_project_uuid + algorithm_project_uuid?: ID; + algorithm_parameter?: { ['variables']: AlgorithmParams[] | undefined }; + variables: Variable[]; + }; + }; +}; +export type ModelJobGroupUpdatePayload = { + authorized?: boolean; + dataset_id?: ID; + algorithm_id?: ID; + config?: WorkflowConfig; + comment?: string; + cron_config?: string; + global_config?: ModelJobGlobalConfig; +}; +export type PeerModelJobGroupUpdatePayload = { + config?: WorkflowConfig; + global_config?: ModelJobGlobalConfig; +}; + +export type ModelJobMetrics = { + train: { + [key: string]: + | [number[], number[]] + | { + steps: number[]; + values: number[]; + }; + }; + eval: { + [key: string]: + | [number[], number[]] + | { + steps: number[]; + values: number[]; + }; + }; + feature_importance: { [featureName: string]: number }; + confusion_matrix?: { + /** True Postive, position: top left */ + tp: number; + /** False Postive, position: top right */ + fp: number; + /** False Negative, position: bottom left */ + fn: number; + /** True Negative, position: bottom right */ + tn: number; + }; +}; + +export type Item = { + label: string; + value: number | null; +}; +export type FormattedModelJobMetrics = { + train: Item[]; + eval: Item[]; + feature_importance: Item[]; + confusion_matrix?: Array<Item & { percentValue: string }>; + trainMaxValue: number; + evalMaxValue: number; + featureImportanceMaxValue: number; +}; + +export type ModelJobQueryParams = { + projectId?: ID; + types?: Array<ModelJobType>; +}; + +export interface ModelJobQueryParams_new { + keyword?: string; + project_id?: string; + group_id?: string; + algorithm_types?: AlgorithmType[]; + states?: WorkflowState[]; + page?: number; + page_size?: number; + types?: 'EVALUATION' | 'PREDICTION'; + role?: string; + filter?: string; +} + +export type ModelJob = { + id: number; + uuid: ID; + name: string; + comment: string; + model_job_type: ModelJobType; + model_name?: string; + state: WorkflowState; + status: ModelJobStatus; + job_name: string; + job_id: number; + workflow_id: number; + workflow_uuid: ID; + /** model set id */ + group_id: number; + project_id: number; + code_id: number; + dataset_id: number; + dataset_name?: string; + intersection_dataset_id: number; + intersection_dataset_name?: string; + model_id: number; + params: any; + algorithm_type: EnumAlgorithmProjectType; + algorithm_id: ID; + role: 'COORDINATOR' | 'PARTICIPANT'; + config: WorkflowConfig; + parent?: Model; + metrics: ModelJobMetrics; + version: number; + + extra: any; + local_extra: any; + detail_level: any[]; + + created_at: DateTime; + updated_at: DateTime; + deleted_at: DateTime; + started_at?: DateTime; + stopped_at?: DateTime; + + job: JobExecutionDetalis; + workflow: Workflow; + + /** no source field */ + 'model.name'?: string; + 'model.desc'?: string; + 'model.dataset_id'?: any; + 'model.group_id'?: any; + 'model.parent_job_name'?: string; + + formattedMetrics?: FormattedModelJobMetrics; + + output_models: Model[]; + creator_username: string; + metric_is_public: boolean; + global_config?: ModelJobGlobalConfig; + auto_update?: boolean; + data_batch_id?: ID; +}; + +export type ModelUpdatePayload = { + comment?: string; +}; + +export type ModelType = 'UNSPECIFIED' | 'TREE_MODEL' | 'NN_MODEL'; + +export type Model = { + id: number; + uuid: string; + name: string; + comment: string; + model_type: ModelType; + model_path: string; + // TODO: federated_type + federated_type: string; + version: number | null; + favorite: boolean; + /** Model set id */ + group_id: ID | null; + project_id: ID | null; + model_job_id: ID | null; + model_job_name: string | null; + job_id: ID | null; + job_name: string | null; + workflow_id: ID | null; + workflow_name: string | null; +} & DateTimeInfo; + +export type Algorithm = { + id: number; + name: string; + comment: string; + state: string; + type: string; + file: string; + file_owner: string; + file_no_owner: string; + project_id?: ID; + + creator: string; + created_at: DateTime; + updated_at: DateTime; + deleted_at: DateTime; +}; + +export type AlgorithmChangeLog = { + id: number; + comment: string; + creator: string; + created_at: DateTime; + updated_at: DateTime; + deleted_at: DateTime; +}; + +export type FakeAlgorithm = { + id: number; + name: string; + value: string; + comment: string; + type: ModelType; + + created_at: DateTime; + updated_at: DateTime; + deleted_at: DateTime; +}; + +export type AlgorithmParams = { + id?: string; + name: string; + display_name: string; + required: boolean; + comment: string; + value: string; +}; + +export interface ModelJobCreateFormData { + name: string; + model_job_type: ModelJobType; + algorithm_type: AlgorithmType; + algorithm_id: string; + eval_model_job_id?: string; + dataset_id: string; + config: Record<string, any>; + comment?: string; + model_id?: string; +} + +export type ModelJobPatchFormData = Pick< + ModelJobCreateFormData, + 'algorithm_id' | 'dataset_id' | 'config' | 'comment' +>; + +export interface ModelJobGroupCreateForm { + name: string; + comment: string | undefined; + dataset_id: ID; + algorithm_type: EnumAlgorithmProjectType; + algorithm_project_list?: { algorithmProjects: { [pure_domain_name: string]: ID } }; + loss_type?: LossType; +} +export interface ModelJobTrainCreateForm { + name: string; + model_job_type: ModelJobType; + algorithm_type?: EnumAlgorithmProjectType; + dataset_id?: string; + comment?: string; + global_config?: ModelJobGlobalConfig; + group_id?: ID; + data_batch_id?: ID; +} + +export interface ModelJobDefinitionQueryParams { + model_job_type: string; + algorithm_type: string; +} + +export interface ModelJobDefinitionResult { + is_federated: boolean; + variables: Variable[]; +} + +export type ModelJobVariable = Omit<Variable, 'widget_schema'> & { + widget_schema: string | VariableWidgetSchema; +}; +// TODO: 等后端定义 +export enum ModelGroupStatus { + TICKET_PENDING = 'TICKET_PENDING', + TICKET_DECLINE = 'TICKET_DECLINE', + CREATE_PENDING = 'CREATE_PENDING', + CREATE_FAILED = 'CREATE_FAILED', + SELF_AUTH_PENDING = 'SELF_AUTH_PENDING', + PART_AUTH_PENDING = 'PART_AUTH_PENDING', + ALL_AUTHORIZED = 'ALL_AUTHORIZED', +} + +export enum EnumModelJobType { + TRAINING = 'TRAINING', + EVALUATION = 'EVALUATION', + PREDICTION = 'PREDICTION', +} + +export type TRouteParams = { + id: string; + step: keyof typeof CreateSteps; + type: string; +}; +export enum CreateSteps { + coordinator = 1, + participant = 2, +} + +export enum AutoModelJobStatus { + INITIAL = 'INITIAL', + ACTIVE = 'ACTIVE', + STOPPED = 'STOPPED', +} +export enum ModelJobStatus { + PENDING = 'PENDING', + CONFIGURED = 'CONFIGURED', + ERROR = 'ERROR', + RUNNING = 'RUNNING', + STOPPED = 'STOPPED', + SUCCEEDED = 'SUCCEEDED', + FAILED = 'FAILED', + UNKNOWN = 'UNKNOWN', +} diff --git a/web_console_v2/client/src/typings/modelServing.ts b/web_console_v2/client/src/typings/modelServing.ts new file mode 100644 index 000000000..bbb8aa84a --- /dev/null +++ b/web_console_v2/client/src/typings/modelServing.ts @@ -0,0 +1,80 @@ +import { PageQueryParams, DateTimeInfo } from 'typings/app'; +import { ModelType } from 'typings/modelCenter'; + +export enum ModelDirectionType { + VERTICAL = 'vertical', + HORIZONTAL = 'horizontal', +} +export enum ModelServingState { + UNKNOWN = 'UNKNOWN', + LOADING = 'LOADING', + AVAILABLE = 'AVAILABLE', + UNLOADING = 'UNLOADING', + DELETED = 'DELETED', + PENDING_ACCEPT = 'PENDING_ACCEPT', + WAITING_CONFIG = 'WAITING_CONFIG', +} +export enum ModelServingInstanceState { + AVAILABLE = 'AVAILABLE', + UNAVAILABLE = 'UNAVAILABLE', +} + +export enum ModelServingDetailTabType { + USER_GUIDE = 'user-guide', + INSTANCE_LIST = 'instance-list', +} + +export interface ModelServingQueryParams extends PageQueryParams { + name?: string; + project_id?: ID; + keyword?: string; + filter?: string; + order_by?: string; +} + +export interface ModelServingInstance extends DateTimeInfo { + name: string; + status: ModelServingInstanceState; + cpu: string; + memory: string; +} + +export interface ModelServingResource { + cpu: string; + memory: string; + replicas: number; +} + +export interface ModelServing extends DateTimeInfo { + id: number; + project_id: number; + name: string; + comment: string; + instances: ModelServingInstance[]; + deployment_id: number; + resource: ModelServingResource; + model_id: number; + is_local: boolean; // 横向:true,纵向:false + support_inference: boolean; + model_type: ModelType; + + status: ModelServingState; + /** URL */ + endpoint: string; + /** API Input and Output */ + signature: string; + extra: any; + instance_num_status: string; + model_group_id?: number; + psm?: string; + remote_platform?: RemotePlatform; +} + +export interface UserTypeInfo { + type: string; +} + +export interface RemotePlatform { + payload: string; + platform: string; +} diff --git a/web_console_v2/client/src/typings/operation.ts b/web_console_v2/client/src/typings/operation.ts new file mode 100644 index 000000000..be3f3b321 --- /dev/null +++ b/web_console_v2/client/src/typings/operation.ts @@ -0,0 +1,30 @@ +export enum Role { + COORDINATOR = 'coordinator', + PARTICIPANT = 'participant', +} + +export type JobGroupFetchPayload = { + role: string; + name_prefix: string; + project_name: string; + e2e_image_url: string; + fedlearner_image_uri: string; + platform_endpoint?: string; +}; + +export type JobItem = { + job_name: string; + job_type: string; +}; + +export type JobInfo = { + job_name: string; + log: Array<string>; + status: object; +}; + +export type Dashboard = { + name: string; + url: string; + uuid: string; +}; diff --git a/web_console_v2/client/src/typings/participant.ts b/web_console_v2/client/src/typings/participant.ts new file mode 100644 index 000000000..66dce962e --- /dev/null +++ b/web_console_v2/client/src/typings/participant.ts @@ -0,0 +1,71 @@ +export enum ParticipantType { + PLATFORM = 'PLATFORM', + LIGHT_CLIENT = 'LIGHT_CLIENT', +} + +export interface UpdateParticipantPayload { + name?: string; + domain_name?: string; + grpc_ssl_server_host: string; + host?: number; + port?: string; + certificates?: string; + comment?: string; + type: ParticipantType | `${ParticipantType}`; +} + +export interface CreateParticipantPayload { + name: string; + domain_name: string; + is_manual_configured: boolean; + grpc_ssl_server_host?: string; + host?: string; + port?: number; + certificates?: string; + comment?: string; + type: ParticipantType | `${ParticipantType}`; +} + +export interface Participant { + id: number; + name: string; + domain_name: string; + pure_domain_name: string; + extra: { + is_manual_configured: boolean; + grpc_ssl_server_host?: string; + }; + host?: string; + port?: number; + certificates?: string; + comment?: string | null; + created_at?: number; + updated_at?: number; + num_project?: number; + type: ParticipantType | `${ParticipantType}`; + last_connected_at?: number; + support_blockchain?: boolean; +} + +export enum ConnectionStatusType { + Success = 'success', + Fail = 'error', + Processing = 'processing', +} + +export interface Version { + pub_date?: string; + revision?: string; + branch_name?: string; + version?: string; +} + +export interface ConnectionStatus { + success: ConnectionStatusType; + message: string; + application_version: Version; +} + +export interface DomainName { + domain_name: string; +} diff --git a/web_console_v2/client/src/typings/trustedCenter.ts b/web_console_v2/client/src/typings/trustedCenter.ts new file mode 100644 index 000000000..576c6387c --- /dev/null +++ b/web_console_v2/client/src/typings/trustedCenter.ts @@ -0,0 +1,206 @@ +export enum TrustedJobGroupDisplayStatus { + CREATE_PENDING = 'CREATE_PENDING', + CREATE_FAILED = 'CREATE_FAILED', + TICKET_PENDING = 'TICKET_PENDING', + TICKET_DECLINED = 'TICKET_DECLINED', + SELF_AUTH_PENDING = 'SELF_AUTH_PENDING', + PART_AUTH_PENDING = 'PART_AUTH_PENDING', + JOB_PENDING = 'JOB_PENDING', + JOB_RUNNING = 'JOB_RUNNING', + JOB_SUCCEEDED = 'JOB_SUCCEEDED', + JOB_FAILED = 'JOB_FAILED', + JOB_STOPPED = 'JOB_STOPPED', +} + +export enum TrustedJobGroupStatus { + PENDING = 'PENDING', + SUCCEEDED = 'SUCCEEDED', + FAILED = 'FAILED', +} + +export enum AuthStatus { + PENDING = 'PENDING', + WITHDRAW = 'WITHDRAW', + AUTHORIZED = 'AUTHORIZED', +} + +export enum TrustedJobType { + ANALYZE = 'ANALYZE', + EXPORT = 'EXPORT', +} + +export enum TrustedJobStatus { + NEW = 'NEW', + CREATED = 'CREATED', + PENDING = 'PENDING', + RUNNING = 'RUNNING', + SUCCEEDED = 'SUCCEEDED', + FAILED = 'FAILED', + STOPPED = 'STOPPED', +} + +export enum TicketStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + DECLINED = 'DECLINED', +} + +export enum TrustedJobRole { + COORDINATOR = 'COORDINATOR', + PARTICIPANT = 'PARTICIPANT', +} + +export enum ResourceTemplateType { + HIGH = 'high', + MEDIUM = 'medium', + LOW = 'low', + CUSTOM = 'custom', +} + +export enum NotificationType { + TRUSTED_JOB_GROUP_CREATE = 'TRUSTED_JOB_GROUP_CREATE', + TRUSTED_JOB_EXPORT = 'TRUSTED_JOB_EXPORT', +} + +export enum TrustedJobParamType { + ANALYZE = 'ANALYZE', + EXPORT = 'EXPORT', +} + +export enum TicketAuthStatus { + CREATE_PENDING = 'CREATE_PENDING', + CREATE_FAILED = 'CREATE_FAILED', + TICKET_PENDING = 'TICKET_PENDING', + TICKET_DECLINED = 'TICKET_DECLINED', + AUTH_PENDING = 'AUTH_PENDING', + AUTHORIZED = 'AUTHORIZED', +} + +export interface TrustedJobResource { + cpu: number; + memory: number; + replicas?: number; +} + +export type ParticipantDataset = { + participant_id: ID; + uuid: ID; + name: string; +}; + +export type TrustedJobGroupPayload = { + name?: string; + comment?: string; + algorithm_id?: ID; + dataset_id?: ID; + participant_datasets?: ParticipantDataset[]; + resource?: TrustedJobResource; + auth_status?: AuthStatus; +}; + +export type datasetConstruct = { + items: ParticipantDataset[]; +}; + +export type TrustedJobGroup = { + id: ID; + name: string; + uuid: ID; + latest_version: string; + comment: string; + project_id: ID; + creator_username?: string; + created_at?: DateTime; + updated_at?: DateTime; + analyzer_id?: ID; + coordinator_id?: ID; + ticket_uuid: ID; + ticket_status: TicketStatus; + status?: TrustedJobGroupStatus; + auth_status?: AuthStatus; + latest_job_status?: TrustedJobStatus; + ticket_auth_status: TicketAuthStatus; + unauth_participant_ids?: ID[]; + algorithm_id?: ID; + algorithm_participant_id?: ID; + algorithm_uuid?: string; + algorithm_project_uuid?: string; + resource?: TrustedJobResource; + dataset_id?: ID; + creator_name: string; + participant_datasets: datasetConstruct; +}; + +export type TrustedJobGroupItem = { + id: ID; + name: string; + created_at: DateTime; + is_creator: boolean; + creator_id: ID; + ticket_status: TicketStatus; + is_configured?: boolean; + status?: TrustedJobGroupStatus; + auth_status?: AuthStatus; + latest_job_status?: TrustedJobStatus; + ticket_auth_status?: TicketAuthStatus; + participants_info: any; + unauth_participant_ids?: ID[]; +}; + +export type TrustedJob = { + id: ID; + name: string; + coordinator_id?: ID; + type?: TrustedJobType; + job_id: ID; + uuid: ID; + version: number; + comment: string; + project_id: ID; + trusted_job_group_id: ID; + started_at: DateTime; + finished_at: DateTime; + status: TrustedJobStatus; + algorithm_id: ID; + algorithm_uuid: ID; + resource: TrustedJobResource; + auth_status: AuthStatus; + dataset_job_id: ID; + ticket_uuid?: ID; + ticket_status?: string; + ticket_auth_status: TicketAuthStatus; + export_dataset_id?: ID; +}; + +export type TrustedJobListItem = { + id: ID; + name: string; + type?: TrustedJobType; + job_id: ID; + comment: string; + started_at: DateTime; + finished_at: DateTime; + status: TrustedJobStatus; + ticket_auth_status: TicketAuthStatus; + coordinator_id?: ID; +}; + +export type Instance = { + id: ID; + status: TrustedJobStatus; + created_at: DateTime; + resource: TrustedJobResource; +}; + +export type NotificationItem = { + type: NotificationType; + id: ID; + name: string; + created_at: DateTime; + coordinator_id: ID; +}; + +export enum TrustedJobGroupTabType { + COMPUTING = 'computing', + EXPORT = 'export', +} diff --git a/web_console_v2/client/src/typings/utils.ts b/web_console_v2/client/src/typings/utils.ts new file mode 100644 index 000000000..3d4984aae --- /dev/null +++ b/web_console_v2/client/src/typings/utils.ts @@ -0,0 +1,12 @@ +export type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U; + +export type Notification = { + /** workflow id */ + id?: number | null; + kind: string; + workflow_name: string; + peer_name: string; + project_name: string; + created_at: DateTime; + project_id: ID; +}; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmAcceptance/index.module.less b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmAcceptance/index.module.less new file mode 100644 index 000000000..5e74c24f3 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmAcceptance/index.module.less @@ -0,0 +1,55 @@ +.styled_container { + box-sizing: border-box; + width: 440px; + margin: 80px auto 20px auto; + padding: 40px; + border-radius: 8px; + border: 1px solid var(--color-border-2); +} + +.styled_header { + text-align: center; +} + +.styled_avatar { + display: inline-block; +} + +.styled_form_text { + line-height: 24px; +} + +.styled_form_footer { + text-align: center; +} + +.styled_form_footer_button { + padding-left: 40px; + padding-right: 40px; +} + +.styled_success_icon { + display: block; + width: 70px; + height: 70px; + background-image: url('../../../assets/icons/successful-status-icon.svg'); + background-size: 68px 68px; + background-position: center center; + background-repeat: no-repeat; +} + +.styled_result { + :global(.arco-result-icon) { + margin-bottom: 0; + } + :global(.arco-result-title) { + margin-bottom: 30px; + font-size: 16px; + } + :global(.arco-result-subtitle) { + font-size: 12px; + } + :global(.arco-result-extra) { + margin-top: 10px; + } +} diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmAcceptance/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmAcceptance/index.tsx new file mode 100644 index 000000000..8367b9fa7 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmAcceptance/index.tsx @@ -0,0 +1,247 @@ +import React, { FC, useState } from 'react'; +import styled from './index.module.less'; +import { useParams, useHistory } from 'react-router'; +import { useQuery, useMutation } from 'react-query'; +import { + Typography, + Divider, + Form, + Input, + Button, + Space, + Result, + Drawer, + RulesProps, + Message, +} from '@arco-design/web-react'; +import { useRecoilValue } from 'recoil'; +import BackButton from 'components/BackButton'; +import { + postAcceptAlgorithm, + fetchProjectPendingList, + fetchProjectDetail, +} from 'services/algorithm'; +import { projectState } from 'stores/project'; +import SharedPageLayout from 'components/SharedPageLayout'; +import { AlgorithmManagementTabType } from 'typings/modelCenter'; +import { validNamePattern, MAX_COMMENT_LENGTH } from 'shared/validator'; +import { useInterval } from 'hooks'; +import AlgorithmType from 'components/AlgorithmType'; +import AlgorithmInfo from 'components/AlgorithmDrawer/AlgorithmInfo'; +import { Avatar } from '../shared'; + +enum FormField { + NAME = 'name', + COMMENT = 'comment', +} + +const rules: Record<string, RulesProps[]> = { + [FormField.NAME]: [ + { required: true, message: '算法名称不能为空' }, + { + match: validNamePattern, + message: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ], + [FormField.COMMENT]: [ + { + maxLength: MAX_COMMENT_LENGTH, + message: '最多为 200 个字符', + }, + ], +}; + +type TMutationParams = { + projId: ID; + id: ID; + payload: { + name: string; + comment?: string; + }; +}; + +const REDIRECT_COUNTDOWN_DURATION = 3; +const AlgorithmAcceptance: FC = () => { + const [form] = Form.useForm(); + const selectedProject = useRecoilValue(projectState); + const { id } = useParams<{ id: string }>(); + const history = useHistory(); + const [successful, setSuccessful] = useState(false); + const [previewVisible, setPreviewVisible] = useState(false); + const [redirectCountdown, setRedirectCountdown] = useState(REDIRECT_COUNTDOWN_DURATION); + const query = useQuery( + ['algorithm_acceptance', id, selectedProject.current?.id], + () => { + return fetchProjectPendingList(selectedProject.current?.id).then((res) => { + const algorithm = res.data.find((item) => item.id.toString() === id); + if (!algorithm) { + throw new Error('算法不存在'); + } + return algorithm; + }); + }, + { + refetchOnWindowFocus: false, + retry: 1, + onSuccess(res) { + form.setFieldsValue(res); + }, + onError(e: any) { + Message.error(e.message); + }, + }, + ); + + // Get algorithm project name + useQuery( + ['fetch_algorithm_project_detail', query?.data?.algorithm_project_id], + () => { + return fetchProjectDetail(query!.data!.algorithm_project_id!); + }, + { + enabled: Boolean(query?.data?.algorithm_project_id), + onSuccess(res) { + if (res?.data?.name) { + form.setFieldsValue({ + name: res.data.name, + }); + } + }, + }, + ); + + const accept = useMutation( + ({ projId, id, payload }: TMutationParams) => { + return postAcceptAlgorithm(projId, id, payload); + }, + { + onSuccess() { + setSuccessful(true); + }, + onError(e: any) { + if (e.code === 409 || /already\s*exist/.test(e.message)) { + Message.error('算法名称已存在'); + } + }, + }, + ); + const detail = query.data; + const layoutTitle = <BackButton onClick={goBackMyAlgorithmList}>{'算法仓库'}</BackButton>; + + useInterval( + () => { + if (redirectCountdown === 0) { + goBackMyAlgorithmList(); + return; + } + setRedirectCountdown(redirectCountdown - 1); + }, + successful ? 1000 : undefined, + { + immediate: false, + }, + ); + + if (successful) { + return ( + <SharedPageLayout title={layoutTitle}> + <Result + className={styled.styled_result} + status={null} + icon={<i className={styled.styled_success_icon} />} + title={`已同意并保存『${form.getFieldValue('name')}-V${detail?.version}』`} + subTitle={`${redirectCountdown}S 后自动前往算法列表`} + extra={[ + <Button key="back" type="primary" onClick={goBackMyAlgorithmList}> + {'前往算法列表'} + </Button>, + ]} + /> + </SharedPageLayout> + ); + } + + return ( + <SharedPageLayout title={layoutTitle}> + <div className={styled.styled_container}> + <header className={styled.styled_header}> + <Avatar /> + <br /> + <Typography.Text type="secondary">{detail?.comment}</Typography.Text> + </header> + <Divider /> + <Form form={form} labelCol={{ span: 5 }} onSubmit={acceptAlgorithm}> + <Form.Item + label={'名称'} + field="name" + rules={rules[FormField.NAME]} + disabled={Boolean(detail?.algorithm_project_id)} + > + <Input placeholder={'请输入算法名称'} /> + </Form.Item> + <Form.Item label={'描述'} field="comment" rules={rules[FormField.COMMENT]}> + <Input.TextArea rows={2} placeholder={'最多为 200 个字符'} /> + </Form.Item> + <Form.Item label={'类型'}> + {detail?.type && <AlgorithmType type={detail.type} />} + </Form.Item> + <Form.Item label={'版本'}> + <div className={styled.styled_form_text}>V{detail?.version}</div> + </Form.Item> + <Form.Item label={'算法'}> + <button + type="button" + className="custom-text-button" + onClick={() => setPreviewVisible(true)} + > + {'查看算法配置'} + </button> + </Form.Item> + <div className={styled.styled_form_footer}> + <Space> + <Button + className={styled.styled_form_footer_button} + type="primary" + htmlType="submit" + loading={accept.isLoading} + > + {'同意并保存'} + </Button> + <Button className={styled.styled_form_footer_button} onClick={goBackMyAlgorithmList}> + {'取消'} + </Button> + </Space> + </div> + </Form> + </div> + <Drawer + closable + onCancel={() => setPreviewVisible(false)} + width={1000} + visible={previewVisible} + title={`算法版本 V${detail?.version}`} + > + <AlgorithmInfo type="pending_algorithm" detail={detail} /> + </Drawer> + </SharedPageLayout> + ); + + async function acceptAlgorithm(formValues: any) { + if (selectedProject.current?.id) { + accept.mutate({ + projId: selectedProject.current.id, + id: detail?.id!, + payload: { + name: formValues.name as string, + comment: formValues.comment as string, + }, + }); + } + } + + function goBackMyAlgorithmList() { + history.replace(`/algorithm-management/${AlgorithmManagementTabType.MY}`); + } +}; + +export default AlgorithmAcceptance; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/VersionsTab.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/VersionsTab.tsx new file mode 100644 index 000000000..13d389ff3 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/VersionsTab.tsx @@ -0,0 +1,283 @@ +import React, { FC, useMemo, useState } from 'react'; +import { Table, Button, Link, Drawer, Empty } from '@arco-design/web-react'; +import AlgorithmInfo from 'components/AlgorithmDrawer/AlgorithmInfo'; +import { + AlgorithmProject, + Algorithm, + EnumAlgorithmProjectSource, + AlgorithmVersionStatus, +} from 'typings/algorithm'; +import { formatTimestamp } from 'shared/date'; +import MoreActions from 'components/MoreActions'; +import { CONSTANTS } from 'shared/constants'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import { useGetCurrentProjectId, useGetCurrentProjectParticipantId } from 'hooks'; +import { useQuery } from 'react-query'; +import { fetchAlgorithmList, fetchPeerAlgorithmList } from 'services/algorithm'; +import styled from './index.module.less'; + +type TTableParams = { + onPreviewClick: (algorithm: Algorithm) => void; + onPublishClick: (algorithm: Algorithm) => void; + onUnpublishClick: (algorithm: Algorithm) => void; + onDeleteClick: (algorithm: Algorithm) => void; + onDownloadClick: (algorithm: Algorithm) => void; + algorithmProjectDetail: AlgorithmProject; + isParticipant?: boolean; +}; + +const calcStateIndicatorProps = ( + state: AlgorithmVersionStatus, +): { type: StateTypes; text: string; tip?: string } => { + const tip = ''; + const stateMap = new Map(); + stateMap.set(AlgorithmVersionStatus.UNPUBLISHED, { + text: '未发布', + type: 'gold', + }); + stateMap.set(AlgorithmVersionStatus.PUBLISHED, { + text: '已发布', + type: 'success', + }); + stateMap.set(AlgorithmVersionStatus.PENDING, { + text: '待审批', + type: 'goprocessingld', + }); + stateMap.set(AlgorithmVersionStatus.APPROVED, { + text: '已通过', + type: 'processing', + }); + stateMap.set(AlgorithmVersionStatus.DECLINED, { + text: '已拒绝', + type: 'error', + }); + return { + ...stateMap.get(state), + tip, + }; +}; + +const getTableProps = ({ + onPreviewClick, + onPublishClick, + onUnpublishClick, + onDeleteClick, + onDownloadClick, + algorithmProjectDetail, + isParticipant, +}: TTableParams) => { + const cols = [ + { + dataIndex: 'version', + title: '版本号', + render(version: number) { + return `V${version}`; + }, + }, + { + dataIndex: 'id', + title: '版本配置', + render(id: string, record: Algorithm) { + return <Link onClick={() => onPreviewClick(record)}>点击查看</Link>; + }, + }, + !isParticipant && + ({ + title: '状态', + dataIndex: 'status', + name: 'status', + width: 150, + render: (state: AlgorithmVersionStatus, record: any) => { + return <StateIndicator {...calcStateIndicatorProps(state)} />; + }, + } as any), + !isParticipant && + ({ + dataIndex: 'username', + title: '创建者', + } as any), + { + dataIndex: 'comment', + title: '描述', + render(val: string) { + return val || CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + dataIndex: 'created_at', + title: '发版时间', + render(val: number) { + return formatTimestamp(val); + }, + }, + !isParticipant && { + dataIndex: 'operation', + title: '操作', + render(_: any, record: Algorithm) { + return ( + <> + {(record.status === AlgorithmVersionStatus.PUBLISHED || + record.status === AlgorithmVersionStatus.APPROVED) && ( + <Button + className={styled.version_list_button} + type="text" + size="small" + onClick={() => { + onUnpublishClick(record); + }} + disabled={algorithmProjectDetail.source !== EnumAlgorithmProjectSource.USER} + > + {'撤销发布'} + </Button> + )} + {(record.status === AlgorithmVersionStatus.UNPUBLISHED || + record.status === AlgorithmVersionStatus.DECLINED) && ( + <Button + className={styled.version_list_button} + type="text" + size="small" + onClick={() => { + onPublishClick(record); + }} + disabled={algorithmProjectDetail.source !== EnumAlgorithmProjectSource.USER} + > + {'发布'} + </Button> + )} + <MoreActions + actionList={[ + { + label: '下载', + onClick: () => { + onDownloadClick(record); + }, + }, + { + label: '删除', + onClick: () => { + onDeleteClick(record); + }, + danger: true, + disabled: + algorithmProjectDetail.source !== EnumAlgorithmProjectSource.USER || + record.status === AlgorithmVersionStatus.PENDING, + }, + ]} + /> + </> + ); + }, + }, + ].filter(Boolean); + + return cols; +}; + +type Props = { + id?: ID; + detail?: AlgorithmProject; + isParticipant?: boolean; + isBuiltIn?: boolean; + onPublishClick: (algorithm: Algorithm) => void; + onUnpublishClick: (algorithm: Algorithm) => void; + onReleaseClick: () => void; + onDeleteClick: (algorithm: Algorithm) => void; + onDownloadClick: (algorithm: Algorithm) => void; +}; + +const VersionsTab: FC<Props> = ({ + id, + detail, + isParticipant, + isBuiltIn, + onPublishClick, + onUnpublishClick, + onReleaseClick, + onDeleteClick, + onDownloadClick, +}) => { + const projectId = useGetCurrentProjectId(); + const participantId = useGetCurrentProjectParticipantId(); + const listQuery = useQuery( + ['fetchAlgorithmList', projectId, participantId, id], + () => { + if (isParticipant) { + return fetchPeerAlgorithmList(projectId, participantId, { algorithm_project_uuid: id! }); + } + return fetchAlgorithmList(isBuiltIn ? 0 : projectId, { algo_project_id: id! }); + }, + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + const [previewAlgorithm, setPreviewAlgorithm] = useState<Algorithm>(); + + const formattedAlgorithms = useMemo(() => { + if (!listQuery.data?.data) { + return []; + } + return listQuery.data?.data; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [listQuery.data?.data]); + + if (!detail) { + return null; + } + return ( + <> + <Table + className="custom-table custom-table-left-side-filter" + hover={false} + data={formattedAlgorithms} + noDataElement={ + <Empty + description={ + <> + 暂无已发版的算法版本. + {!isBuiltIn && ( + <Button + className={styled.version_list_button} + size="small" + type="text" + style={{ marginLeft: '0.2em' }} + onClick={() => { + onReleaseClick(); + }} + > + 去发版 + </Button> + )} + </> + } + /> + } + columns={getTableProps({ + onPreviewClick, + onPublishClick, + onUnpublishClick, + onDeleteClick, + onDownloadClick, + algorithmProjectDetail: detail, + isParticipant: isParticipant, + })} + rowKey="uuid" + /> + <Drawer + closable + onCancel={() => setPreviewAlgorithm(undefined)} + width={1200} + visible={Boolean(previewAlgorithm)} + title={`算法版本 V${previewAlgorithm?.version}`} + > + <AlgorithmInfo isParticipant={isParticipant} type="algorithm" detail={previewAlgorithm} /> + </Drawer> + </> + ); + + function onPreviewClick(algorithm: Algorithm) { + setPreviewAlgorithm(algorithm); + } +}; + +export default VersionsTab; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/index.module.less b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/index.module.less new file mode 100644 index 000000000..1caf03a45 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/index.module.less @@ -0,0 +1,43 @@ +.version_list_button { + margin-left: -5px; + padding-left: 5px; + padding-right: 5px; +} + +.padding_container { + padding: 20px 20px 0; +} + +.styled_name { + margin-top: 0; + margin-bottom: -3px; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +.comment { + font-size: 12px; + color: var(--textColorSecondary); +} + +.content { + padding: 0 20px; +} + +.header_col { + margin-top: 9px; + text-align: right; +} + +.styled_version_amount_tag { + margin-left: 6px; + border-radius: 10px; + height: 20px; + line-height: 20px; + transform: translateY(-2px); +} + +.styled_avatar { + display: inline-block; +} diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/index.tsx new file mode 100644 index 000000000..06614f18f --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmDetail/index.tsx @@ -0,0 +1,387 @@ +import React, { FC, useState, useMemo, useEffect } from 'react'; +import { Grid, Button, Space, Tabs, Tag, Message } from '@arco-design/web-react'; +import { useHistory, useParams, Redirect } from 'react-router-dom'; +import { forceToRefreshQuery } from 'shared/queryClient'; +import { useMutation, useQuery } from 'react-query'; + +import VersionsTab from './VersionsTab'; +import AlgorithmInfo from 'components/AlgorithmDrawer/AlgorithmInfo'; + +import BackButton from 'components/BackButton'; +import MoreActions from 'components/MoreActions'; +import PropertyList from 'components/PropertyList'; +import SharedPageLayout from 'components/SharedPageLayout'; +import { + fetchAlgorithmList, + fetchPeerAlgorithmList, + fetchPeerAlgorithmProjectById, + fetchProjectDetail, + getFullAlgorithmDownloadHref, + postPublishAlgorithm, + publishAlgorithm, +} from 'services/algorithm'; +import { formatTimestamp } from 'shared/date'; +import { + Algorithm, + EnumAlgorithmProjectSource, + AlgorithmReleaseStatus, + AlgorithmVersionStatus, +} from 'typings/algorithm'; +import showAlgorithmSendingModal from '../AlgorithmSendModal'; +import AlgorithmType from 'components/AlgorithmType'; + +import styled from './index.module.less'; +import { AlgorithmManagementTabType } from 'typings/modelCenter'; +import { Avatar, deleteConfirm, unpublishConfirm } from '../shared'; +import { CONSTANTS } from 'shared/constants'; +import request from 'libs/request'; +import { useGetCurrentProjectId, useGetCurrentProjectParticipantId } from 'hooks'; + +const { Row, Col } = Grid; + +enum AlgorithmDetailTabType { + FILES = 'files', + VERSIONS = 'versions', +} + +enum algorithmDetailType { + MY = 'my', + BUILT_IN = 'built-in', + PARTICIPANT = 'participant', +} + +type TRouteParams = { + id: string; + tabType: AlgorithmDetailTabType; + algorithmDetailType: algorithmDetailType; +}; + +const AlgorithmDetail: FC = () => { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + const participantId = useGetCurrentProjectParticipantId(); + const params = useParams<TRouteParams>(); + const [activeKey, setActiveKey] = useState<AlgorithmDetailTabType>(); + const [algoNumber, setAlgoNumber] = useState<number>(0); + const isParticipant = params.algorithmDetailType === algorithmDetailType.PARTICIPANT; + const isBuiltIn = params.algorithmDetailType === algorithmDetailType.BUILT_IN; + const queryKeys = ['algorithmDetail', params.id]; + + const detailQuery = useQuery( + queryKeys, + () => { + if (isParticipant) { + return fetchPeerAlgorithmProjectById(projectId, participantId, params.id).then( + (res) => res.data, + ); + } + return fetchProjectDetail(params.id).then((res) => res.data); + }, + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const listQuery = useQuery( + ['fetchAlgorithmList', projectId, participantId, params.id], + () => { + if (isParticipant) { + return fetchPeerAlgorithmList(projectId, participantId, { + algorithm_project_uuid: params.id, + }); + } + return fetchAlgorithmList(isBuiltIn ? 0 : projectId, { algo_project_id: params.id }); + }, + { + retry: 2, + refetchOnWindowFocus: false, + onSuccess(res) { + if (res.data) { + setAlgoNumber(res.data.length); + } + }, + }, + ); + + const detail = detailQuery.data; + + const publishMutation = useMutation( + (comment: string) => { + return postPublishAlgorithm(params.id, comment); + }, + { + onSuccess() { + if (activeKey === AlgorithmDetailTabType.FILES) { + onTabChange(AlgorithmDetailTabType.VERSIONS); + } else { + forceToRefreshQuery([...queryKeys] as string[]); + } + }, + }, + ); + + const displayedProps = useMemo( + () => [ + { + value: detail?.type ? ( + <div style={{ marginTop: '-4px' }}> + <AlgorithmType type={detail.type} /> + </div> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + label: '类型', + }, + { + value: detail?.username || CONSTANTS.EMPTY_PLACEHOLDER, + label: '创建者', + hidden: isParticipant, + }, + { + value: detail?.updated_at + ? formatTimestamp(detail.updated_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '更新时间', + }, + { + value: detail?.created_at + ? formatTimestamp(detail.created_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '创建时间', + }, + ], + [detail, isParticipant], + ); + + const algorithms = useMemo(() => { + if (!listQuery.data) { + return []; + } + return listQuery.data.data || []; + }, [listQuery.data]); + + useEffect(() => { + setActiveKey(params.tabType); + }, [params.tabType]); + + let tabContent: React.ReactNode; + + switch (params.tabType) { + case AlgorithmDetailTabType.FILES: + tabContent = <AlgorithmInfo type="algorithm_project" detail={detail} />; + break; + case AlgorithmDetailTabType.VERSIONS: + tabContent = detail ? ( + <VersionsTab + onPublishClick={handleAlgorithmPublish} + onUnpublishClick={handleAlgorithmVersionUnpublish} + onReleaseClick={onRelease} + onDeleteClick={handleAlgorithmVersionDelete} + onDownloadClick={handleAlgorithmVersionDownload} + detail={detail} + id={params.id} + isParticipant={isParticipant} + isBuiltIn={isBuiltIn} + /> + ) : null; + break; + default: + tabContent = null; + break; + } + + return ( + <SharedPageLayout + title={ + <BackButton + onClick={() => history.push(`/algorithm-management/${AlgorithmManagementTabType.MY}`)} + > + {'算法仓库'} + </BackButton> + } + cardPadding={0} + > + <div className={styled.padding_container}> + <Row> + <Col span={12}> + <Space size="medium"> + <Avatar + data-name={detail?.name ? detail.name.slice(0, 1) : CONSTANTS.EMPTY_PLACEHOLDER} + /> + <div> + <h3 className={styled.styled_name}>{detail?.name ?? '....'}</h3> + <Space className={styled.comment}> + {renderAlgorithmStatus(detail)} + {detail?.comment ?? CONSTANTS.EMPTY_PLACEHOLDER} + </Space> + </div> + </Space> + </Col> + <Col className={styled.header_col} span={12}> + {isParticipant ? ( + <></> + ) : ( + <Space> + <Button + type="primary" + disabled={detail?.source !== EnumAlgorithmProjectSource.USER} + onClick={() => { + history.push(`/algorithm-management/edit?id=${detail?.id}`); + }} + > + {'编辑'} + </Button> + {detail?.release_status === AlgorithmReleaseStatus.UNRELEASED && ( + <Button + loading={publishMutation.isLoading} + onClick={onRelease} + disabled={detail?.source !== EnumAlgorithmProjectSource.USER} + > + {'发版'} + </Button> + )} + <MoreActions + actionList={[ + { + label: '发布最新版本', + onClick: () => { + if (algorithms.length > 0) { + onPublishAlgorithm(algorithms[0]); + } + }, + disabled: + detail?.latest_version === 0 || + detail?.source !== EnumAlgorithmProjectSource.USER || + algorithms.length === 0 || + algorithms[0]?.status === AlgorithmVersionStatus.PUBLISHED, + }, + ]} + /> + </Space> + )} + </Col> + </Row> + <PropertyList cols={6} colProportions={[1, 1, 1, 1]} properties={displayedProps} /> + </div> + <Tabs activeTab={activeKey} onChange={onTabChange}> + {isParticipant ? ( + <></> + ) : ( + <Tabs.TabPane title={'算法文件'} key={AlgorithmDetailTabType.FILES} /> + )} + <Tabs.TabPane + title={ + <> + {'版本列表'}{' '} + <Tag + color={activeKey === AlgorithmDetailTabType.VERSIONS ? 'arcoblue' : ''} + className={styled.styled_version_amount_tag} + > + {algoNumber} + </Tag> + </> + } + key={AlgorithmDetailTabType.VERSIONS} + /> + </Tabs> + <div className={styled.content}>{tabContent}</div> + {!params.tabType ? ( + <Redirect + to={`/algorithm-management/detail/${params.id}/${AlgorithmDetailTabType.FILES}`} + /> + ) : null} + </SharedPageLayout> + ); + + function renderAlgorithmStatus(detail: any) { + if (isParticipant || isBuiltIn) { + return <></>; + } + + if (detail?.release_status === AlgorithmReleaseStatus.RELEASED) { + return ( + <Tag size="small" color="green"> + 已发版 + </Tag> + ); + } + + return ( + <Tag size="small" color="orange"> + 未发版 + </Tag> + ); + } + + function onTabChange(val: string) { + setActiveKey(val as AlgorithmDetailTabType); + detailQuery.refetch(); + history.replace( + `/algorithm-management/detail/${params.id}/${val}/${params.algorithmDetailType}`, + ); + } + + function onRelease() { + showAlgorithmSendingModal( + detail!, + async (comment: string) => { + return publishMutation.mutate(comment); + }, + () => {}, + true, + ); + } + + function refetchProjectDetail() { + listQuery.refetch(); + detailQuery.refetch(); + } + + function onPublishAlgorithm(algorithm: Algorithm) { + handleAlgorithmPublish(algorithm); + } + + function handleAlgorithmPublish(algorithm: Algorithm) { + showAlgorithmSendingModal( + algorithm, + (comment: string) => { + return publishAlgorithm(projectId, algorithm.id, { comment }).then((resp) => { + refetchProjectDetail(); + }); + }, + () => {}, + ); + } + + async function handleAlgorithmVersionUnpublish(algorithm: Algorithm) { + try { + await unpublishConfirm(projectId!, algorithm); + forceToRefreshQuery([...queryKeys] as string[]); + refetchProjectDetail(); + } catch (e) { + Message.error(e.message); + } + } + + async function handleAlgorithmVersionDelete(algorithm: Algorithm) { + try { + await deleteConfirm(algorithm, false); + forceToRefreshQuery([...queryKeys] as string[]); + refetchProjectDetail(); + } catch (e) { + Message.error(e.message); + } + } + + async function handleAlgorithmVersionDownload(algorithm: Algorithm) { + try { + const tip = await request.download(getFullAlgorithmDownloadHref(algorithm.id)); + tip && Message.info(tip); + } catch (error) { + Message.error(error.message); + } + } +}; + +export default AlgorithmDetail; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmForm/index.module.less b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmForm/index.module.less new file mode 100644 index 000000000..910b1efca --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmForm/index.module.less @@ -0,0 +1,69 @@ +.title_with_icon { + margin: -10px 0 10px 10px; +} + +.styled_form_item { + margin-left: 12px; + width: 50%; + + :global(.arco-form-item-symbol) { + margin-left: -16px; + } + + // note: 通过这种方式把 upload 组件上传进度隐藏 + :global(.arco-upload-list-status) { + display: none; + } +} + +.styled_big_text { + display: block; + margin-left: 12px; + margin-bottom: 12px; + font-size: 14px; +} + +.styled_icon_code_square { + font-size: 15px; +} + +.styled_footer_space { + margin-top: 40px; +} + +.styled_code_editor_entry { + position: relative; + width: 519px; + padding-top: 38px; + padding-bottom: 24px; + text-align: center; + cursor: pointer; + background: var(--color-fill-2); + transition: background 0.2s; + &:hover { + background: var(--color-fill-2); + } +} + +.styled_status_row { + margin-top: 4px; + line-height: 2; + font-size: 12px; + color: var(--color-text-3); +} + +.styled_unsaved_tag { + margin-right: 8px; + color: rgb(var(--green-5)); + font-size: 12px; + font-weight: 400; + background-color: #fff; +} + +.styled_saved_tag { + margin-right: 8px; + color: var(--color-text-1); + font-size: 12px; + font-weight: 400; + background-color: #fff; +} diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmForm/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmForm/index.tsx new file mode 100644 index 000000000..9e898018b --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmForm/index.tsx @@ -0,0 +1,433 @@ +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { + Typography, + RulesProps, + Message, + Select, + Upload, + Button, + Space, + Input, + Form, + Tag, +} from '@arco-design/web-react'; +import { useParams, useHistory } from 'react-router-dom'; +import { IconCodeSquare, IconInfoCircle } from '@arco-design/web-react/icon'; +import { useRecoilValue } from 'recoil'; +import { useMutation } from 'react-query'; +import { useGetAppFlagValue, useUrlState } from 'hooks/index'; +import SharedPageLayout, { FormHeader } from 'components/SharedPageLayout'; +import BackButton from 'components/BackButton'; +import CodeEditorModal from 'components/CodeEditorModal'; +import { + fetchProjectDetail, + createProject, + patchProject, + postPublishAlgorithm, +} from 'services/algorithm'; +import { FlagKey } from 'typings/flag'; +import showSendModal from '../AlgorithmSendModal'; +import { projectState } from 'stores/project'; +import ParamsInput from '../AlgorithmParamsInput'; +import AlgorithmType from 'components/AlgorithmType'; +import { AlgorithmTypeOptions } from '../shared'; +import { AlgorithmProject, EnumAlgorithmProjectType } from 'typings/algorithm'; +import { AlgorithmManagementTabType } from 'typings/modelCenter'; +import { validNamePattern, MAX_COMMENT_LENGTH } from 'shared/validator'; +import { UploadItem } from '@arco-design/web-react/es/Upload'; +import { useIsFormValueChange } from 'hooks'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import TitleWithIcon from 'components/TitleWithIcon'; +import styled from './index.module.less'; + +enum FormField { + NAME = 'name', + COMMENT = 'comment', + ALGORITHM_TYPE = 'type', + Files = 'file', + PARAMETER = 'parameter', +} + +const RULES: Record<FormField, RulesProps[]> = { + [FormField.NAME]: [ + { required: true, message: '算法名称不能为空' }, + { + match: validNamePattern, + message: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ], + [FormField.ALGORITHM_TYPE]: [{ required: true }], + [FormField.Files]: [ + { + required: false, + validator(fileList: UploadItem[], callback) { + const file = fileList[0]; + if (!file) { + callback(undefined); + return; + } + + const isOverSize = file.originFile?.size && file.originFile.size > 100 * 1024 * 1024; // 100M + callback(isOverSize ? '大小超过限制' : undefined); + }, + }, + ], + [FormField.COMMENT]: [ + { + maxLength: MAX_COMMENT_LENGTH, + message: '最多为 200 个字符', + }, + ], + [FormField.PARAMETER]: [{ required: false }], +}; + +const defaultValue: Record<FormField, any> = { + [FormField.NAME]: '', + [FormField.ALGORITHM_TYPE]: EnumAlgorithmProjectType.NN_HORIZONTAL, + [FormField.Files]: [], + [FormField.COMMENT]: '', + [FormField.PARAMETER]: [], +}; + +type TMutationParams<T, K = AlgorithmProject> = { + id: ID; + payload: T; + project?: K; + shouldPublish?: boolean; +}; + +const AlgorithmForm: FC = () => { + const [form] = Form.useForm(); + const history = useHistory(); + const [urlState] = useUrlState(); + const { action } = useParams<{ action: 'edit' | 'create' }>(); + const [project, setProject] = useState<AlgorithmProject>(); + const [codeEditorVisible, setCodeEditorVisible] = useState<boolean>(false); + const [codeEditorTouched, setCodeEditorTouched] = useState<boolean>(false); + const selectedProject = useRecoilValue(projectState); + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(); + const trusted_computing_enabled = useGetAppFlagValue(FlagKey.TRUSTED_COMPUTING_ENABLED); + + const isEdit = action === 'edit'; + if (trusted_computing_enabled) { + AlgorithmTypeOptions.push({ + label: '可信计算', + value: EnumAlgorithmProjectType.TRUSTED_COMPUTING, + }); + } + + const createProjectMutation = useMutation( + async ({ id, payload, project, shouldPublish }: TMutationParams<FormData>) => { + if (shouldPublish) { + await publishAlgorithmWrap(project!, () => + createProject(id, payload as FormData).then((res) => res.data), + ); + Message.success('创建并发版成功'); + } else { + await createProject(id, payload as FormData); + Message.success('创建成功'); + } + return project; + }, + { + onSuccess: () => { + goBackProjectList(); + }, + onError(e: any) { + if (e.code === 409) { + Message.error('算法名称已存在'); + } else { + Message.error(e.message); + } + }, + }, + ); + const updateProjectMutation = useMutation( + async (params: TMutationParams<Partial<AlgorithmProject>>) => { + const { id, payload, shouldPublish } = params; + const action = () => + patchProject(id, { + comment: payload.comment, + parameter: payload.parameter, + } as Partial<AlgorithmProject>).then((res) => res.data); + + if (shouldPublish) { + await publishAlgorithmWrap(project!, action); + Message.success('编辑并发版成功'); + } else { + await action(); + Message.success('编辑成功'); + } + return { ...project, ...payload }; + }, + { + onSuccess: () => { + goBackProjectList(); + }, + }, + ); + const goBackProjectList = useCallback( + (replace = false) => { + const url = `/algorithm-management/${AlgorithmManagementTabType.MY}`; + return replace ? history.replace(url) : history.push(url); + }, + [history], + ); + + useEffect(() => { + if (!isEdit) { + form.setFieldsValue(defaultValue); + return; + } + if (!urlState.id) { + goBackProjectList(true); + return; + } + fetchProjectDetail(urlState.id) + .then(({ data }) => { + setProject(data); + form.setFieldsValue({ + [FormField.NAME]: data.name, + [FormField.ALGORITHM_TYPE]: data.type, + [FormField.COMMENT]: data.comment, + [FormField.PARAMETER]: + (data.parameter?.variables ?? []).length > 0 + ? data.parameter!.variables + : defaultValue[FormField.PARAMETER], + }); + }) + .catch(() => {}); + }, [form, goBackProjectList, isEdit, urlState.id]); + + // if the code editor is visible, try to prevent user from closing the page + useEffect(() => { + const handler = (e: Event) => { + const msg = 'Are you sure to leave?'; + e.preventDefault(); + // @ts-ignore + e.returnValue = msg; + return msg; + }; + if (codeEditorVisible) { + window.addEventListener('beforeunload', handler); + } + + return () => { + window.removeEventListener('beforeunload', handler); + }; + }, [codeEditorVisible]); + + return ( + <SharedPageLayout + title={ + <BackButton + onClick={goBackProjectList} + isShowConfirmModal={isFormValueChanged || codeEditorTouched} + > + {'算法仓库'} + </BackButton> + } + > + <FormHeader>{isEdit ? '编辑算法' : '创建算法'}</FormHeader> + <Form form={form} layout="vertical" onChange={onFormValueChange}> + <Typography.Text className={styled.styled_big_text} bold> + {'基本信息'} + </Typography.Text> + <Form.Item + className={styled.styled_form_item} + field={FormField.NAME} + label={'算法名称'} + rules={RULES[FormField.NAME]} + > + {isEdit ? ( + <Typography.Text>{project?.name}</Typography.Text> + ) : ( + <Input readOnly={isEdit} placeholder={'请输入算法名称'} /> + )} + </Form.Item> + <Form.Item + className={styled.styled_form_item} + field={FormField.COMMENT} + label={'算法描述'} + rules={RULES[FormField.COMMENT]} + > + <Input.TextArea rows={2} placeholder={'最多为 200 个字符'} /> + </Form.Item> + <Form.Item + className={styled.styled_form_item} + field={FormField.ALGORITHM_TYPE} + label={'算法类型'} + rules={RULES[FormField.ALGORITHM_TYPE]} + > + {isEdit && project?.type ? ( + <AlgorithmType type={project.type} /> + ) : ( + <Select options={AlgorithmTypeOptions} /> + )} + </Form.Item> + <TitleWithIcon + title="选择纵向联邦-NN模型时,代码层级的首层必须为leader和follower两个文件夹" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + className={styled.title_with_icon} + /> + {!isEdit ? ( + <Form.Item + className={styled.styled_form_item} + field={FormField.Files} + label={'算法文件'} + rules={RULES[FormField.Files]} + > + <Upload + drag + multiple={false} + limit={1} + accept=".gz,.tar" + tip={'仅支持上传1个 .tar 或 .gz 格式文件,大小不超过 100 MiB'} + /> + </Form.Item> + ) : ( + <Form.Item className={styled.styled_form_item} label={'算法文件'}> + <div + className={styled.styled_code_editor_entry} + onClick={() => { + setCodeEditorVisible(true); + }} + > + <div> + <IconCodeSquare className={styled.styled_icon_code_square} /> + <br /> + <Typography.Text bold>{'代码编辑器'}</Typography.Text> + </div> + <div className={styled.styled_status_row}> + <Tag + className={ + codeEditorTouched ? styled.styled_unsaved_tag : styled.styled_saved_tag + } + > + {codeEditorTouched ? '已保存' : '未编辑'} + </Tag> + {'点击进入代码编辑器'} + </div> + </div> + </Form.Item> + )} + <Typography.Text className={styled.styled_big_text} bold> + {'超参数'} + </Typography.Text> + <Form.Item + className={styled.styled_form_item} + field={FormField.PARAMETER} + style={{ marginLeft: 12, width: '80%', minWidth: 800 }} + > + <ParamsInput /> + </Form.Item> + + <Form.Item className={styled.styled_form_item} label=""> + <Space className={styled.styled_footer_space}> + <Button + loading={createProjectMutation.isLoading || updateProjectMutation.isLoading} + onClick={() => submitForm()} + type="primary" + > + {'提交'} + </Button> + <Button + loading={createProjectMutation.isLoading || updateProjectMutation.isLoading} + onClick={() => submitForm(true)} + type="primary" + > + {'提交并发版'} + </Button> + <ButtonWithModalConfirm + onClick={goBackProjectList} + isShowConfirmModal={isFormValueChanged || codeEditorTouched} + > + {'取消'} + </ButtonWithModalConfirm> + </Space> + </Form.Item> + </Form> + {project ? ( + <CodeEditorModal.AlgorithmProject + isAsyncMode={true} + id={project.id} + visible={codeEditorVisible} + title={project.name} + onClose={() => { + setCodeEditorVisible(false); + setCodeEditorTouched(true); + }} + /> + ) : null} + </SharedPageLayout> + ); + + async function submitForm(shouldPublish = false) { + const projectId = urlState.id; + await form.validate(); + const values = form.getFieldsValue(); + const parameter = { + variables: values[FormField.PARAMETER].filter((item: any) => item.name), + }; + + if (isEdit) { + updateProjectMutation.mutate({ + id: projectId, + shouldPublish, + payload: { + ...values, + [FormField.PARAMETER]: parameter, + } as any, + }); + } else { + if (!selectedProject.current?.id) { + Message.info('请选择工作区'); + return; + } + const file = values[FormField.Files]?.[0]?.originFile; + const formData = new FormData(); + formData.append(FormField.NAME, values[FormField.NAME]); + if (file) { + formData.append(FormField.Files, file); + } + formData.append(FormField.ALGORITHM_TYPE, values[FormField.ALGORITHM_TYPE]); + formData.append(FormField.PARAMETER, JSON.stringify(parameter)); + formData.append(FormField.COMMENT, values[FormField.COMMENT]); + + await createProjectMutation.mutate({ + id: selectedProject.current?.id, + shouldPublish, + project: values as AlgorithmProject, + payload: formData, + }); + } + } + + function publishAlgorithmWrap( + algorithm: AlgorithmProject, + beforePublish: () => Promise<AlgorithmProject>, + ) { + return new Promise((resolve, reject) => { + showSendModal( + algorithm, + async (comment: string) => { + try { + const res = await beforePublish(); + await postPublishAlgorithm(res.id, comment); + resolve(''); + } catch (e) { + reject(e); + } + }, + () => { + reject(null); + }, + true, + ); + }); + } +}; + +export default AlgorithmForm; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/AlgorithmTable/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/AlgorithmTable/index.tsx new file mode 100644 index 000000000..10b547d1a --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/AlgorithmTable/index.tsx @@ -0,0 +1,310 @@ +import React, { FC } from 'react'; +import { Link } from 'react-router-dom'; +import { Table, Empty, PaginationProps, Space } from '@arco-design/web-react'; +import { SorterResult } from '@arco-design/web-react/es/Table/interface'; +import { formatTimestamp } from 'shared/date'; +import { CONSTANTS } from 'shared/constants'; + +import AlgorithmType from 'components/AlgorithmType'; +import MoreActions from 'components/MoreActions'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import { + AlgorithmProject, + AlgorithmReleaseStatus, + EnumAlgorithmProjectSource, +} from 'typings/algorithm'; +import { Participant } from 'typings/participant'; +import { + algorithmReleaseStatusFilters, + algorithmTypeFilters, + FILTER_ALGORITHM_MY_OPERATOR_MAPPER, +} from 'views/AlgorithmManagement/shared'; +import { expression2Filter } from 'shared/filter'; +import { getSortOrder } from 'views/Datasets/shared'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; + +type ColumnsGetterOptions = { + urlState: UrlState; + participant?: Participant[]; + onReleaseClick?: any; + onDeleteClick?: any; + onChangeClick?: any; + onPublishClick?: any; + onDownloadClick?: any; + withoutActions?: boolean; + isBuiltIn?: boolean; + isParticipant?: boolean; +}; + +interface UrlState { + [key: string]: any; +} + +const calcStateIndicatorProps = ( + state: AlgorithmReleaseStatus, + options: ColumnsGetterOptions, +): { type: StateTypes; text: string; tip?: string } => { + let text = CONSTANTS.EMPTY_PLACEHOLDER; + let type = 'default' as StateTypes; + const tip = ''; + + switch (state) { + case AlgorithmReleaseStatus.UNRELEASED: + text = '未发版'; + type = 'gold'; + break; + case AlgorithmReleaseStatus.RELEASED: + text = '已发版'; + type = 'success'; + break; + default: + break; + } + + return { + text, + type, + tip, + }; +}; + +export const getTableColumns = (options: ColumnsGetterOptions) => { + const cols = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + width: 200, + ellipsis: true, + render: (name: any, record: any) => { + if (options.isParticipant) { + return ( + <Link to={`/algorithm-management/detail/${record.uuid}/versions/participant`}> + {name} + </Link> + ); + } + if (options.isBuiltIn) { + return ( + <Link to={`/algorithm-management/detail/${record.id}/files/built-in`}>{name}</Link> + ); + } + return <Link to={`/algorithm-management/detail/${record.id}/files/my`}>{name}</Link>; + }, + }, + !options.isBuiltIn && + !options.isParticipant && + ({ + title: '状态', + dataIndex: 'release_status', + name: 'release_status', + width: 150, + ...algorithmReleaseStatusFilters, + filteredValue: expression2Filter(options.urlState.filter).release_status, + render: (state: AlgorithmReleaseStatus, record: any) => { + return <StateIndicator {...calcStateIndicatorProps(state, options)} />; + }, + } as any), + { + title: '类型', + dataIndex: 'type', + name: 'type', + width: 150, + ...algorithmTypeFilters, + filteredValue: expression2Filter(options.urlState.filter).type, + render(_: any, record: any) { + return <AlgorithmType type={record.type} />; + }, + }, + options.isParticipant && { + title: '合作伙伴名称', + dataIndex: 'participant_id', + name: 'participant_id', + width: 200, + render(_: any, record: any) { + let result: any = undefined; + if (Array.isArray(options.participant) && options.participant.length !== 0) { + result = options.participant.find((item) => item.id === record.participant_id); + } + return <span>{result ? result.name : '-'}</span>; + }, + }, + { + title: '更新时间', + dataIndex: 'updated_at', + name: 'updated_at', + width: 200, + sortOrder: getSortOrder(options.urlState, 'updated_at'), + sorter(a: AlgorithmProject, b: AlgorithmProject) { + return a.updated_at - b.updated_at; + }, + render: (date: number) => <div>{formatTimestamp(date * 1000)}</div>, + }, + ].filter(Boolean); + + if (!options.withoutActions) { + cols.push({ + title: '操作', + dataIndex: 'operation', + name: 'operation', + fixed: 'right', + width: 150, + render: (_: number, record: AlgorithmProject) => ( + <Space> + <button + className="custom-text-button" + onClick={() => { + options?.onReleaseClick?.(record); + }} + disabled={ + record.source !== EnumAlgorithmProjectSource.USER || + record.release_status === AlgorithmReleaseStatus.RELEASED + } + > + {'发版'} + </button> + <button + className="custom-text-button" + onClick={() => { + options?.onChangeClick?.(record); + }} + > + 编辑 + </button> + <MoreActions + actionList={[ + { + label: '发布最新版本', + disabled: + record.latest_version === 0 || + record.source === EnumAlgorithmProjectSource.THIRD_PARTY, + onClick() { + options?.onPublishClick?.(record); + }, + }, + { + label: '删除', + onClick() { + options?.onDeleteClick?.(record); + }, + danger: true, + }, + ]} + /> + </Space> + ), + } as any); + } + + return cols; +}; + +type Props = { + data: AlgorithmProject[]; + loading: boolean; + isBuiltIn?: boolean; + isParticipant?: boolean; + noDataElement?: string; + participant?: Participant[]; + pagination?: PaginationProps | boolean; + urlState?: UrlState; + setUrlState?: (newState: any) => void; + onReleaseClick?: (record: any) => void; + onPublishClick?: (record: any) => void; + onChangeClick?: (record: any) => void; + onDeleteClick?: (record: any) => void; + onDownloadClick?: (record: any) => void; + onShowSizeChange?: (current: number, size: number) => void; + onPageChange?: (page: number, pageSize: number) => void; +}; +const AlgorithmTable: FC<Props> = ({ + data, + urlState = {}, + setUrlState, + loading, + isBuiltIn, + isParticipant, + participant, + noDataElement, + pagination, + onReleaseClick, + onPublishClick, + onChangeClick, + onDeleteClick, + onDownloadClick, + onPageChange, +}) => { + return ( + <Table + className="custom-table custom-table-left-side-filter" + data={data} + rowKey="uuid" + loading={loading} + scroll={{ x: '100%' }} + pagination={pagination} + noDataElement={<Empty description={noDataElement} />} + onChange={handleChange} + columns={getTableColumns({ + urlState, + onReleaseClick, + onPublishClick, + onDeleteClick, + onChangeClick, + onDownloadClick, + isBuiltIn, + isParticipant, + participant, + withoutActions: isBuiltIn || isParticipant, + })} + /> + ); + + function handleChange( + pagination: PaginationProps, + sorter: SorterResult, + filters: any, + extra: any, + ) { + const { action } = extra; + + switch (action) { + case 'paginate': + onPageChange && onPageChange(pagination.current as number, pagination.pageSize as number); + break; + case 'filter': + onFilterChange && onFilterChange(filters); + break; + case 'sort': + onSortChange && onSortChange(sorter); + } + } + + function onFilterChange(filters: any) { + setUrlState && + setUrlState((prevState: any) => ({ + ...prevState, + filter: filterExpressionGenerator( + { + ...filters, + name: expression2Filter(urlState.filter).name, + }, + FILTER_ALGORITHM_MY_OPERATOR_MAPPER, + ), + page: 1, + })); + } + + function onSortChange(sorter: SorterResult) { + let orderValue = ''; + if (sorter.direction) { + orderValue = sorter.direction === 'ascend' ? 'asc' : 'desc'; + } + setUrlState && + setUrlState((prevState: any) => ({ + ...prevState, + order_by: orderValue ? `${sorter.field} ${orderValue}` : '', + })); + } +}; + +export default AlgorithmTable; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/BuiltInAlgorithmTab/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/BuiltInAlgorithmTab/index.tsx new file mode 100644 index 000000000..b9cacff55 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/BuiltInAlgorithmTab/index.tsx @@ -0,0 +1,129 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useMutation, useQuery } from 'react-query'; + +import { EnumAlgorithmProjectSource } from 'typings/algorithm'; +import { fetchProjectList, updatePresetAlgorithm } from 'services/algorithm'; +import { TIME_INTERVAL } from 'shared/constants'; +import AlgorithmTable from '../AlgorithmTable'; +import { + useGetCurrentProjectParticipantList, + useTablePaginationWithUrlState, + useUrlState, +} from 'hooks'; +import GridRow from 'components/_base/GridRow'; +import { Button, Input, Message, Tooltip } from '@arco-design/web-react'; +import { useIsAdminRole } from 'hooks/user'; +import { expression2Filter } from 'shared/filter'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { FILTER_ALGORITHM_MY_OPERATOR_MAPPER } from 'views/AlgorithmManagement/shared'; + +export const LIST_QUERY_KEY = 'PresetAlgorithmProjects'; + +const BuiltInAlgorithmTab: FC = () => { + const participantList = useGetCurrentProjectParticipantList(); + const isAdminRole = useIsAdminRole(); + + const [total, setTotal] = useState(0); + const [pageTotal, setPageTotal] = useState(0); + const { paginationProps } = useTablePaginationWithUrlState(); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: '', + order_by: '', + }); + + const listQuery = useQuery( + [LIST_QUERY_KEY, urlState], + () => + fetchProjectList(0, { + ...urlState, + sources: EnumAlgorithmProjectSource.PRESET, + }), + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + onSuccess: (res) => { + const { page_meta } = res || {}; + setTotal((pre) => page_meta?.total_items || pre); + setPageTotal(page_meta?.total_pages ?? 0); + }, + }, + ); + + const updatePresetAlgorithmMutation = useMutation( + (payload: any) => { + return updatePresetAlgorithm(payload); + }, + { + onSuccess() { + Message.success('更新预置算法成功'); + listQuery.refetch(); + }, + onError(e: any) { + Message.error(e.message); + }, + }, + ); + + const pagination = useMemo(() => { + return pageTotal <= 1 + ? false + : { + ...paginationProps, + total, + }; + }, [paginationProps, pageTotal, total]); + + return ( + <> + <GridRow justify="space-between" align="center"> + <Tooltip content="只有管理员才能更新预置算法" disabled={isAdminRole}> + <Button + className="custom-operation-button" + type="primary" + onClick={onUpdateClick} + loading={updatePresetAlgorithmMutation.isLoading} + disabled={!isAdminRole} + > + 更新预置算法 + </Button> + </Tooltip> + + <Input.Search + className="custom-input" + allowClear + defaultValue={expression2Filter(urlState.filter).name} + onSearch={onSearch} + onClear={() => onSearch('')} + placeholder="输入算法名称" + /> + </GridRow> + <AlgorithmTable + loading={listQuery.isFetching} + data={listQuery.data?.data ?? []} + urlState={urlState} + setUrlState={setUrlState} + isBuiltIn={true} + pagination={pagination} + noDataElement="暂无算法" + participant={participantList ?? []} + /> + </> + ); + + function onSearch(value: string) { + const filters = expression2Filter(urlState.filter); + filters.name = value; + setUrlState((prevState) => ({ + ...prevState, + page: 1, + filter: filterExpressionGenerator(filters, FILTER_ALGORITHM_MY_OPERATOR_MAPPER), + })); + } + function onUpdateClick() { + updatePresetAlgorithmMutation.mutate({}); + } +}; + +export default BuiltInAlgorithmTab; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/MyAlgorithmTab/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/MyAlgorithmTab/index.tsx new file mode 100644 index 000000000..569af1b5c --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/MyAlgorithmTab/index.tsx @@ -0,0 +1,197 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useHistory } from 'react-router'; +import { useQuery } from 'react-query'; + +import request from 'libs/request'; +import { + fetchProjectDetail, + fetchProjectList, + postPublishAlgorithm, + getFullAlgorithmProjectDownloadHref, + publishAlgorithm, +} from 'services/algorithm'; +import { forceToRefreshQuery } from 'shared/queryClient'; +import { TIME_INTERVAL } from 'shared/constants'; + +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useTablePaginationWithUrlState, + useUrlState, +} from 'hooks'; + +import { Button, Input, Message } from '@arco-design/web-react'; +import { IconPlus } from '@arco-design/web-react/icon'; +import GridRow from 'components/_base/GridRow'; +import AlgorithmTable from '../AlgorithmTable'; +import showSendModal from '../../AlgorithmSendModal'; +import { AlgorithmProject, EnumAlgorithmProjectSource } from 'typings/algorithm'; +import { + deleteConfirm, + FILTER_ALGORITHM_MY_OPERATOR_MAPPER, +} from 'views/AlgorithmManagement/shared'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { expression2Filter } from 'shared/filter'; + +export const LIST_QUERY_KEY = 'my_algorithm_list_query'; + +const MyAlgorithmTab: FC = () => { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + const participantList = useGetCurrentProjectParticipantList(); + const [total, setTotal] = useState(0); + const [pageTotal, setPageTotal] = useState(0); + const { paginationProps } = useTablePaginationWithUrlState(); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: filterExpressionGenerator( + { + project_id: projectId, + }, + FILTER_ALGORITHM_MY_OPERATOR_MAPPER, + ), + order_by: '', + }); + const listQueryKey = [LIST_QUERY_KEY, projectId, urlState]; + const listQuery = useQuery( + listQueryKey, + () => { + if (!projectId) { + Message.info('请选择工作区'); + } + return fetchProjectList(projectId ?? 0, { + ...urlState, + sources: [EnumAlgorithmProjectSource.USER, EnumAlgorithmProjectSource.THIRD_PARTY], + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + onSuccess: (res) => { + const { page_meta } = res || {}; + setTotal((pre) => page_meta?.total_items || pre); + setPageTotal(page_meta?.total_pages ?? 0); + }, + }, + ); + + const pagination = useMemo(() => { + return pageTotal <= 1 + ? false + : { + ...paginationProps, + total, + }; + }, [paginationProps, pageTotal, total]); + + return ( + <> + <GridRow justify="space-between" align="center"> + <Button + className="custom-operation-button" + type="primary" + icon={<IconPlus />} + onClick={onCreateClick} + > + 创建算法 + </Button> + + <Input.Search + className="custom-input" + allowClear + defaultValue={expression2Filter(urlState.filter).name} + onSearch={onSearch} + onClear={() => onSearch('')} + placeholder="输入算法名称" + /> + </GridRow> + <AlgorithmTable + data={listQuery.data?.data ?? []} + urlState={urlState} + setUrlState={setUrlState} + noDataElement="暂无算法,去创建" + loading={listQuery.isFetching} + participant={participantList ?? []} + pagination={pagination} + onReleaseClick={onReleaseClick} + onPublishClick={onPublishClick} + onChangeClick={onChangeClick} + onDeleteClick={onDeleteClick} + onDownloadClick={onDownloadClick} + /> + </> + ); + function onSearch(value: string) { + const filters = expression2Filter(urlState.filter); + filters.name = value; + setUrlState((prevState) => ({ + ...prevState, + page: 1, + filter: filterExpressionGenerator(filters, FILTER_ALGORITHM_MY_OPERATOR_MAPPER), + })); + } + + function onCreateClick() { + history.push('/algorithm-management/create'); + } + function onChangeClick(record: any) { + history.push(`/algorithm-management/edit?id=${record.id}`); + } + + function onReleaseClick(record: any) { + showSendModal( + record, + async (comment: string) => { + await postPublishAlgorithm(record.id, comment); + forceToRefreshQuery([...listQueryKey]); + Message.success('发版成功'); + }, + () => {}, + true, + ); + } + + function onPublishClick(record: AlgorithmProject) { + // indicate that there're not any algorithm + if (record.latest_version === 0) { + return; + } + showSendModal( + () => + fetchProjectDetail(record.id).then((res) => { + const latestAlgorithm = res.data?.algorithms?.[0]; + if (!latestAlgorithm) { + Message.error('没有算法'); + throw new Error('no algorithm'); + } + return latestAlgorithm; + }), + async (comment: string, algorithm) => { + await publishAlgorithm(projectId, algorithm.id, { comment }); + Message.success('发布成功'); + }, + () => {}, + false, + ); + } + + async function onDeleteClick(record: AlgorithmProject) { + try { + await deleteConfirm(record, true); + forceToRefreshQuery([...listQueryKey]); + } catch (e) { + Message.error(e.message); + } + } + async function onDownloadClick(record: AlgorithmProject) { + try { + const tip = await request.download(getFullAlgorithmProjectDownloadHref(record.id)); + tip && Message.info(tip); + } catch (error) { + Message.error(error.message); + } + } +}; + +export default MyAlgorithmTab; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/ParticipantAlgorithmTab/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/ParticipantAlgorithmTab/index.tsx new file mode 100644 index 000000000..60ed0d265 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/ParticipantAlgorithmTab/index.tsx @@ -0,0 +1,107 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; + +import { fetchPeerAlgorithmProjectList } from 'services/algorithm'; +import { TIME_INTERVAL } from 'shared/constants'; +import { expression2Filter } from 'shared/filter'; +import { pageSplit } from '../../shared'; +import AlgorithmTable from '../AlgorithmTable'; +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useTablePaginationWithUrlState, + useUrlState, +} from 'hooks'; +import GridRow from 'components/_base/GridRow'; +import { Input } from '@arco-design/web-react'; +import { FILTER_ALGORITHM_MY_OPERATOR_MAPPER } from 'views/AlgorithmManagement/shared'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; + +export const LIST_QUERY_KEY = 'PresetAlgorithmProjects'; + +const ParticipantAlgorithmTab: FC = () => { + const projectId = useGetCurrentProjectId(); + const participantList = useGetCurrentProjectParticipantList(); + const [total, setTotal] = useState(0); + const [pageTotal, setPageTotal] = useState(0); + const { paginationProps } = useTablePaginationWithUrlState(); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: filterExpressionGenerator( + { + project_id: projectId, + }, + FILTER_ALGORITHM_MY_OPERATOR_MAPPER, + ), + order_by: '', + }); + + const listQuery = useQuery( + [LIST_QUERY_KEY, projectId, urlState], + () => + fetchPeerAlgorithmProjectList(projectId, 0, { + filter: urlState.filter, + }), + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + onSuccess: (res) => { + setTotal((pre) => res.data?.length || pre); + setPageTotal(Math.ceil(res.data?.length / urlState.pageSize) ?? 0); + }, + }, + ); + + const pagination = useMemo(() => { + return pageTotal <= 1 + ? false + : { + ...paginationProps, + total, + }; + }, [paginationProps, pageTotal, total]); + + const list = useMemo(() => { + if (!listQuery.data?.data) return []; + const { page, pageSize } = urlState; + return pageSplit(listQuery.data.data, page, pageSize); + }, [listQuery.data, urlState]); + + return ( + <> + <GridRow justify="end" align="center"> + <Input.Search + className="custom-input" + allowClear + defaultValue={expression2Filter(urlState.filter).name} + onSearch={onSearch} + onClear={() => onSearch('')} + placeholder="输入算法名称" + /> + </GridRow> + <AlgorithmTable + loading={listQuery.isFetching} + data={list} + urlState={urlState} + setUrlState={setUrlState} + noDataElement="暂无算法,去创建" + isParticipant={true} + participant={participantList ?? []} + pagination={pagination} + /> + </> + ); + + function onSearch(value: string) { + const filters = expression2Filter(urlState.filter); + filters.name = value; + setUrlState((prevState) => ({ + ...prevState, + page: 1, + filter: filterExpressionGenerator(filters, FILTER_ALGORITHM_MY_OPERATOR_MAPPER), + })); + } +}; + +export default ParticipantAlgorithmTab; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/index.module.less b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/index.module.less new file mode 100644 index 000000000..208c49ca5 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/index.module.less @@ -0,0 +1,11 @@ +.algorithm_tab{ + :global{ + .arco-tabs-header-nav-horizontal { + padding-left: 4px; + } + .arco-tabs-header-title { + margin-top: 4px; + margin-bottom: 4px; + } + } +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/index.tsx new file mode 100644 index 000000000..c355a496e --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmList/index.tsx @@ -0,0 +1,59 @@ +import React, { FC } from 'react'; +import { useParams, useHistory } from 'react-router'; + +import { AlgorithmManagementTabType } from 'typings/modelCenter'; + +import { Tabs } from '@arco-design/web-react'; +import SharedPageLayout, { RemovePadding } from 'components/SharedPageLayout'; +import MyAlgorithmTab from './MyAlgorithmTab'; +import BuiltInAlgorithmTab from './BuiltInAlgorithmTab'; +import ParticipantAlgorithmTab from './ParticipantAlgorithmTab'; +import { Redirect, Route } from 'react-router'; +import styled from './index.module.less'; + +const AlgorithmManagementList: FC = () => { + const history = useHistory(); + + const { tabType } = useParams<{ tabType: AlgorithmManagementTabType }>(); + if (!tabType) { + return <Redirect to={`/algorithm-management/${AlgorithmManagementTabType.MY}`} />; + } + return ( + <SharedPageLayout title="算法仓库"> + <RemovePadding style={{ height: 46 }}> + <Tabs className={styled.algorithm_tab} defaultActiveTab={tabType} onChange={onTabChange}> + <Tabs.TabPane title="我的算法" key={AlgorithmManagementTabType.MY} /> + <Tabs.TabPane title="预置算法" key={AlgorithmManagementTabType.BUILT_IN} /> + <Tabs.TabPane title="合作伙伴算法" key={AlgorithmManagementTabType.PARTICIPANT} /> + </Tabs> + </RemovePadding> + <Route + path={`/algorithm-management/${AlgorithmManagementTabType.MY}`} + exact + render={(props) => { + return <MyAlgorithmTab />; + }} + /> + <Route + path={`/algorithm-management/${AlgorithmManagementTabType.BUILT_IN}`} + exact + render={(props) => { + return <BuiltInAlgorithmTab />; + }} + /> + <Route + path={`/algorithm-management/${AlgorithmManagementTabType.PARTICIPANT}`} + exact + render={(props) => { + return <ParticipantAlgorithmTab />; + }} + /> + </SharedPageLayout> + ); + + function onTabChange(val: string) { + history.replace(`/algorithm-management/${val}`); + } +}; + +export default AlgorithmManagementList; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmParamsInput/index.module.less b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmParamsInput/index.module.less new file mode 100644 index 000000000..b29d3f0e8 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmParamsInput/index.module.less @@ -0,0 +1,78 @@ +.styled_container { + font-size: 12px; +} + +.styled_button { + margin-left: -5px; + padding-left: 5px; + padding-right: 5px; + font-size: 12px; + &.arco-btn-text:not(.arco-btn-disabled):not(.arco-btn-loading):hover { + background: transparent; + } +} + +.styled_required_text { + --height: 28px; + display: block; + position: relative; + height: var(--height); + line-height: var(--height); +} + +.styled_row_with_border { + --rightSpaceWidth: 30px; + position: relative; + margin-bottom: 7px; + padding-right: var(--rightSpaceWidth); + line-height: 28px; + &::after { + position: absolute; + left: 5px; + right: calc(var(--rightSpaceWidth) + 5px); + bottom: 0; + height: 1px; + background: var(--color-neutral-3); + content: ''; + } +} + +.styled_row_without_border { + --rightSpaceWidth: 30px; + position: relative; + margin-bottom: 6px; + padding-right: var(--rightSpaceWidth); + line-height: 28px; + &::after { + display: none; + position: absolute; + left: 5px; + right: calc(var(--rightSpaceWidth) + 5px); + bottom: 0; + height: 1px; + background: var(--color-neutral-3); + content: ''; + } +} + +.styled_radio_group { + display: flex; + width: 100%; + :global(.arco-radio-button) { + flex: 1; + } + :global(.arco-radio-button-inner) { + width: 100%; + text-align: center; + } +} + +.styled_delete_button { + position: absolute; + top: 0; + right: 0; + width: 30px; + &:not(.arco-btn-disabled) { + color: var(--color-text-1); + } +} diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmParamsInput/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmParamsInput/index.tsx new file mode 100644 index 000000000..b546ec68c --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmParamsInput/index.tsx @@ -0,0 +1,163 @@ +import React, { FC, useEffect, useState } from 'react'; +import { Grid, Input, Radio, Typography, Button } from '@arco-design/web-react'; +import { IconPlus, IconDelete } from '@arco-design/web-react/icon'; +import styled from './index.module.less'; +import { giveWeakRandomKey } from 'shared/helpers'; +import { AlgorithmParams } from 'typings/modelCenter'; + +type TProps = { + value?: AlgorithmParams[]; + defaultValue?: AlgorithmParams[]; + onChange?: (value: AlgorithmParams[]) => void; +}; + +const { Row, Col } = Grid; +const { Text } = Typography; +const { TextArea } = Input; + +const emptyRow: AlgorithmParams = { + name: '', + value: '', + display_name: '', + comment: '', + required: true, +}; + +type AlgorithmParamsState = { + id: string; + value: AlgorithmParams; +}; + +const AlgorithmParamsInput: FC<TProps> = ({ value, defaultValue, onChange }: TProps) => { + const [curValue, setCurValue] = useState<Array<AlgorithmParamsState>>( + getValue(value || defaultValue || []), + ); + // 用 value 是否有值判断是否受控 + const isControlled = Array.isArray(value); + + useEffect(() => { + if (isControlled) { + setCurValue(getValue(value || [])); + } + }, [value, isControlled]); + + return ( + <div className={styled.styled_container}> + <> + <Row gutter={10} className={styled.styled_row_with_border}> + <Col span={7}> + <Text className={styled.styled_required_text} type="secondary"> + {'名称'} + </Text> + </Col> + <Col span={7}> + <Text type="secondary">{'默认值'}</Text> + </Col> + <Col span={3}> + <Text type="secondary">{'是否必填'}</Text> + </Col> + <Col span={7}> + <Text type="secondary">{'提示语'}</Text> + </Col> + </Row> + {curValue.map(({ id, value: item }, index) => ( + <Row className={styled.styled_row_without_border} gutter={10} key={id}> + <Col span={7}> + <Input + placeholder={'请输入参数名称'} + defaultValue={item.name} + type="text" + onBlur={getFormHandler(index, 'name')} + /> + </Col> + <Col span={7}> + <TextArea + rows={1} + placeholder={'请输入默认值'} + defaultValue={item.value} + onBlur={getFormHandler(index, 'value')} + /> + </Col> + <Col span={3}> + <Radio.Group + className={styled.styled_radio_group} + defaultValue={item.required} + type="button" + onChange={getFormHandler(index, 'required')} + > + <Radio value={true}>是</Radio> + <Radio value={false}>否</Radio> + </Radio.Group> + </Col> + <Col span={7}> + <TextArea + rows={1} + placeholder={'请输入提示语'} + defaultValue={item.comment} + onBlur={getFormHandler(index, 'comment')} + /> + </Col> + <Button + className={styled.styled_delete_button} + onClick={(e: Event) => { + e.preventDefault(); + e.stopPropagation(); + delRow(index); + }} + type="text" + size="small" + icon={<IconDelete />} + /> + </Row> + ))} + </> + <Button className={styled.styled_button} onClick={addRow} type="text" size="small"> + <IconPlus /> + {'新增超参数'} + </Button> + </div> + ); + + function setValue(value: AlgorithmParamsState[]) { + // 如果受控,内部不处理 value,直接提交给外部处理,通过上方的 useEffect 来更新 curValue + if (isControlled) { + onChange?.(value.map((item) => item.value)); + return; + } + setCurValue(value); + } + + function getFormHandler(index: number, field: keyof AlgorithmParams) { + return (val: any | string) => { + const newValue = [...curValue]; + const { id, value } = newValue[index]; + newValue[index] = { + id, + value: { + ...value, + [field]: typeof val === 'object' ? val?.target?.value : val, + }, + }; + setValue([...newValue]); + }; + } + + function addRow(e: any) { + e.stopPropagation(); + e.preventDefault(); + const newValue = [...curValue, { id: `${Date.now()}`, value: { ...emptyRow } }]; + setValue(newValue); + } + function delRow(index: number) { + setValue(curValue.filter((_, i) => i !== index)); + } +}; + +function getValue(value: AlgorithmParams[]) { + return value.map((item) => ({ + id: item.name || giveWeakRandomKey(), + value: item, + })); +} + +export default AlgorithmParamsInput; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmSendModal/index.module.less b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmSendModal/index.module.less new file mode 100644 index 000000000..6402a609f --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmSendModal/index.module.less @@ -0,0 +1,11 @@ +.styled_container { + font-size: 14px; +} + +.styled_property_list { + margin-top: 6px; + margin-bottom: 20px; + padding: 20px; + border: 1px solid var(--color-border-2); + background: transparent; +} diff --git a/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmSendModal/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmSendModal/index.tsx new file mode 100644 index 000000000..051dfce69 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/AlgorithmSendModal/index.tsx @@ -0,0 +1,126 @@ +import React, { FC } from 'react'; +import { Modal, Typography, Input, Message } from '@arco-design/web-react'; +import { IconLoading } from '@arco-design/web-react/icon'; +import PropertyList from 'components/PropertyList'; +import { Algorithm, AlgorithmProject } from 'typings/algorithm'; +import AlgorithmType from 'components/AlgorithmType'; +import { ConfirmProps } from '@arco-design/web-react/es/Modal/confirm'; +import styled from './index.module.less'; + +type Props = { + algorithm: Algorithm | AlgorithmProject; + isPublish?: boolean; + onChange: (comment: string) => void; +}; + +const { Text } = Typography; +const { TextArea } = Input; + +const AlgorithmSendModalContent: FC<Props> = ({ isPublish = false, algorithm, onChange }) => { + const curVersion = + (algorithm as AlgorithmProject).latest_version || (algorithm as Algorithm).version || 0; + const propertyList = [ + { + label: '名称', + value: algorithm.name, + }, + { + label: '类型', + value: algorithm?.type && <AlgorithmType type={algorithm.type} style={{ marginTop: -4 }} />, + }, + { + label: '版本', + value: `V${isPublish ? curVersion + 1 : curVersion}`, + }, + { + label: '描述', + value: algorithm.comment, + }, + ]; + return ( + <div className={styled.styled_container}> + <Text type="secondary">{'算法'}</Text> + <PropertyList className={styled.styled_property_list} cols={2} properties={propertyList} /> + {isPublish ? ( + <> + <Text type="secondary">{'版本描述'}</Text> + <TextArea style={{ marginTop: 6 }} rows={2} onChange={onChange} /> + </> + ) : ( + <></> + )} + </div> + ); +}; + +function sendModal( + algorithmGetter: Props['algorithm'] | (() => Promise<Props['algorithm']>), + onConfirm: (comment: string, algorithm: Props['algorithm']) => Promise<any>, + onCancel: () => void, + isPublish = false, + showMsg = false, +) { + return new Promise(async (resolve) => { + let algorithm: Props['algorithm'] | undefined = undefined; + const modalProps: ConfirmProps = { + icon: null, + title: <IconLoading />, + closable: true, + style: { + width: '600px', + }, + okButtonProps: { + disabled: true, + }, + okText: isPublish ? '发版' : '发布', + content: null, + cancelText: '取消', + onCancel, + + async onConfirm() { + if (!algorithm) { + return; + } + modalProps.confirmLoading = true; + modal.update({ ...modalProps }); + try { + await onConfirm(curComment, algorithm); + } catch (e) { + Message.error(e.message); + throw e; + } + if (showMsg) { + Message.success(isPublish ? '发版成功' : '发布成功'); + } + + modal.close(); + resolve(''); + }, + }; + const algorithmPromise = + typeof algorithmGetter === 'function' ? algorithmGetter() : Promise.resolve(algorithmGetter); + + const modal = Modal.confirm({ ...modalProps }); + + algorithm = await algorithmPromise; + let curComment = algorithm.comment ?? ''; + + // update the modal content and state with algorithm data + modalProps.title = isPublish ? `发版「${algorithm.name}」` : `发布${algorithm.name}」`; + modalProps.content = ( + <AlgorithmSendModalContent + isPublish={isPublish} + algorithm={algorithm} + onChange={(value: string) => { + curComment = value; + }} + /> + ); + modalProps.okButtonProps = { + disabled: false, + }; + modal.update({ ...modalProps }); + }); +} + +export default sendModal; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/index.tsx b/web_console_v2/client/src/views/AlgorithmManagement/index.tsx new file mode 100644 index 000000000..04c4a9865 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/index.tsx @@ -0,0 +1,36 @@ +import ErrorBoundary from 'components/ErrorBoundary'; +import React, { FC } from 'react'; +import { Route, Redirect, useLocation, Switch } from 'react-router-dom'; + +import { AlgorithmManagementTabType } from 'typings/modelCenter'; + +import AlgorithmDetail from './AlgorithmDetail'; +import AlgorithmForm from './AlgorithmForm'; +import AlgorithmList from './AlgorithmList'; + +const AlgorithmManagement: FC = () => { + const location = useLocation(); + + return ( + <ErrorBoundary> + <Switch> + <Route + path={`/algorithm-management/:tabType(${AlgorithmManagementTabType.MY}|${AlgorithmManagementTabType.BUILT_IN}|${AlgorithmManagementTabType.PARTICIPANT})`} + exact + component={AlgorithmList} + /> + <Route path="/algorithm-management/:action(create|edit)" component={AlgorithmForm} /> + <Route + path={`/algorithm-management/detail/:id/:tabType?/:algorithmDetailType?`} + exact + component={AlgorithmDetail} + /> + {location.pathname === '/algorithm-management' && ( + <Redirect to={`/algorithm-management/${AlgorithmManagementTabType.MY}`} /> + )} + </Switch> + </ErrorBoundary> + ); +}; + +export default AlgorithmManagement; diff --git a/web_console_v2/client/src/views/AlgorithmManagement/shared.module.less b/web_console_v2/client/src/views/AlgorithmManagement/shared.module.less new file mode 100644 index 000000000..12b47dfa7 --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/shared.module.less @@ -0,0 +1,22 @@ +@import '~styles/mixins.less'; +.avatar_container { + .MixinSquare(44px); + background-color: var(--primary-1); + color: white; + border-radius: 4px; + font-size: 18px; + text-align: center; + &::before { + display: inline-block; + width: 100%; + height: 100%; + content: ''; + background-image: url('../../assets/icons/atom-icon-algorithm-management.svg'); + background-repeat: no-repeat; + background-size: contain; + } +} +.plus_icon { + margin-right: 4px; + vertical-align: 0.03em !important; +} diff --git a/web_console_v2/client/src/views/AlgorithmManagement/shared.tsx b/web_console_v2/client/src/views/AlgorithmManagement/shared.tsx new file mode 100644 index 000000000..25b869e7f --- /dev/null +++ b/web_console_v2/client/src/views/AlgorithmManagement/shared.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import styled from './shared.module.less'; +import { + EnumAlgorithmProjectType, + EnumAlgorithmProjectSource, + AlgorithmProject, + Algorithm, + AlgorithmReleaseStatus, +} from 'typings/algorithm'; +import { deleteAlgorithm, deleteAlgorithmProject, unpublishAlgorithm } from 'services/algorithm'; +import { Modal, TableColumnProps } from '@arco-design/web-react'; +import { FilterOp } from 'typings/filter'; +type TableFilterConfig = Pick<TableColumnProps, 'filters' | 'onFilter'>; + +export const AlgorithmProjectTypeText = { + [EnumAlgorithmProjectType.UNSPECIFIED]: '自定义算法', + [EnumAlgorithmProjectType.NN_VERTICAL]: '纵向联邦-NN模型', + [EnumAlgorithmProjectType.NN_LOCAL]: '本地-NN模型', + [EnumAlgorithmProjectType.TREE_VERTICAL]: '纵向联邦-树模型', + [EnumAlgorithmProjectType.TREE_HORIZONTAL]: '横向联邦-树模型', +}; + +export const AlgorithmTypeOptions = [ + { + label: '自定义算法', + value: EnumAlgorithmProjectType.UNSPECIFIED, + }, + // { + // label: i18n.t('algorithm_management.label_model_type_nn_local'), + // value: EnumAlgorithmProjectType.NN_LOCAL, + // }, + { + label: '横向联邦-NN模型', + value: EnumAlgorithmProjectType.NN_HORIZONTAL, + }, + { + label: '纵向联邦-NN模型', + value: EnumAlgorithmProjectType.NN_VERTICAL, + }, + // { + // label: i18n.t('algorithm_management.label_model_type_tree_vertical'), + // value: EnumAlgorithmProjectType.TREE_VERTICAL, + // }, + // { + // label: i18n.t('algorithm_management.label_model_type_tree_horizontal'), + // value: EnumAlgorithmProjectType.TREE_HORIZONTAL, + // }, +]; + +export const AlgorithmSourceText = { + [EnumAlgorithmProjectSource.USER]: '我方', + [EnumAlgorithmProjectSource.PRESET]: '系统预置', + [EnumAlgorithmProjectSource.THIRD_PARTY]: '第三方', +}; + +export const Avatar: React.FC = () => { + return <div className={styled.avatar_container} />; +}; + +export function deleteConfirm( + algorithm: AlgorithmProject | Algorithm, + isProject = false, +): Promise<void> { + return new Promise((resolve, reject) => { + Modal.confirm({ + className: 'custom-modal', + style: { width: 360 }, + title: isProject + ? `确认删除「${algorithm.name}}」?` + : `确认删除版本「V${(algorithm as Algorithm).version}」?`, + content: isProject + ? '删除后,使用该算法的模型训练将无法发起新任务,请谨慎操作' + : '删除后,使用该算法版本的模型训练将无法发起新任务,请谨慎操作', + cancelText: '取消', + okText: '确认', + okButtonProps: { + status: 'danger', + }, + async onConfirm() { + try { + await (isProject ? deleteAlgorithmProject(algorithm.id) : deleteAlgorithm(algorithm.id)); + resolve(); + } catch (e) { + reject(e); + } + }, + }); + }); +} + +export function unpublishConfirm(projectId: ID, algorithm: Algorithm): Promise<void> { + return new Promise((resolve, reject) => { + Modal.confirm({ + className: 'custom-modal', + style: { width: 360 }, + title: `确认撤销发布 「V${algorithm.version}」?`, + content: '撤销发布后,合作伙伴使用该算法版本的模型训练将无法发起新任务,请谨慎操作', + cancelText: '取消', + okText: '确认', + okButtonProps: { + status: 'danger', + }, + async onConfirm() { + try { + await unpublishAlgorithm(projectId, algorithm.id); + resolve(); + } catch (e) { + reject(e); + } + }, + }); + }); +} + +export function pageSplit(data: any[], page: number, pageSize: number): any[] { + if (data.length === 0) return []; + const offset = (page - 1) * pageSize; + return offset + pageSize >= data.length + ? data.slice(offset, data.length) + : data.slice(offset, offset + pageSize); +} + +export const FILTER_ALGORITHM_MY_OPERATOR_MAPPER = { + release_status: FilterOp.IN, + type: FilterOp.IN, + name: FilterOp.CONTAIN, +}; + +export const algorithmReleaseStatusFilters: TableFilterConfig = { + filters: [ + { + text: '已发布', + value: AlgorithmReleaseStatus.RELEASED, + }, + { + text: '未发布', + value: AlgorithmReleaseStatus.UNRELEASED, + }, + ], + onFilter: (value: string, record: AlgorithmProject) => { + return value === record.release_status; + }, +}; + +export const algorithmTypeFilters: TableFilterConfig = { + filters: [ + { + text: '横向联邦-NN模型', + value: EnumAlgorithmProjectType.NN_HORIZONTAL, + }, + { + text: '纵向联邦-NN模型', + value: EnumAlgorithmProjectType.NN_VERTICAL, + }, + ], + onFilter: (value: string, record: AlgorithmProject) => { + return value === record.type; + }, +}; diff --git a/web_console_v2/client/src/views/Audit/EventList/EventDetailDrawer/index.module.less b/web_console_v2/client/src/views/Audit/EventList/EventDetailDrawer/index.module.less new file mode 100644 index 000000000..2d77f851f --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/EventDetailDrawer/index.module.less @@ -0,0 +1,24 @@ +.content{ + flex: 1; +} + +.gap{ + margin: 20px 0; + height: 1px; + background-color: var(--lineColor); +} + +.header{ + display: flex; + justify-content: space-between; + align-items: center; +} + +.click_text{ + font-size: 12px; + color: #1664ff; + cursor: pointer; +} +.styled_copy_button{ + color: var(--textColor) !important; +} diff --git a/web_console_v2/client/src/views/Audit/EventList/EventDetailDrawer/index.tsx b/web_console_v2/client/src/views/Audit/EventList/EventDetailDrawer/index.tsx new file mode 100644 index 000000000..59855ef61 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/EventDetailDrawer/index.tsx @@ -0,0 +1,274 @@ +import React, { useState, useEffect } from 'react'; + +import { formatTimestamp } from 'shared/date'; +import { copyToClipboard, formatJSONValue } from 'shared/helpers'; +import { CONSTANTS } from 'shared/constants'; +import { systemInfoQuery } from 'stores/app'; +import { useRecoilQuery } from 'hooks/recoil'; + +import { Drawer, Button, Message, Tag } from '@arco-design/web-react'; +import { IconCopy } from '@arco-design/web-react/icon'; +import { LabelStrong } from 'styles/elements'; +import CodeEditor from 'components/CodeEditor'; +import BackButton from 'components/BackButton'; + +import PropList from '../PropList'; + +import { DrawerProps } from '@arco-design/web-react/es/Drawer'; +import { Audit, EventType } from 'typings/audit'; + +import styles from './index.module.less'; +import WhichParticipants from '../WhichParticipants'; + +export interface Props extends DrawerProps { + data?: Audit; + event_type: EventType; +} + +const hideExtraBlockList = ['null', '{}']; + +function EventDetailDrawer({ visible, data, title = '事件详情', event_type, ...restProps }: Props) { + const [isShowCodeEditor, setIsShowCodeEditor] = useState(false); + const { data: systemInfo } = useRecoilQuery(systemInfoQuery); + const { name: myName, domain_name: myDomainName, pure_domain_name: myPureDomainName } = + systemInfo || {}; + + useEffect(() => { + if (!visible) { + // reset isShowCodeEditor + setIsShowCodeEditor((prevState) => false); + } + }, [visible]); + + function renderInfoLayout() { + return ( + <> + <LabelStrong isBlock={true}>基础信息</LabelStrong> + <PropList + list={[ + { + key: '事件ID', + value: data?.uuid ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '事件时间', + value: data?.created_at + ? formatTimestamp(data.created_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '事件名称', + value: data?.name ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '用户名', + value: data?.user?.username ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '操作名称', + value: data?.op_type ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + ]} + /> + <div className={styles.gap} /> + <LabelStrong isBlock={true}>请求信息</LabelStrong> + <PropList + list={[ + { + key: '请求ID', + value: data?.uuid ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: 'AccessKey ID', + value: data?.access_key_id ?? CONSTANTS.EMPTY_PLACEHOLDER, + isCanCopy: true, + }, + { + key: '事件结果', + value: data?.result ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '错误码', + value: data?.error_code ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '资源类型', + value: data?.resource_type ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '资源名称', + value: data?.resource_name ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '源IP地址', + value: data?.source_ip ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '额外信息', + value: + data?.extra && !hideExtraBlockList.includes(data.extra) ? ( + <span className={styles.click_text}>查看</span> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + onClick: () => { + if (data?.extra && !hideExtraBlockList.includes(data.extra)) { + setIsShowCodeEditor(true); + } + }, + }, + ]} + /> + </> + ); + } + function renderCrossDomainInfoLayout() { + return ( + <> + <LabelStrong isBlock={true}>基础信息</LabelStrong> + <PropList + list={[ + { + key: '事件ID', + value: data?.uuid ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '事件时间', + value: data?.created_at + ? formatTimestamp(data.created_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '事件名称', + value: data?.name ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '操作名称', + value: data?.op_type ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + ]} + /> + <div className={styles.gap} /> + <LabelStrong isBlock={true}>请求信息</LabelStrong> + <PropList + list={[ + { + key: '请求ID', + value: data?.uuid ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '事件结果', + value: data?.result ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '资源类型', + value: data?.resource_type ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + key: '资源名称', + value: data?.resource_name ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + ]} + /> + <div className={styles.gap} /> + <LabelStrong isBlock={true}>跨域信息</LabelStrong> + <PropList + list={[ + { + key: '发起方', + value: + data?.coordinator_pure_domain_name === myPureDomainName ? ( + <span> + {`${myName} | ${myDomainName}`} <Tag color="arcoblue"> 本侧</Tag> + </span> + ) : ( + <WhichParticipants + pureDomainName={data?.coordinator_pure_domain_name} + showCoordinator={true} + showAll={true} + /> + ), + }, + { + key: '协作方', + value: ( + <WhichParticipants + currentDomainName={ + data?.coordinator_pure_domain_name !== myPureDomainName + ? myDomainName + : undefined + } + currentName={ + data?.coordinator_pure_domain_name !== myPureDomainName ? myName : undefined + } + pureDomainName={data?.coordinator_pure_domain_name} + projectId={data?.project_id} + showAll={true} + /> + ), + }, + ]} + /> + </> + ); + } + function renderCodeEditorLayout() { + return ( + <> + <div className={styles.header}> + <BackButton onClick={onBackClick}> 返回</BackButton> + <Button + className={styles.styled_copy_button} + icon={<IconCopy />} + onClick={onCopyClick} + type="text" + > + 复制 + </Button> + </div> + <CodeEditor + language="json" + isReadOnly={true} + theme="grey" + height="calc(100vh - 119px)" // 55(drawer header height) + 16*2(content padding) + 32(header height) + value={formatJSONValue(data?.extra ?? '')} + /> + </> + ); + } + + return ( + <Drawer + placement="right" + title={title} + closable={true} + width="50%" + visible={visible} + unmountOnExit + {...restProps} + > + <div className={styles.content}> + {isShowCodeEditor + ? renderCodeEditorLayout() + : event_type === EventType.CROSS_DOMAIN + ? renderCrossDomainInfoLayout() + : renderInfoLayout()} + </div> + </Drawer> + ); + + function onBackClick() { + setIsShowCodeEditor(false); + } + async function onCopyClick() { + const isOK = await copyToClipboard(formatJSONValue(data?.extra ?? '')); + + if (isOK) { + Message.success('复制成功'); + } else { + Message.error('复制失败'); + } + } +} + +export default EventDetailDrawer; diff --git a/web_console_v2/client/src/views/Audit/EventList/EventTable/index.module.less b/web_console_v2/client/src/views/Audit/EventList/EventTable/index.module.less new file mode 100644 index 000000000..84e9a1627 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/EventTable/index.module.less @@ -0,0 +1,53 @@ + +.styled_title_icon{ + display: inline-block; + margin-left: 16px; + font-size: 12px; + color: var(--textColor); +} +.left{ + .arco-radio-group { + margin-right: 12px; + } +} +.styled_select{ + display: inline-block; + width: 128px; + .arco-select-view { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + } +} +.styled_search{ + display: inline-block; + width: 230px; + .arco-input-group > :first-child { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + } +} +.styled_button{ + width: 32px; + height: 32px; + margin-left: 8px; +} +.styled_footer_button{ + color: var(--textColor) !important; +} +.click_text{ + display: inline-block; + color: #1664ff; + cursor: pointer; +} +.footer{ + display: flex; + align-items: center; + justify-content: space-between; +} + +.styled_table{ + .arco-table-tr { + cursor: pointer; + } +} + diff --git a/web_console_v2/client/src/views/Audit/EventList/EventTable/index.tsx b/web_console_v2/client/src/views/Audit/EventList/EventTable/index.tsx new file mode 100644 index 000000000..3817fca87 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/EventTable/index.tsx @@ -0,0 +1,198 @@ +import React, { FC, useState } from 'react'; +import { systemInfoQuery } from 'stores/app'; +import { useRecoilQuery } from 'hooks/recoil'; + +import { formatTimestamp } from 'shared/date'; +import WhichParticipants from '../WhichParticipants'; + +import { CONSTANTS } from 'shared/constants'; + +import { Table, Tag } from '@arco-design/web-react'; +import { Label, LabelTint } from 'styles/elements'; +import EventDetailDrawer from '../EventDetailDrawer'; + +import { EventType, Audit } from 'typings/audit'; + +import styles from './index.module.less'; + +interface Props { + event_type: EventType; + tableData: Audit[]; + isLoading: boolean; +} + +const EventTable: FC<Props> = ({ event_type, tableData, isLoading }) => { + const [isShowEventDetailDrawer, setIsShowEventDetailDrawer] = useState(false); + const [selectedAudit, setSelectedAudit] = useState<Audit>(); + + const { data: systemInfo } = useRecoilQuery(systemInfoQuery); + const { name: myName, domain_name: myDomainName, pure_domain_name: myPureDomainName } = + systemInfo || {}; + const columns = [ + { + title: '事件时间', + dataIndex: 'created_at', + key: 'created_at', + width: 250, + fixed: 'left', + render: (value: any, record: any) => { + return ( + <span + className={styles.click_text} + onClick={() => { + setSelectedAudit(record); + setIsShowEventDetailDrawer(() => true); + }} + > + {value ? formatTimestamp(value) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ); + }, + }, + { + title: '用户名', + dataIndex: 'user', + key: 'user', + render: (_value: any, record: any) => { + return ( + <> + <Label marginRight={8} fontSize={14}> + {record.user?.username} + </Label> + <LabelTint fontSize={14}>{record.user?.role}</LabelTint> + </> + ); + }, + }, + { + title: '事件名称', + dataIndex: 'name', + key: 'name', + render: (value: any) => value || CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + title: '资源类型', + dataIndex: 'resource_type', + key: 'resource_type', + render: (value: any) => value || CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + title: '资源名称', + dataIndex: 'resource_name', + key: 'resource_name', + render: (value: any) => value || CONSTANTS.EMPTY_PLACEHOLDER, + }, + ]; + const cross_domain_columns = [ + { + title: '事件时间', + dataIndex: 'created_at', + key: 'created_at', + width: 250, + fixed: 'left', + render: (value: any, record: any) => { + return ( + <span + className={styles.click_text} + onClick={() => { + setSelectedAudit(record); + setIsShowEventDetailDrawer(() => true); + }} + > + {value ? formatTimestamp(value) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ); + }, + }, + { + title: '发起方', + dataIndex: 'coordinator_pure_domain_name', + key: 'coordinator_pure_domain_name', + render: (value: any, record: any) => { + return ( + <> + {value === myPureDomainName ? ( + <> + <Label marginRight={8} fontSize={14}> + {systemInfo?.name} + </Label>{' '} + <Tag color="arcoblue"> 本侧</Tag> + </> + ) : ( + <Label marginRight={8} fontSize={14}> + <WhichParticipants showCoordinator={true} pureDomainName={value} /> + </Label> + )} + </> + ); + }, + }, + { + title: '协作方', + dataIndex: 'project_id', + key: 'participants', + render: (value: any, record: any) => ( + <WhichParticipants + currentDomainName={ + record.coordinator_pure_domain_name !== myPureDomainName ? myDomainName : undefined + } + currentName={ + record.coordinator_pure_domain_name !== myPureDomainName ? myName : undefined + } + pureDomainName={record.coordinator_pure_domain_name} + projectId={value} + /> + ), + }, + + { + title: '事件名称 ', + dataIndex: 'name', + key: 'name', + render: (value: any) => value || CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + title: '资源类型 ', + dataIndex: 'resource_type', + key: ' resource_type', + render: (value: any) => value || CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + title: ' 资源名称 ', + dataIndex: 'resource_name', + key: 'resource_name', + render: (value: any) => value || CONSTANTS.EMPTY_PLACEHOLDER, + }, + ]; + + return ( + <> + <Table<Audit> + className={`${styles.styled_table} ${styles.event_margin}`} + rowKey="uuid" + data={tableData} + loading={isLoading} + columns={(event_type === EventType.CROSS_DOMAIN ? cross_domain_columns : columns) as any} + pagination={false} + onRow={(record) => ({ + onClick: () => { + setSelectedAudit(record); + setIsShowEventDetailDrawer(() => true); + }, + })} + /> + <EventDetailDrawer + visible={isShowEventDetailDrawer} + data={selectedAudit} + onCancel={onEventDetailDrawerClose} + event_type={event_type} + /> + </> + ); + function onEventDetailDrawerClose() { + setIsShowEventDetailDrawer(false); + setSelectedAudit(undefined); + } +}; + +export default EventTable; diff --git a/web_console_v2/client/src/views/Audit/EventList/MoreParticipants/index.module.less b/web_console_v2/client/src/views/Audit/EventList/MoreParticipants/index.module.less new file mode 100644 index 000000000..1a1f9564c --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/MoreParticipants/index.module.less @@ -0,0 +1,3 @@ +.tag_content{ + margin-left: 8px; +} diff --git a/web_console_v2/client/src/views/Audit/EventList/MoreParticipants/index.tsx b/web_console_v2/client/src/views/Audit/EventList/MoreParticipants/index.tsx new file mode 100644 index 000000000..a52a0ce63 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/MoreParticipants/index.tsx @@ -0,0 +1,44 @@ +import React, { FC } from 'react'; +import { Popover, Tag } from '@arco-design/web-react'; +import { PopoverProps } from '@arco-design/web-react/es/Popover'; +import { TagProps } from '@arco-design/web-react/es/Tag'; + +import styles from './index.module.less'; + +export interface MoreParticipantsProps { + textList: string[]; + count: number; + popoverProps?: PopoverProps; + tagProps?: TagProps; +} + +const MoreParticipants: FC<MoreParticipantsProps> = (props: MoreParticipantsProps) => { + const { textList = [], count = 1, popoverProps = {}, tagProps = {} } = props; + function renderText(textList: string[], count: number) { + const listLength = textList.length; + if (listLength === 0) { + return <div>-</div>; + } else if (listLength <= count) { + return <div>{textList.slice(0, count).join(' ')}</div>; + } + return ( + <> + <span>{textList.slice(0, count)}</span> + <Popover + {...popoverProps} + content={textList.map((item) => ( + <div>{item}</div> + ))} + > + <Tag className={styles.tag_content} {...tagProps}> + +{listLength - count} + </Tag> + </Popover> + </> + ); + } + + return renderText(textList, count); +}; + +export default MoreParticipants; diff --git a/web_console_v2/client/src/views/Audit/EventList/PropList/index.module.less b/web_console_v2/client/src/views/Audit/EventList/PropList/index.module.less new file mode 100644 index 000000000..174f18988 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/PropList/index.module.less @@ -0,0 +1,11 @@ +.styled_row{ + margin-top: 16px; +} +.styled_copy_icon{ + margin-left: 20px; + font-size: 14px; + + &:hover { + color: #1664ff; + } +} diff --git a/web_console_v2/client/src/views/Audit/EventList/PropList/index.tsx b/web_console_v2/client/src/views/Audit/EventList/PropList/index.tsx new file mode 100644 index 000000000..8c8017d6f --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/PropList/index.tsx @@ -0,0 +1,49 @@ +import React, { FC, ReactNode } from 'react'; + +import { Grid } from '@arco-design/web-react'; +import { Label, LabelStrong } from 'styles/elements'; +import { Copy } from 'components/IconPark'; +import ClickToCopy from 'components/ClickToCopy'; + +import styles from './index.module.less'; + +const { Row, Col } = Grid; +export interface Item { + key: string; + value: ReactNode; + isCanCopy?: boolean; + onClick?: () => void; +} +export interface Props { + list: Item[]; +} + +const PropList: FC<Props> = ({ list }) => { + return ( + <> + {list.map((item) => { + return ( + <Row className={styles.styled_row} key={item.key}> + <Col span={4}> + <Label>{item.key}</Label> + </Col> + <Col span={20}> + {item.isCanCopy ? ( + <ClickToCopy text={String(item.value)}> + <LabelStrong onClick={item.onClick}> + {item.value} + <Copy className={styles.styled_copy_icon} /> + </LabelStrong> + </ClickToCopy> + ) : ( + <LabelStrong onClick={item.onClick}>{item.value}</LabelStrong> + )} + </Col> + </Row> + ); + })} + </> + ); +}; + +export default PropList; diff --git a/web_console_v2/client/src/views/Audit/EventList/WhichParticipants/index.tsx b/web_console_v2/client/src/views/Audit/EventList/WhichParticipants/index.tsx new file mode 100644 index 000000000..2b396caa1 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/WhichParticipants/index.tsx @@ -0,0 +1,94 @@ +import React, { FC, useMemo } from 'react'; + +import { useRecoilQuery } from 'hooks/recoil'; +import { projectListQuery } from 'stores/project'; +import { participantListQuery } from 'stores/participant'; +import MoreParticipants from '../MoreParticipants'; + +import { Spin } from '@arco-design/web-react'; + +type Props = { + projectId?: ID; + pureDomainName?: string; + currentName?: string; + currentDomainName?: string; + loading?: boolean; + showAll?: boolean; + showCoordinator?: boolean; +}; + +const WhichParticipants: FC<Props> = ({ + projectId, + pureDomainName, + currentName, + currentDomainName, + loading, + showAll = false, + showCoordinator = false, +}) => { + const { isLoading: projectLoading, data: projectList } = useRecoilQuery(projectListQuery); + const { isLoading: participantsLoading, data: participantsList } = useRecoilQuery( + participantListQuery, + ); + + const { currentCoordinatorName, currentCoordinatorDomainName } = useMemo(() => { + const currentCoordinator = participantsList?.find( + (item) => item.pure_domain_name === pureDomainName, + ); + return { + currentCoordinatorName: currentCoordinator?.name || '-', + currentCoordinatorDomainName: currentCoordinator?.domain_name || '-', + }; + }, [participantsList, pureDomainName]); + + const { collaboratorsName, collaboratorsDomain } = useMemo(() => { + const currentParticipants = + projectList?.find((item) => Number(item.id) === Number(projectId))?.participants || []; + const collaborators = currentParticipants.filter( + (item) => item.pure_domain_name !== pureDomainName, + ); + const collaboratorsName = collaborators.map((item) => item.name); + const collaboratorsDomain = collaborators.map((item) => ({ + name: item.name, + domain_name: item.domain_name, + })); + return { + collaboratorsName, + collaboratorsDomain, + }; + }, [projectList, pureDomainName, projectId]); + + if (loading || projectLoading || participantsLoading) { + return <Spin />; + } + + if (showCoordinator) { + return showAll ? ( + <span>{`${currentCoordinatorName} | ${currentCoordinatorDomainName} `}</span> + ) : ( + <span>{currentCoordinatorName}</span> + ); + } + + return ( + <> + {!showAll ? ( + <MoreParticipants + textList={currentName ? [currentName, ...collaboratorsName] : collaboratorsName} + count={1} + /> + ) : collaboratorsDomain.length || currentName ? ( + <div> + {currentName && <div> {`${currentName} | ${currentDomainName}`}</div>} + {collaboratorsDomain.map((item) => ( + <div key={item.domain_name}>{`${item.name} | ${item.domain_name}`}</div> + ))} + </div> + ) : ( + '-' + )} + </> + ); +}; + +export default WhichParticipants; diff --git a/web_console_v2/client/src/views/Audit/EventList/index.module.less b/web_console_v2/client/src/views/Audit/EventList/index.module.less new file mode 100644 index 000000000..84e9a1627 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/index.module.less @@ -0,0 +1,53 @@ + +.styled_title_icon{ + display: inline-block; + margin-left: 16px; + font-size: 12px; + color: var(--textColor); +} +.left{ + .arco-radio-group { + margin-right: 12px; + } +} +.styled_select{ + display: inline-block; + width: 128px; + .arco-select-view { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + } +} +.styled_search{ + display: inline-block; + width: 230px; + .arco-input-group > :first-child { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + } +} +.styled_button{ + width: 32px; + height: 32px; + margin-left: 8px; +} +.styled_footer_button{ + color: var(--textColor) !important; +} +.click_text{ + display: inline-block; + color: #1664ff; + cursor: pointer; +} +.footer{ + display: flex; + align-items: center; + justify-content: space-between; +} + +.styled_table{ + .arco-table-tr { + cursor: pointer; + } +} + diff --git a/web_console_v2/client/src/views/Audit/EventList/index.tsx b/web_console_v2/client/src/views/Audit/EventList/index.tsx new file mode 100644 index 000000000..1cdcba489 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/EventList/index.tsx @@ -0,0 +1,383 @@ +import React, { FC, useState, useMemo } from 'react'; + +import { useUrlState, useTablePaginationWithUrlState } from 'hooks'; + +import dayjs, { Dayjs } from 'dayjs'; +import { useQuery } from 'react-query'; +import { useGetUserInfo } from 'hooks/user'; + +import { fetchAuditList, deleteAudit } from 'services/audit'; +import { expression2Filter } from 'shared/filter'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { + FILTER_EVENT_OPERATOR_MAPPER, + EVENT_SOURCE_TYPE_MAPPER, + EVENT_TYPE_DELETE_MAPPER, +} from '../shared'; + +import { + Input, + Button, + Radio, + DatePicker, + Select, + Message, + Pagination, + Tabs, +} from '@arco-design/web-react'; +import { IconRefresh, IconDownload, IconDelete, IconInfoCircle } from '@arco-design/web-react/icon'; +import GridRow from 'components/_base/GridRow'; +import Modal from 'components/Modal'; +import SharedPageLayout from 'components/SharedPageLayout'; +import TitleWithIcon from 'components/TitleWithIcon'; +import EventTable from './EventTable'; + +import { + EventType, + QueryParams, + RadioType, + SelectType, + CrossDomainSelectType, +} from 'typings/audit'; + +import styles from './index.module.less'; + +const { TabPane } = Tabs; +const { Search } = Input; +const { RangePicker } = DatePicker; + +type Props = {}; + +const EventList: FC<Props> = (props) => { + const userInfo = useGetUserInfo(); + const { + urlState: pageInfoState, + reset: resetPageInfoState, + paginationProps, + } = useTablePaginationWithUrlState(); + const [urlState, setUrlState] = useUrlState({ + radioType: RadioType.ALL, + selectType: SelectType.EVENT_NAME, + crossDomainSelectType: CrossDomainSelectType.EVENT_NAME, + eventType: EventType.INNER, + filter: initFilter(EventType.INNER), + }); + + const initFilterParams = expression2Filter(urlState.filter); + const keyword = + urlState.eventType === EventType.CROSS_DOMAIN + ? initFilterParams[urlState.crossDomainSelectType] + : initFilterParams[urlState.selectType]; + const [filterParams, setFilterParams] = useState<QueryParams>({ + keyword: keyword || '', + startTime: '', + endTime: '', + }); + + const [dateList, setDateList] = useState<null | Dayjs[]>(() => { + if (initFilterParams.start_time && initFilterParams.end_time) { + return [dayjs.unix(initFilterParams.start_time), dayjs.unix(initFilterParams.end_time)]; + } + + if (filterParams.startTime && filterParams.endTime) { + return [ + dayjs(filterParams.startTime, 'YYYY-MM-DD HH:mm:ss'), + dayjs(filterParams.endTime, 'YYYY-MM-DD HH:mm:ss'), + ]; + } + return null; + }); + + const auditListQuery = useQuery( + [ + 'fetchAuditList', + dateList, + pageInfoState.page, + pageInfoState.pageSize, + filterParams.keyword, + urlState.filter, + ], + () => + fetchAuditList({ + filter: urlState.filter, + page: pageInfoState.page, + page_size: pageInfoState.pageSize, + }), + { + retry: 2, + refetchOnWindowFocus: false, + enabled: Boolean(userInfo?.id), + }, + ); + + const auditList = useMemo(() => { + return auditListQuery.data?.data ?? []; + }, [auditListQuery]); + + return ( + <SharedPageLayout + title="审计日志" + isNeedHelp={false} + rightTitle={ + <TitleWithIcon + className={styles.styled_title_icon} + title="以下列表最长展示过去9个月的事件记录" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + } + > + <Tabs defaultActiveTab={urlState.eventType ?? EventType.INNER} onChange={onTabChange}> + <TabPane key="inner" title="内部事件" destroyOnHide={false} /> + <TabPane key="cross_domain" title="跨域事件" destroyOnHide={true} /> + </Tabs> + <GridRow justify="space-between" align="center"> + <div className={styles.left}> + <Radio.Group + value={urlState.radioType || null} + onChange={onRadioTypeChange} + type="button" + > + <Radio value={RadioType.ALL}>全部</Radio> + <Radio value={RadioType.WEEK}>近七天</Radio> + <Radio value={RadioType.ONE_MONTH}>近1月</Radio> + <Radio value={RadioType.THREE_MONTHS}>近3月</Radio> + </Radio.Group> + <RangePicker showTime value={dateList as any} onChange={onRangePickerChange} /> + </div> + <div> + {urlState.eventType === EventType.CROSS_DOMAIN ? ( + <Select + className={styles.styled_select} + value={urlState.crossDomainSelectType} + onChange={onCrossDomainSelectTypeChange} + > + <Select.Option value={CrossDomainSelectType.EVENT_NAME}>事件名称</Select.Option> + <Select.Option value={CrossDomainSelectType.RESOURCE_TYPE}>资源类型</Select.Option> + <Select.Option value={CrossDomainSelectType.RESOURCE_NAME}>资源名称</Select.Option> + <Select.Option value={CrossDomainSelectType.COORDINATOR_PURE_DOMAIN_NAME}> + 发起方 + </Select.Option> + </Select> + ) : ( + <Select + className={styles.styled_select} + value={urlState.selectType} + onChange={onSelectTypeChange} + > + <Select.Option value={SelectType.EVENT_NAME}>事件名称</Select.Option> + <Select.Option value={SelectType.RESOURCE_TYPE}>资源类型</Select.Option> + <Select.Option value={SelectType.RESOURCE_NAME}>资源名称</Select.Option> + <Select.Option value={SelectType.USER_NAME}>用户名</Select.Option> + </Select> + )} + + <Search + className={styles.styled_search} + allowClear + onSearch={onSearchTextChange} + onClear={() => onSearchTextChange('')} + placeholder="搜索关键词" + defaultValue={ + filterParams.keyword || + initFilterParams[urlState.selectType] || + initFilterParams[urlState.crossDomainSelectType] || + '' + } + /> + <Button className={styles.styled_button} icon={<IconRefresh />} onClick={onRefresh} /> + <Button className={styles.styled_button} icon={<IconDownload />} onClick={onDownload} /> + </div> + </GridRow> + + <EventTable + event_type={urlState.eventType || EventType.INNER} + tableData={auditList} + isLoading={auditListQuery.isLoading} + /> + <div className={styles.footer}> + <Button + className={styles.styled_footer_button} + icon={<IconDelete />} + onClick={onDelete} + type="text" + > + 删除6个月前的记录 + </Button> + <Pagination + sizeCanChange={true} + total={auditListQuery.data?.page_meta?.total_items ?? undefined} + {...paginationProps} + /> + </div> + </SharedPageLayout> + ); + function onTabChange(tab: string) { + constructFilterArray({ ...filterParams, ...urlState, eventType: tab as EventType }); + } + + function onRadioTypeChange(value: any) { + const type: RadioType = value; + + let currentDay = null; + let startDay: Dayjs | null = null; + let endDay: Dayjs | null = null; + switch (type) { + case RadioType.WEEK: + currentDay = dayjs(); + startDay = currentDay.subtract(7, 'day'); + endDay = dayjs(); + break; + case RadioType.ONE_MONTH: + currentDay = dayjs(); + startDay = currentDay.subtract(1, 'month'); + endDay = dayjs(); + break; + case RadioType.THREE_MONTHS: + currentDay = dayjs(); + startDay = currentDay.subtract(3, 'month'); + endDay = dayjs(); + break; + case RadioType.ALL: + default: + break; + } + if (startDay && endDay) { + setDateList([startDay, endDay]); + } else { + setDateList(null); + } + + constructFilterArray({ + ...filterParams, + ...urlState, + startTime: startDay?.format('YYYY-MM-DD HH:mm:ss') ?? '', + endTime: endDay?.format('YYYY-MM-DD HH:mm:ss') ?? '', + radioType: type, + }); + } + function onSelectTypeChange(value: any) { + constructFilterArray({ ...filterParams, ...urlState, selectType: value }); + } + function onCrossDomainSelectTypeChange(value: any) { + constructFilterArray({ ...filterParams, ...urlState, crossDomainSelectType: value }); + } + function onRangePickerChange(dateString: string[], date: any[]) { + setDateList(date as any); + if (date) { + // Clear radio type + constructFilterArray({ + ...filterParams, + ...urlState, + startTime: date?.[0]?.format('YYYY-MM-DD HH:mm:ss') ?? '', + endTime: date?.[1]?.format('YYYY-MM-DD HH:mm:ss') ?? '', + radioType: undefined, + }); + } else { + // Reset radio type + constructFilterArray({ + ...filterParams, + ...urlState, + startTime: '', + endTime: '', + radioType: RadioType.ALL, + }); + } + } + function onSearchTextChange(value: string) { + constructFilterArray({ ...filterParams, ...urlState, keyword: value }); + } + function onRefresh() { + auditListQuery.refetch(); + } + function onDownload() { + // TODO: onDownload + Message.info('Coming soon'); + } + function onDelete() { + Modal.delete({ + title: + urlState.eventType === EventType.CROSS_DOMAIN + ? '确定要删除跨域事件吗' + : '确定要删除内部事件吗?', + content: '基于安全审核的原因,平台仅支持清理6个月前的事件记录', + onOk() { + // Delete audit data from 6 month ago + deleteAudit({ + event_type: EVENT_TYPE_DELETE_MAPPER[urlState.eventType as EventType], + }) + .then(() => { + if (String(pageInfoState.page) !== '1') { + // Reset page info and refresh audit list data + resetPageInfoState(); + } else { + // Only refresh audit list data + auditListQuery.refetch(); + } + + Message.success('删除成功'); + }) + .catch((error) => { + Message.error(error.message); + }); + }, + }); + } + function constructFilterArray(value: QueryParams) { + let start_time = 0; + let end_time = 0; + + if (value.startTime && value.endTime) { + start_time = dayjs(value.startTime).utc().unix(); + end_time = dayjs(value.endTime).utc().unix(); + } else { + const currentDay = dayjs(); + const nineMonthAgoDay = currentDay.subtract(9, 'month'); + start_time = nineMonthAgoDay.utc().unix(); + end_time = currentDay.utc().unix(); + } + const keywordType = + value.eventType === EventType.CROSS_DOMAIN ? value.crossDomainSelectType : value.selectType; + const serialization = filterExpressionGenerator( + { + [keywordType!]: value.keyword, + start_time: start_time, + end_time: end_time, + source: EVENT_SOURCE_TYPE_MAPPER[value.eventType || EventType.INNER], + }, + FILTER_EVENT_OPERATOR_MAPPER, + ); + + setFilterParams({ + keyword: value.keyword, + startTime: value.startTime, + endTime: value.endTime, + }); + setUrlState((prevState) => ({ + ...prevState, + filter: serialization, + radioType: value.radioType, + selectType: value.selectType, + crossDomainSelectType: value.crossDomainSelectType, + eventType: value.eventType, + page: 1, + })); + } + function initFilter(event_type: EventType) { + const currentDay = dayjs(); + const nineMonthAgoDay = currentDay.subtract(9, 'month'); + const start_time = nineMonthAgoDay.utc().unix(); + const end_time = currentDay.utc().unix(); + const filter = filterExpressionGenerator( + { + start_time: start_time, + end_time: end_time, + source: EVENT_SOURCE_TYPE_MAPPER[event_type ?? EventType.INNER], + }, + FILTER_EVENT_OPERATOR_MAPPER, + ); + return filter; + } +}; +export default EventList; diff --git a/web_console_v2/client/src/views/Audit/index.tsx b/web_console_v2/client/src/views/Audit/index.tsx new file mode 100644 index 000000000..2ce10f0ec --- /dev/null +++ b/web_console_v2/client/src/views/Audit/index.tsx @@ -0,0 +1,14 @@ +import React, { FC } from 'react'; +import ErrorBoundary from 'components/ErrorBoundary'; +import { Route } from 'react-router-dom'; +import EventList from './EventList'; + +const Audit: FC = () => { + return ( + <ErrorBoundary> + <Route path="/audit/event" exact component={EventList} /> + </ErrorBoundary> + ); +}; + +export default Audit; diff --git a/web_console_v2/client/src/views/Audit/shared.tsx b/web_console_v2/client/src/views/Audit/shared.tsx new file mode 100644 index 000000000..fecec1ec0 --- /dev/null +++ b/web_console_v2/client/src/views/Audit/shared.tsx @@ -0,0 +1,22 @@ +import { FilterOp } from 'typings/filter'; + +export const FILTER_EVENT_OPERATOR_MAPPER = { + source: FilterOp.IN, + start_time: FilterOp.GREATER_THAN, + end_time: FilterOp.LESS_THAN, + name: FilterOp.CONTAIN, + resource_type: FilterOp.CONTAIN, + username: FilterOp.EQUAL, + resource_name: FilterOp.CONTAIN, + coordinator_pure_domain_name: FilterOp.CONTAIN, +}; + +export const EVENT_SOURCE_TYPE_MAPPER = { + inner: ['UI', 'API', 'UNKNOWN_SOURCE'], + cross_domain: ['RPC'], +}; + +export const EVENT_TYPE_DELETE_MAPPER = { + inner: 'USER_ENDPOINT', + cross_domain: 'RPC', +}; diff --git a/web_console_v2/client/src/views/Cleanup/CleanupDetailDrawer/index.module.less b/web_console_v2/client/src/views/Cleanup/CleanupDetailDrawer/index.module.less new file mode 100644 index 000000000..e69de29bb diff --git a/web_console_v2/client/src/views/Cleanup/CleanupDetailDrawer/index.tsx b/web_console_v2/client/src/views/Cleanup/CleanupDetailDrawer/index.tsx new file mode 100644 index 000000000..c2e834380 --- /dev/null +++ b/web_console_v2/client/src/views/Cleanup/CleanupDetailDrawer/index.tsx @@ -0,0 +1,127 @@ +import React, { FC, useMemo } from 'react'; +import { Drawer, Table, Empty } from '@arco-design/web-react'; +import { DrawerProps } from '@arco-design/web-react/es/Drawer'; +import { Cleanup } from 'typings/cleanup'; +import { LabelStrong } from 'styles/elements'; +import PropertyList from 'components/PropertyList'; +import StateIndicator from 'components/StateIndicator'; +import { calcStateIndicatorProps } from '../CleanupList'; +import { formatTimestamp } from 'shared/date'; +import CONSTANTS from 'shared/constants'; + +export interface Props extends DrawerProps { + data?: Cleanup; +} + +export interface TableProps { + file_path?: string; +} + +const CleanupDetailDrawer: FC<Props> = ({ visible, data, title = 'Cleanup详情', ...restProps }) => { + const displayedProps = useMemo( + () => [ + { + value: data?.id, + label: 'ID', + }, + { + value: <StateIndicator {...calcStateIndicatorProps(data?.state)} />, + label: '状态', + }, + { + value: ( + <span> + {data?.target_start_at + ? formatTimestamp(data?.target_start_at) + : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + label: '目标开始时间', + }, + { + value: ( + <span> + {data?.completed_at ? formatTimestamp(data?.completed_at) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + label: '完成时间', + }, + { + value: <span>{data?.resource_id}</span>, + label: 'Resource ID', + }, + { + value: <span>{data?.resource_type}</span>, + label: '资源类型', + }, + { + value: ( + <span> + {data?.created_at ? formatTimestamp(data?.created_at) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + label: '创建时间', + }, + { + value: ( + <span> + {data?.updated_at ? formatTimestamp(data?.updated_at) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + label: '更新时间', + }, + ], + [data], + ); + + const filePathList = useMemo(() => { + if (!data || data.payload.paths.length === 0) return []; + const list = data.payload.paths.map((item) => { + return { file_path: item }; + }); + return list; + }, [data]); + + const columns = useMemo( + () => [ + { + title: 'payload文件路径', + dataIndex: 'file_path', + name: 'file_path', + render: (_: any, record: TableProps) => <span>{record.file_path}</span>, + }, + ], + [], + ); + + return ( + <Drawer + placement="right" + visible={visible} + title={title} + closable={true} + width="50%" + unmountOnExit + {...restProps} + > + {renderBaseInfo()} + <Table + data={filePathList} + columns={columns} + rowKey="file_path" + noDataElement={<Empty description="暂无数据" />} + /> + </Drawer> + ); + + function renderBaseInfo() { + return ( + <> + <LabelStrong isBlock={true}>基础信息</LabelStrong> + <PropertyList cols={12} colProportions={[1, 1]} properties={displayedProps} /> + </> + ); + } +}; + +export default CleanupDetailDrawer; diff --git a/web_console_v2/client/src/views/Cleanup/CleanupList/index.module.less b/web_console_v2/client/src/views/Cleanup/CleanupList/index.module.less new file mode 100644 index 000000000..60d1bd361 --- /dev/null +++ b/web_console_v2/client/src/views/Cleanup/CleanupList/index.module.less @@ -0,0 +1,5 @@ +.link_text { + display: inline-block; + color: #1664ff; + cursor: pointer; +} diff --git a/web_console_v2/client/src/views/Cleanup/CleanupList/index.tsx b/web_console_v2/client/src/views/Cleanup/CleanupList/index.tsx new file mode 100644 index 000000000..8d32e1c1b --- /dev/null +++ b/web_console_v2/client/src/views/Cleanup/CleanupList/index.tsx @@ -0,0 +1,348 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useToggle } from 'react-use'; +import { Button, Input, Table } from '@arco-design/web-react'; +import { useUrlState } from 'hooks'; +import { fetchCleanupList, postCleanupState } from 'services/cleanup'; +import { Cleanup, CleanupState } from 'typings/cleanup'; +import { FilterOp } from 'typings/filter'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import SharedPageLayout from 'components/SharedPageLayout'; +import GridRow from 'components/_base/GridRow'; +import { constructExpressionTree, expression2Filter } from 'shared/filter'; +import { formatTimestamp } from 'shared/date'; +import CONSTANTS from 'shared/constants'; +import styled from './index.module.less'; +import CleanupDetailDrawer from '../CleanupDetailDrawer'; + +export type QueryParams = { + state?: CleanupState; + resource_type?: string; + resource_id?: string; +}; + +export const calcStateIndicatorProps = ( + state?: CleanupState, +): { type: StateTypes; text: string; tip?: string } => { + let text = CONSTANTS.EMPTY_PLACEHOLDER; + let type = 'default' as StateTypes; + const tip = ''; + + switch (state) { + case CleanupState.WAITING: + text = '等待中'; + type = 'gold'; + break; + case CleanupState.CANCELED: + text = '已撤销'; + type = 'default'; + break; + case CleanupState.RUNNING: + text = '运行中'; + type = 'processing'; + break; + case CleanupState.FAILED: + text = '失败'; + type = 'error'; + break; + case CleanupState.SUCCEEDED: + text = '成功'; + type = 'success'; + break; + } + + return { + text, + type, + tip, + }; +}; + +const CleanupList: FC = () => { + const [isDrawerVisible, setIsDrawerVisible] = useToggle(false); + const [selectedCleanup, setSelectedCleanup] = useState<Cleanup>(); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: '', + }); + + const initFilterParams = expression2Filter(urlState.filter); + const [filterParams, setFilterParams] = useState<QueryParams>({ + state: initFilterParams.state, + resource_type: initFilterParams.resource_type, + resource_id: initFilterParams.resource_id, + }); + + const listQ = useQuery( + ['CLEANUP_QUERY_KEY', urlState], + () => + fetchCleanupList({ + page: urlState.page, + pageSize: urlState.pageSize, + filter: urlState.filter === '' ? undefined : urlState.filter, + }), + { + refetchOnWindowFocus: false, + keepPreviousData: true, + }, + ); + + // Filter the display list by the search string + const itemListShow = useMemo(() => { + if (!listQ.data?.data) { + return []; + } + + return listQ.data.data || []; + }, [listQ.data]); + + const columns = useMemo( + () => [ + { + title: 'ID', + dataIndex: 'id', + name: 'id', + render: (_: any, record: Cleanup) => ( + <span + className={styled.link_text} + onClick={() => { + setSelectedCleanup(record); + setIsDrawerVisible(true); + }} + > + {record.id} + </span> + ), + }, + { + title: '状态', + dataIndex: 'state', + name: 'state', + filters: [ + { + text: '等待中', + value: CleanupState.WAITING, + }, + { + text: '运行中', + value: CleanupState.RUNNING, + }, + { + text: '已撤销', + value: CleanupState.CANCELED, + }, + { + text: '失败', + value: CleanupState.FAILED, + }, + { + text: '成功', + value: CleanupState.SUCCEEDED, + }, + ], + defaultFilters: filterParams.state ? [filterParams.state as string] : [], + filterMultiple: false, + render: (_: any, record: Cleanup) => { + return <StateIndicator {...calcStateIndicatorProps(record.state)} />; + }, + }, + { + title: '目标开始时间', + dataIndex: 'target_start_at', + name: 'target_start_at', + render: (_: any, record: Cleanup) => ( + <span> + {record.target_start_at + ? formatTimestamp(record.target_start_at) + : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + }, + { + title: '完成时间', + dataIndex: 'completed_at', + name: 'completed_at', + render: (_: any, record: Cleanup) => ( + <span> + {record.completed_at + ? formatTimestamp(record.completed_at) + : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + }, + { + title: 'Resource ID', + dataIndex: 'resource_id', + name: 'resource_id', + render: (_: any, record: Cleanup) => <span>{record.resource_id}</span>, + }, + { + title: '资源类型', + dataIndex: 'resource_type', + name: 'resource_type', + render: (_: any, record: Cleanup) => <span>{record.resource_type}</span>, + }, + { + title: '创建时间', + dataIndex: 'created_at', + name: 'created_at', + render: (_: any, record: Cleanup) => ( + <span> + {record.created_at ? formatTimestamp(record.created_at) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + name: 'updated_at', + render: (_: any, record: Cleanup) => ( + <span> + {record.updated_at ? formatTimestamp(record.updated_at) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + }, + { + title: '操作', + dataIndex: 'operation', + name: 'operation', + width: 200, + render: (_: any, record: Cleanup) => { + return ( + <Button + disabled={record.state !== CleanupState.WAITING} + onClick={() => { + postCleanupState(record.id).then(() => { + listQ.refetch(); + }); + }} + > + 取消 + </Button> + ); + }, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [listQ], + ); + + useEffect(() => { + constructFilterArray(filterParams); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterParams]); + + return ( + <SharedPageLayout title="Cleanup"> + <GridRow justify="end" align="center"> + <Input.Search + style={{ paddingRight: 20 }} + allowClear + defaultValue={filterParams.resource_type} + placeholder={'请输入资源类型'} + onSearch={onResourceTypeSearch} + onClear={() => onResourceTypeSearch('')} + /> + <Input.Search + allowClear + defaultValue={filterParams.resource_id} + placeholder={'请输入resource id'} + onSearch={onResourceIdSearch} + onClear={() => onResourceIdSearch('')} + /> + </GridRow> + <Table + loading={listQ.isFetching} + data={itemListShow} + columns={columns} + scroll={{ x: '100%' }} + rowKey="id" + onChange={(_, sorter, filters, extra) => { + if (extra.action === 'filter') { + setFilterParams({ + ...filterParams, + state: (filters.state?.[0] as CleanupState) ?? undefined, + }); + } + }} + pagination={{ + total: listQ.data?.page_meta?.total_items ?? undefined, + current: Number(urlState.page), + pageSize: Number(urlState.pageSize), + onChange: onPageChange, + showTotal: (total: number) => `共 ${total} 条记录`, + }} + /> + <CleanupDetailDrawer + visible={isDrawerVisible} + data={selectedCleanup} + onCancel={onCleanupDetailDrawerClose} + /> + </SharedPageLayout> + ); + + function onPageChange(page: number, pageSize: number | undefined) { + setUrlState((prevState) => ({ + ...prevState, + page, + pageSize, + })); + } + + function onResourceTypeSearch(value: any) { + setFilterParams({ + ...filterParams, + resource_type: value, + }); + } + + function onResourceIdSearch(value: any) { + setFilterParams({ + ...filterParams, + resource_id: value, + }); + } + + function constructFilterArray(value: QueryParams) { + const expressionNodes = []; + + if (value.state) { + expressionNodes.push({ + field: 'state', + op: FilterOp.EQUAL, + string_value: value.state, + }); + } + if (value.resource_type) { + expressionNodes.push({ + field: 'resource_type', + op: FilterOp.EQUAL, + string_value: value.resource_type, + }); + } + if (value.resource_id) { + expressionNodes.push({ + field: 'resource_id', + op: FilterOp.EQUAL, + string_value: value.resource_id, + }); + } + + const serialization = constructExpressionTree(expressionNodes); + if ((serialization || urlState.filter) && serialization !== urlState.filter) { + setUrlState((prevState) => ({ + ...prevState, + filter: serialization, + page: 1, + })); + } + } + + function onCleanupDetailDrawerClose() { + setSelectedCleanup(undefined); + setIsDrawerVisible(false); + } +}; + +export default CleanupList; diff --git a/web_console_v2/client/src/views/Cleanup/index.tsx b/web_console_v2/client/src/views/Cleanup/index.tsx new file mode 100644 index 000000000..13b629045 --- /dev/null +++ b/web_console_v2/client/src/views/Cleanup/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import CleanupList from './CleanupList'; + +function Cleanup() { + return ( + <> + <Route path="/cleanup" exact component={CleanupList} /> + </> + ); +} + +export default Cleanup; diff --git a/web_console_v2/client/src/views/Composer/SchedulerItemDetail/index.tsx b/web_console_v2/client/src/views/Composer/SchedulerItemDetail/index.tsx new file mode 100644 index 000000000..c0b8febb5 --- /dev/null +++ b/web_console_v2/client/src/views/Composer/SchedulerItemDetail/index.tsx @@ -0,0 +1,227 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import { Table } from '@arco-design/web-react'; +import { useUrlState } from 'hooks'; +import { useQuery } from 'react-query'; +import { FilterOp } from 'typings/filter'; +import { constructExpressionTree, expression2Filter } from 'shared/filter'; +import { RunnerStatus, SchedulerRunner } from 'typings/composer'; +import { fetchRunnersByItemId } from 'services/composer'; +import { formatTimestamp } from 'shared/date'; +import { useParams } from 'react-router'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import CONSTANTS from 'shared/constants'; + +const calcStateIndicatorProps = ( + state: RunnerStatus, +): { type: StateTypes; text: string; tip?: string } => { + let text = CONSTANTS.EMPTY_PLACEHOLDER; + let type = 'default' as StateTypes; + const tip = ''; + + switch (state) { + case RunnerStatus.INIT: + text = '初始化'; + type = 'gold'; + break; + case RunnerStatus.RUNNING: + text = '运行中'; + type = 'processing'; + break; + case RunnerStatus.FAILED: + text = '运行失败'; + type = 'error'; + break; + case RunnerStatus.DONE: + text = '已结束'; + type = 'success'; + break; + default: + break; + } + + return { + text, + type, + tip, + }; +}; + +export type QueryParams = { + status?: RunnerStatus; +}; + +const SchedulerItemDetail: FC = () => { + const params = useParams<{ item_id: string }>(); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: '', + }); + + const initFilterParams = expression2Filter(urlState.filter); + const [filterParams, setFilterParams] = useState<QueryParams>({ + status: initFilterParams.status, + }); + + const listQ = useQuery( + ['SCHEDULE_RUNNER_QUERY_KEY', urlState], + () => + fetchRunnersByItemId(params.item_id, { + page: urlState.page, + pageSize: urlState.pageSize, + filter: urlState.filter === '' ? undefined : urlState.filter, + }), + { + refetchOnWindowFocus: false, + }, + ); + + // Filter the display list by the search string + const runnerListShow = useMemo(() => { + if (!listQ.data?.data) { + return []; + } + const templateList = listQ.data.data || []; + return templateList; + }, [listQ.data]); + + const columns = useMemo( + () => [ + { + title: '调度项ID', + dataIndex: 'item_id', + name: 'item_id', + render: (_: any, record: SchedulerRunner) => <span>{record.item_id}</span>, + }, + { + title: '状态', + dataIndex: 'status', + name: 'status', + filters: [ + { + text: '初始化', + value: RunnerStatus.INIT, + }, + { + text: '运行中', + value: RunnerStatus.RUNNING, + }, + { + text: '已结束', + value: RunnerStatus.DONE, + }, + { + text: '运行失败', + value: RunnerStatus.FAILED, + }, + ], + defaultFilters: filterParams.status ? [filterParams.status as string] : [], + filterMultiple: false, + render: (_: any, record: SchedulerRunner) => { + return <StateIndicator {...calcStateIndicatorProps(record.status)} />; + }, + }, + { + title: '开始时间', + dataIndex: 'start_at', + name: 'start_at', + render: (_: any, record: SchedulerRunner) => ( + <span>{formatTimestamp(record.start_at)}</span> + ), + }, + { + title: '结束时间', + dataIndex: 'end_at', + name: 'end_at', + render: (_: any, record: SchedulerRunner) => <span>{formatTimestamp(record.end_at)}</span>, + }, + { + title: '创建时间', + dataIndex: 'created_at', + name: 'created_at', + render: (_: any, record: SchedulerRunner) => ( + <span>{formatTimestamp(record.created_at)}</span> + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + name: 'updated_at', + render: (_: any, record: SchedulerRunner) => ( + <span>{formatTimestamp(record.updated_at)}</span> + ), + }, + { + title: '删除时间', + dataIndex: 'deleted_at', + name: 'deleted_at', + render: (_: any, record: SchedulerRunner) => ( + <span>{record.deleted_at ? formatTimestamp(record.deleted_at!) : '——'}</span> + ), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [listQ], + ); + + useEffect(() => { + constructFilterArray(filterParams); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterParams]); + + return ( + <SharedPageLayout title="调度程序项详情"> + <Table + loading={listQ.isFetching} + data={runnerListShow} + columns={columns} + scroll={{ x: '100%' }} + rowKey="id" + onChange={(_, sorter, filters, extra) => { + if (extra.action === 'filter') { + setFilterParams({ + status: (filters.status?.[0] as RunnerStatus) ?? undefined, + }); + } + }} + pagination={{ + total: listQ.data?.page_meta?.total_items ?? undefined, + current: Number(urlState.page), + pageSize: Number(urlState.pageSize), + onChange: onPageChange, + showTotal: (total: number) => `共 ${total} 条记录`, + }} + /> + </SharedPageLayout> + ); + + function onPageChange(page: number, pageSize: number | undefined) { + setUrlState((prevState) => ({ + ...prevState, + page, + pageSize, + })); + } + + function constructFilterArray(value: QueryParams) { + const expressionNodes = []; + + if (value.status) { + expressionNodes.push({ + field: 'status', + op: FilterOp.EQUAL, + string_value: value.status, + }); + } + + const serialization = constructExpressionTree(expressionNodes); + setUrlState((prevState) => ({ + ...prevState, + filter: serialization, + page: 1, + })); + } +}; + +export default SchedulerItemDetail; diff --git a/web_console_v2/client/src/views/Composer/SchedulerItemList/SchedulerItemActions/index.tsx b/web_console_v2/client/src/views/Composer/SchedulerItemList/SchedulerItemActions/index.tsx new file mode 100644 index 000000000..9cb4d799c --- /dev/null +++ b/web_console_v2/client/src/views/Composer/SchedulerItemList/SchedulerItemActions/index.tsx @@ -0,0 +1,39 @@ +import React, { FC } from 'react'; +import GridRow from 'components/_base/GridRow'; +import { SchedulerItem } from 'typings/composer'; +import SchedulerPipelineDrawer from '../../components/SchedulerPipelineDrawer'; +import { useToggle } from 'react-use'; + +type Props = { + scheduler: SchedulerItem; +}; + +const SchedulerItemActions: FC<Props> = ({ scheduler }) => { + const [pipelineVisible, setPipelineVisible] = useToggle(false); + const code = JSON.stringify(scheduler.pipeline, null, 2); + return ( + <> + <GridRow> + <button + className="custom-text-button" + style={{ + marginRight: 10, + }} + type="button" + onClick={setPipelineVisible} + > + pipeline + </button> + {/* <MoreActions actionList={actionList} /> */} + </GridRow> + <SchedulerPipelineDrawer + title={<span>pipeline</span>} + visible={pipelineVisible} + onClose={setPipelineVisible} + code={code} + /> + </> + ); +}; + +export default SchedulerItemActions; diff --git a/web_console_v2/client/src/views/Composer/SchedulerItemList/index.tsx b/web_console_v2/client/src/views/Composer/SchedulerItemList/index.tsx new file mode 100644 index 000000000..1063b770b --- /dev/null +++ b/web_console_v2/client/src/views/Composer/SchedulerItemList/index.tsx @@ -0,0 +1,288 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { Link } from 'react-router-dom'; +import { Input, Switch, Table } from '@arco-design/web-react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import GridRow from 'components/_base/GridRow'; +import { useUrlState } from 'hooks'; +import { FilterOp } from 'typings/filter'; +import { ItemStatus, SchedulerItem } from 'typings/composer'; +import { fetchSchedulerItemList, patchEditItemState } from 'services/composer'; +import { constructExpressionTree, expression2Filter } from 'shared/filter'; +import { formatTimestamp } from 'shared/date'; +import SchedulerItemActions from './SchedulerItemActions'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { TABLE_COL_WIDTH } from 'shared/constants'; +import CONSTANTS from 'shared/constants'; + +export type QueryParams = { + is_cron?: boolean; + status?: ItemStatus; + name?: string; + id?: string; +}; + +const SchedulerItemList: FC = () => { + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: '', + }); + const initFilterParams = expression2Filter(urlState.filter); + const [filterParams, setFilterParams] = useState<QueryParams>({ + is_cron: initFilterParams.is_cron || false, + status: initFilterParams.status, + name: initFilterParams.name || '', + id: initFilterParams.id, + }); + + const listQ = useQuery( + ['SCHEDULE_ITEM_QUERY_KEY', urlState], + () => + fetchSchedulerItemList({ + page: urlState.page, + pageSize: urlState.pageSize, + filter: urlState.filter === '' ? undefined : urlState.filter, + }), + { + refetchOnWindowFocus: false, + keepPreviousData: true, + }, + ); + + // Filter the display list by the search string + const itemListShow = useMemo(() => { + if (!listQ.data?.data) { + return []; + } + const templateList = listQ.data.data || []; + return templateList; + }, [listQ.data]); + + const columns: ColumnProps[] = useMemo( + () => [ + { + title: '名称', + dataIndex: 'name', + name: 'name', + width: TABLE_COL_WIDTH.NAME, + render: (name: string, record: SchedulerItem) => ( + <Link + to={`/composer/scheduler-item/detail/${record.id}`} + rel="nopener" + className="col-name-link" + > + {name} + </Link> + ), + }, + { + title: '状态', + dataIndex: 'status', + name: 'status', + width: TABLE_COL_WIDTH.NORMAL, + filters: [ + { + text: '开启', + value: ItemStatus.ON, + }, + { + text: '关闭', + value: ItemStatus.OFF, + }, + ], + defaultFilters: filterParams.status ? [filterParams.status as string] : [], + filterMultiple: false, + render: (_: any, record: SchedulerItem) => { + <span>{record.status}</span>; + return ( + <Switch + size="small" + onChange={(value: boolean) => { + patchEditItemState(record.id, value ? ItemStatus.ON : ItemStatus.OFF).then(() => { + listQ.refetch(); + }); + }} + checked={record.status === ItemStatus.ON} + /> + ); + }, + }, + { + title: 'cron_config', + dataIndex: 'cron_config', + name: 'cron_config', + width: TABLE_COL_WIDTH.NORMAL, + filters: [ + { + text: '展示cron_job_items', + value: true, + }, + { + text: '展示all items', + value: false, + }, + ], + filterMultiple: false, + render: (_: any, record: SchedulerItem) => <span>{record.cron_config}</span>, + }, + { + title: '最近运行时间', + dataIndex: 'last_run_at', + name: 'last_run_at', + width: TABLE_COL_WIDTH.TIME, + render: (_: any, record: SchedulerItem) => ( + <span>{formatTimestamp(record.last_run_at)}</span> + ), + }, + { + title: 'retry_cnt', + dataIndex: 'retry_cnt', + name: 'retry_cnt', + width: TABLE_COL_WIDTH.THIN, + render: (_: any, record: SchedulerItem) => <span>{record.retry_cnt}</span>, + }, + { + title: '创建时间', + dataIndex: 'created_at', + name: 'created_at', + width: TABLE_COL_WIDTH.TIME, + render: (_: any, record: SchedulerItem) => ( + <span>{formatTimestamp(record.created_at)}</span> + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + name: 'updated_at', + width: TABLE_COL_WIDTH.NAME, + render: (_: any, record: SchedulerItem) => ( + <span>{formatTimestamp(record.updated_at)}</span> + ), + }, + { + title: '删除时间', + dataIndex: 'deleted_at', + name: 'deleted_at', + width: TABLE_COL_WIDTH.TIME, + render: (_: any, record: SchedulerItem) => ( + <span> + {record.deleted_at ? formatTimestamp(record.deleted_at!) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + }, + { + title: '操作', + dataIndex: 'operation', + name: 'operation', + fixed: 'right', + width: TABLE_COL_WIDTH.NORMAL, + render: (_: number, record: SchedulerItem) => <SchedulerItemActions scheduler={record} />, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [listQ], + ); + + useEffect(() => { + constructFilterArray(filterParams); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterParams]); + + return ( + <SharedPageLayout title="调度程序项"> + <GridRow justify="end" align="center"> + <Input.Search + allowClear + defaultValue={filterParams.name} + onSearch={onSearch} + onClear={() => onSearch('')} + placeholder={'请输入名称'} + /> + </GridRow> + <Table + loading={listQ.isFetching} + data={itemListShow} + columns={columns} + scroll={{ x: '100%' }} + rowKey="id" + onChange={(_, sorter, filters, extra) => { + if (extra.action === 'filter') { + setFilterParams({ + ...filterParams, + is_cron: Boolean(filters.cron_config?.[0]), + status: (filters.status?.[0] as ItemStatus) ?? undefined, + }); + } + }} + pagination={{ + total: listQ.data?.page_meta?.total_items ?? undefined, + current: Number(urlState.page), + pageSize: Number(urlState.pageSize), + onChange: onPageChange, + showTotal: (total: number) => `共 ${total} 条记录`, + }} + /> + </SharedPageLayout> + ); + + function onPageChange(page: number, pageSize: number | undefined) { + setUrlState((prevState) => ({ + ...prevState, + page, + pageSize, + })); + } + + function onSearch(value: any) { + setFilterParams({ + ...filterParams, + name: value, + }); + } + + function constructFilterArray(value: QueryParams) { + const expressionNodes = []; + if (value.is_cron) { + expressionNodes.push({ + field: 'is_cron', + op: FilterOp.EQUAL, + bool_value: value.is_cron, + }); + } + if (value.name) { + expressionNodes.push({ + field: 'name', + op: FilterOp.CONTAIN, + string_value: value.name, + }); + } + + if (value.status) { + expressionNodes.push({ + field: 'status', + op: FilterOp.EQUAL, + string_value: value.status, + }); + } + + if (value.id) { + expressionNodes.push({ + field: 'id', + op: FilterOp.EQUAL, + number_value: Number(value.id), + }); + } + + const serialization = constructExpressionTree(expressionNodes); + if ((serialization || urlState.filter) && serialization !== urlState.filter) { + setUrlState((prevState) => ({ + ...prevState, + filter: serialization, + page: 1, + })); + } + } +}; + +export default SchedulerItemList; diff --git a/web_console_v2/client/src/views/Composer/SchedulerRunnerList/SchedulerRunnerActions/index.tsx b/web_console_v2/client/src/views/Composer/SchedulerRunnerList/SchedulerRunnerActions/index.tsx new file mode 100644 index 000000000..5c8cb7dbf --- /dev/null +++ b/web_console_v2/client/src/views/Composer/SchedulerRunnerList/SchedulerRunnerActions/index.tsx @@ -0,0 +1,51 @@ +import React, { FC, useMemo } from 'react'; +import GridRow from 'components/_base/GridRow'; +import { SchedulerRunner } from 'typings/composer'; +import SchedulerPipelineDrawer from '../../components/SchedulerPipelineDrawer'; +import { useToggle } from 'react-use'; + +type Props = { + scheduler: SchedulerRunner; +}; + +const SchedulerItemActions: FC<Props> = ({ scheduler }) => { + const [pipelineVisible, setPipelineVisible] = useToggle(false); + const codeString = useMemo(() => { + if (!scheduler) return ''; + const { pipeline, output, context } = scheduler; + return JSON.stringify( + { + pipeline, + context, + output, + }, + null, + 2, + ); + }, [scheduler]); + return ( + <> + <GridRow> + <button + className="custom-text-button" + style={{ + marginRight: 10, + }} + type="button" + onClick={setPipelineVisible} + > + 数据详情 + </button> + {/* <MoreActions actionList={actionList} /> */} + </GridRow> + <SchedulerPipelineDrawer + title={<span>数据详情</span>} + visible={pipelineVisible} + onClose={setPipelineVisible} + code={codeString} + /> + </> + ); +}; + +export default SchedulerItemActions; diff --git a/web_console_v2/client/src/views/Composer/SchedulerRunnerList/index.tsx b/web_console_v2/client/src/views/Composer/SchedulerRunnerList/index.tsx new file mode 100644 index 000000000..07c35f8dd --- /dev/null +++ b/web_console_v2/client/src/views/Composer/SchedulerRunnerList/index.tsx @@ -0,0 +1,278 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import SharedPageLayout from 'components/SharedPageLayout'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import { Table } from '@arco-design/web-react'; +import { useUrlState } from 'hooks'; +import { RunnerStatus, SchedulerRunner } from 'typings/composer'; +import { FilterOp } from 'typings/filter'; +import { fetchSchedulerRunnerList } from 'services/composer'; +import { constructExpressionTree, expression2Filter } from 'shared/filter'; +import { formatTimestamp } from 'shared/date'; +import { Link } from 'react-router-dom'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { TABLE_COL_WIDTH } from 'shared/constants'; +import SchedulerRunnerActions from './SchedulerRunnerActions'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import CONSTANTS from 'shared/constants'; + +export type QueryParams = { + status?: RunnerStatus; +}; + +const calcStateIndicatorProps = ( + state: RunnerStatus, +): { type: StateTypes; text: string; tip?: string } => { + let text = CONSTANTS.EMPTY_PLACEHOLDER; + let type = 'default' as StateTypes; + const tip = ''; + + switch (state) { + case RunnerStatus.INIT: + text = '初始化'; + type = 'gold'; + break; + case RunnerStatus.RUNNING: + text = '运行中'; + type = 'processing'; + break; + case RunnerStatus.FAILED: + text = '运行失败'; + type = 'error'; + break; + case RunnerStatus.DONE: + text = '已结束'; + type = 'success'; + break; + default: + break; + } + + return { + text, + type, + tip, + }; +}; + +const SchedulerRunnerList: FC = () => { + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: '', + }); + + const initFilterParams = expression2Filter(urlState.filter); + const [filterParams, setFilterParams] = useState<QueryParams>({ + status: initFilterParams.status, + }); + + const listQ = useQuery( + ['SCHEDULE_RUNNER_QUERY_KEY', urlState], + () => + fetchSchedulerRunnerList({ + page: urlState.page, + pageSize: urlState.pageSize, + filter: urlState.filter === '' ? undefined : urlState.filter, + }), + { + refetchOnWindowFocus: false, + keepPreviousData: true, + }, + ); + + // Filter the display list by the search string + const runnerListShow = useMemo(() => { + if (!listQ.data?.data) { + return []; + } + const templateList = listQ.data.data || []; + return templateList; + }, [listQ.data]); + + const columns: ColumnProps[] = useMemo( + () => [ + { + title: '调度项ID', + dataIndex: 'item_id', + name: 'item_id', + width: TABLE_COL_WIDTH.NAME, + render: (_: any, record: SchedulerRunner) => { + const filter = filterExpressionGenerator( + { + id: record.item_id, + }, + { + id: FilterOp.EQUAL, + }, + ); + return ( + <Link + to={(location) => ({ + ...location, + pathname: `/composer/scheduler-item/list`, + search: location.search + ? `${location.search}&filter=${filter}` + : `?filter=${filter}`, + })} + > + {record.item_id} + </Link> + ); + }, + }, + { + title: '状态', + dataIndex: 'status', + name: 'status', + width: TABLE_COL_WIDTH.NORMAL, + filters: [ + { + text: '初始化', + value: RunnerStatus.INIT, + }, + { + text: '运行中', + value: RunnerStatus.RUNNING, + }, + { + text: '已结束', + value: RunnerStatus.DONE, + }, + { + text: '运行失败', + value: RunnerStatus.FAILED, + }, + ], + defaultFilters: filterParams.status ? [filterParams.status as string] : [], + filterMultiple: false, + render: (_: any, record: SchedulerRunner) => { + return <StateIndicator {...calcStateIndicatorProps(record.status)} />; + }, + }, + { + title: '开始时间', + dataIndex: 'start_at', + name: 'start_at', + width: TABLE_COL_WIDTH.NORMAL, + render: (_: any, record: SchedulerRunner) => ( + <span>{formatTimestamp(record.start_at)}</span> + ), + }, + { + title: '结束时间', + dataIndex: 'end_at', + name: 'end_at', + width: TABLE_COL_WIDTH.NORMAL, + render: (_: any, record: SchedulerRunner) => ( + <span> + {record.end_at ? formatTimestamp(record.end_at) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + name: 'created_at', + width: TABLE_COL_WIDTH.NORMAL, + render: (_: any, record: SchedulerRunner) => ( + <span>{formatTimestamp(record.created_at)}</span> + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + name: 'updated_at', + width: TABLE_COL_WIDTH.NORMAL, + render: (_: any, record: SchedulerRunner) => ( + <span>{formatTimestamp(record.updated_at)}</span> + ), + }, + { + title: '删除时间', + dataIndex: 'deleted_at', + name: 'deleted_at', + width: TABLE_COL_WIDTH.NORMAL, + render: (_: any, record: SchedulerRunner) => ( + <span> + {record.deleted_at ? formatTimestamp(record.deleted_at!) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ), + }, + { + title: '操作', + dataIndex: 'operation', + name: 'operation', + fixed: 'right', + width: TABLE_COL_WIDTH.NORMAL, + render: (_: number, record: SchedulerRunner) => ( + <SchedulerRunnerActions scheduler={record} /> + ), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [listQ], + ); + + useEffect(() => { + constructFilterArray(filterParams); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterParams]); + + return ( + <SharedPageLayout title="调度程序运行器"> + <Table + loading={listQ.isFetching} + data={runnerListShow} + columns={columns} + scroll={{ x: '100%' }} + rowKey="id" + onChange={(_, sorter, filters, extra) => { + if (extra.action === 'filter') { + setFilterParams({ + status: (filters.status?.[0] as RunnerStatus) ?? undefined, + }); + } + }} + pagination={{ + total: listQ.data?.page_meta?.total_items ?? undefined, + current: Number(urlState.page), + pageSize: Number(urlState.pageSize), + onChange: onPageChange, + showTotal: (total: number) => `共 ${total} 条记录`, + }} + /> + </SharedPageLayout> + ); + + function onPageChange(page: number, pageSize: number | undefined) { + setUrlState((prevState) => ({ + ...prevState, + page, + pageSize, + })); + } + + function constructFilterArray(value: QueryParams) { + const expressionNodes = []; + + if (value.status) { + expressionNodes.push({ + field: 'status', + op: FilterOp.EQUAL, + string_value: value.status, + }); + } + + const serialization = constructExpressionTree(expressionNodes); + if ((serialization || urlState.filter) && serialization !== urlState.filter) { + setUrlState((prevState) => ({ + ...prevState, + filter: serialization, + page: 1, + })); + } + } +}; + +export default SchedulerRunnerList; diff --git a/web_console_v2/client/src/views/Composer/components/SchedulerPipelineDrawer/index.tsx b/web_console_v2/client/src/views/Composer/components/SchedulerPipelineDrawer/index.tsx new file mode 100644 index 000000000..74f3915ee --- /dev/null +++ b/web_console_v2/client/src/views/Composer/components/SchedulerPipelineDrawer/index.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react'; +import { Drawer } from '@arco-design/web-react'; +import CodeEditor from 'components/CodeEditor'; +type Props = { + title: React.ReactNode; + visible: boolean; + onClose: () => void; + code: string; +}; +const SchedulerPipelineDrawer: FC<Props> = ({ title, visible, onClose, code }) => { + return ( + <Drawer width={500} title={title} visible={visible} onOk={onClose} onCancel={onClose}> + <CodeEditor value={code} language="json" theme="grey" /> + </Drawer> + ); +}; + +export default SchedulerPipelineDrawer; diff --git a/web_console_v2/client/src/views/Composer/index.tsx b/web_console_v2/client/src/views/Composer/index.tsx new file mode 100644 index 000000000..e5f7eba87 --- /dev/null +++ b/web_console_v2/client/src/views/Composer/index.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; +import SchedulerItemList from './SchedulerItemList'; +import SchedulerItemDetail from './SchedulerItemDetail'; +import SchedulerRunnerList from './SchedulerRunnerList'; +import ErrorBoundary from 'components/ErrorBoundary'; + +function Composer() { + const location = useLocation(); + return ( + <ErrorBoundary> + <Switch> + <Route path={'/composer/scheduler-item/list'} exact component={SchedulerItemList} /> + <Route path={'/composer/scheduler-runner/list'} exact component={SchedulerRunnerList} /> + <Route + path={'/composer/scheduler-item/detail/:item_id'} + exact + component={SchedulerItemDetail} + /> + {location.pathname === '/composer' && <Redirect to="/composer/scheduler-item/lis" />} + </Switch> + </ErrorBoundary> + ); +} + +export default Composer; diff --git a/web_console_v2/client/src/views/Dashboard/DashboardDetail/index.module.less b/web_console_v2/client/src/views/Dashboard/DashboardDetail/index.module.less new file mode 100644 index 000000000..557b8c972 --- /dev/null +++ b/web_console_v2/client/src/views/Dashboard/DashboardDetail/index.module.less @@ -0,0 +1,7 @@ +.container { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + width: 100%; + height: 100%; +} diff --git a/web_console_v2/client/src/views/Dashboard/DashboardDetail/index.tsx b/web_console_v2/client/src/views/Dashboard/DashboardDetail/index.tsx new file mode 100644 index 000000000..4349d2894 --- /dev/null +++ b/web_console_v2/client/src/views/Dashboard/DashboardDetail/index.tsx @@ -0,0 +1,92 @@ +import React, { FC, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import SharedPageLayout from 'components/SharedPageLayout'; +import styled from './index.module.less'; +import { useQuery } from 'react-query'; +import { fetchDashboardList } from 'services/operation'; +import { Empty, Space, Spin, Tooltip } from '@arco-design/web-react'; +import { IconSend } from '@arco-design/web-react/icon'; +import CodeEditor from 'components/CodeEditor'; + +type Props = {}; + +const Dashboard: FC<Props> = () => { + const { uuid } = useParams<{ uuid: string }>(); + const dashboardQuery = useQuery('fetchDashboardList', () => fetchDashboardList(), {}); + const dashboardURL = useMemo(() => { + if (!dashboardQuery.data) { + return ''; + } + return (dashboardQuery.data?.data || []).find((item) => item.uuid === uuid)?.url; + }, [dashboardQuery.data, uuid]); + if (!uuid) { + return ( + <SharedPageLayout title={'仪表盘'}> + <div className={styled.container}> + <Empty description={renderEmptyTips()} /> + </div> + </SharedPageLayout> + ); + } + return ( + <SharedPageLayout + title={'仪表盘'} + rightTitle={ + <span> + <button + className="custom-text-button" + onClick={() => { + window.open(dashboardURL, '_blank'); + }} + > + <Tooltip position={'left'} trigger="hover" content="新页面打开"> + <IconSend /> + </Tooltip> + </button> + </span> + } + > + <div className={styled.container}> + {dashboardQuery.isFetching ? ( + <Spin style={{ display: 'block' }} /> + ) : ( + <iframe + style={{ + width: '100%', + height: '100%', + }} + title={'dashboard'} + src={dashboardURL} + /> + )} + </div> + </SharedPageLayout> + ); + + function renderEmptyTips() { + return ( + <Space direction={'vertical'}> + <h3> + dashboard功能暂未开启,如需开启须确保此FLAG 「dashboard_enabled」 和环境变量 + 「KIBANA_DASHBOARD_LIST」设置正确 + </h3> + <div + style={{ + height: '100px', + }} + > + <CodeEditor + language={'python'} + isReadOnly={true} + value={ + 'FLAGS=\'{"dashboard_enabled": true}\'\n' + + 'KIBANA_DASHBOARD_LIST=\'[{"name": "overview", "uuid": "<uuid-of-kibana-dashboard>"}]\'' + } + /> + </div> + </Space> + ); + } +}; + +export default Dashboard; diff --git a/web_console_v2/client/src/views/DataFix/DataFixForm/index.less b/web_console_v2/client/src/views/DataFix/DataFixForm/index.less new file mode 100644 index 000000000..5e43c625d --- /dev/null +++ b/web_console_v2/client/src/views/DataFix/DataFixForm/index.less @@ -0,0 +1,11 @@ +.data-fix-container{ + display: flex; + justify-content: space-between; + flex-wrap: wrap; + width: 100%; + height: 100%; + .data-fix-form{ + width: 520px; + margin: 0 auto; + } +} diff --git a/web_console_v2/client/src/views/DataFix/DataFixForm/index.tsx b/web_console_v2/client/src/views/DataFix/DataFixForm/index.tsx new file mode 100644 index 000000000..b8a5892af --- /dev/null +++ b/web_console_v2/client/src/views/DataFix/DataFixForm/index.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import { Button, Form, Input, Message, Switch, Select } from '@arco-design/web-react'; +import { datasetFix } from 'services/operation'; +import { to } from 'shared/helpers'; +import { DatasetForceState } from 'typings/dataset'; +import { useToggle } from 'react-use'; +import './index.less'; + +const FormItem = Form.Item; + +type formData = { + dataset_id: ID; + open_force: boolean; + force: DatasetForceState; +}; + +const forceOptions = [ + { label: '成功', value: DatasetForceState.SUCCEEDED }, + { label: '运行中', value: DatasetForceState.RUNNING }, + { label: '失败', value: DatasetForceState.FAILED }, +]; + +export default function DataFixForm() { + const [forceToggle, setForceToggle] = useToggle(false); + const [formInstance] = Form.useForm<formData>(); + const initFormData = { + open_force: false, + force: DatasetForceState.SUCCEEDED, + }; + return ( + <SharedPageLayout title={'数据集修复'}> + <div className="data-fix-container"> + <Form className="data-fix-form" initialValues={initFormData} form={formInstance}> + <FormItem + label="数据集ID" + field="dataset_id" + required + rules={[ + { + type: 'string', + required: true, + message: '请输入数据集ID', + }, + ]} + > + <Input placeholder="输入需要修复的数据集ID" allowClear /> + </FormItem> + <FormItem label="强制转换状态" field="open_force"> + <Switch onChange={(val) => setForceToggle(val)} /> + </FormItem> + {forceToggle && forceSelectRender()} + <FormItem + wrapperCol={{ + offset: 10, + }} + > + <Button type="primary" style={{ marginRight: 24 }} onClick={submitForm}> + {'提交'} + </Button> + <Button + onClick={() => { + formInstance.resetFields(); + }} + > + {'重置'} + </Button> + </FormItem> + </Form> + </div> + </SharedPageLayout> + ); + + function forceSelectRender() { + return ( + <FormItem label="数据集状态" field="force"> + <Select options={forceOptions} /> + </FormItem> + ); + } + + async function submitForm() { + const params = formInstance.getFieldsValue(); + const datasetId = params.dataset_id; + const openForce = params.open_force; + const force = params.force; + if (datasetId) { + const [, err] = await to( + datasetFix({ + datasetId, + force: openForce ? force : undefined, + }), + ); + if (err) { + return Message.error(err.message); + } + return Message.success('修复成功'); + } + } +} diff --git a/web_console_v2/client/src/views/DataFix/index.tsx b/web_console_v2/client/src/views/DataFix/index.tsx new file mode 100644 index 000000000..eba272285 --- /dev/null +++ b/web_console_v2/client/src/views/DataFix/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import DataFixForm from './DataFixForm'; + +function DataFix() { + return ( + <> + <Route path="/data_fix" exact component={DataFixForm} /> + </> + ); +} + +export default DataFix; diff --git a/web_console_v2/client/src/views/Datasets/CreateDataSource/FormModel/index.module.less b/web_console_v2/client/src/views/Datasets/CreateDataSource/FormModel/index.module.less new file mode 100644 index 000000000..5383ffd47 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateDataSource/FormModel/index.module.less @@ -0,0 +1,42 @@ +.datas_source_create_form{ + max-width: 480px; + margin: 0 auto; +} +.data_source_create_section{ + margin-bottom: 20px; + overflow: hidden; // bfc + > h3 { + margin-bottom: 20px; + font-weight: 500; + font-size: 14px; + color: #1d252f; + } + .title-tag{ + margin: 0 12px 0 12px; + } +} + +.data_source_url{ + display: inline-block; +} + +.data_source_form_label{ + display: inline-block; + font-size: 14px; + font-weight: 400; + color: #4e5969; +} + +.data_source_is_update_text{ + color: #1664FF; + cursor: pointer; +} + +.data_source_is_update_rule{ + color: #4E5969; + font-size: 12px; + line-height: 18px; + & p{ + margin-bottom: 0; + } +} diff --git a/web_console_v2/client/src/views/Datasets/CreateDataSource/FormModel/index.tsx b/web_console_v2/client/src/views/Datasets/CreateDataSource/FormModel/index.tsx new file mode 100644 index 000000000..da391fcb0 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateDataSource/FormModel/index.tsx @@ -0,0 +1,336 @@ +import React, { useState, useMemo } from 'react'; +import { Form, Input, Button, Space, Switch, Tag, Popover } from '@arco-design/web-react'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import { validNamePattern, MAX_COMMENT_LENGTH } from 'shared/validator'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import { Image, Struct, UnStruct } from 'components/IconPark'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import TitleWithIcon from 'components/TitleWithIcon'; +import GridRow from 'components/_base/GridRow'; +import BlockRadio from 'components/_base/BlockRadio'; +import { DataSourceDataType, DataSourceStructDataType, DatasetType } from 'typings/dataset'; +import debounce from 'debounce-promise'; +import { checkDataSourceConnection } from 'services/dataset'; +import { useGetAppFlagValue, useIsFormValueChange } from 'hooks'; +import { FlagKey } from 'typings/flag'; + +import styled from './index.module.less'; + +export interface Props<T = any> { + isEdit: boolean; + onCancel?: () => void; + onChange?: (values: T) => void; + onOk?: (values: T) => Promise<void>; +} + +export interface FormData { + name: string; + data_source_url: string; + dataset_format: DataSourceDataType; + store_format?: DataSourceStructDataType; + dataset_type: DatasetType; +} + +const dataSourceDataTypeAssets = { + [DataSourceDataType.STRUCT]: { explain: '支持 csv、tfrecords', icon: <Struct /> }, + [DataSourceDataType.NONE_STRUCTURED]: { + explain: '支持 fastq、bam、vcf、rsa等', + icon: <UnStruct />, + }, + [DataSourceDataType.PICTURE]: { explain: '支持 JPEG、PNG、BMP、GIF', icon: <Image /> }, +}; + +const structDataOptions = [ + { + value: DataSourceStructDataType.CSV, + label: 'csv', + }, + { + value: DataSourceStructDataType.TFRECORDS, + label: 'tfrecords', + }, +]; + +const FormModal: React.FC<Props<FormData>> = function ({ onCancel, onChange, onOk, isEdit }) { + const [formInstance] = Form.useForm<any>(); + const [formData, setFormData] = useState<Partial<FormData>>({ + dataset_format: DataSourceDataType.STRUCT, + store_format: DataSourceStructDataType.CSV, + dataset_type: DatasetType.PSI, + }); + const trusted_computing_enabled = useGetAppFlagValue(FlagKey.TRUSTED_COMPUTING_ENABLED); + const [isCreating, setIsCreating] = useState(false); + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(onFormChange); + const [connectionState, setConnectionState] = useState<'connecting' | 'success' | 'fail'>(); + const [fileNameList, setFileNameList] = useState<string[]>([]); + const [extraFileCount, setExtraFileCount] = useState<number>(0); + + const dataSourceDataTypeOptions = useMemo(() => { + const options = [ + { + value: DataSourceDataType.STRUCT, + label: '结构化数据', + }, + { + value: DataSourceDataType.PICTURE, + label: '图片', + }, + ]; + if (trusted_computing_enabled) { + options.push({ + value: DataSourceDataType.NONE_STRUCTURED, + label: '非结构化数据', + }); + } + return options; + }, [trusted_computing_enabled]); + + const stateIndicatorProps = useMemo(() => { + let type: StateTypes = 'processing'; + let text = 'processing'; + switch (connectionState) { + case 'connecting': + type = 'processing'; + text = '连接中'; + break; + case 'success': + type = 'success'; + text = '连接成功'; + break; + case 'fail': + type = 'error'; + text = '连接失败'; + break; + default: + break; + } + + return { + type, + text, + }; + }, [connectionState]); + + const handleCheckDataSource = debounce(async function (value: any, cb) { + if (isEdit || !formInstance.getFieldValue('data_source_url')) { + setConnectionState(undefined); + setFileNameList([]); + setExtraFileCount(0); + return; + } + setConnectionState('connecting'); + setFileNameList([]); + setExtraFileCount(0); + try { + const resp = await checkDataSourceConnection({ + dataset_type: formInstance.getFieldValue('dataset_type'), + data_source_url: formInstance.getFieldValue('data_source_url'), + file_num: 3, + }); + setFileNameList(resp?.data?.file_names ?? []); + setExtraFileCount(resp?.data?.extra_nums ?? 0); + setConnectionState('success'); + typeof cb === 'function' && cb(undefined); + } catch (error) { + setConnectionState('fail'); + typeof cb === 'function' && cb(' '); // one space string, validate error but don't show any message + } + }, 300); + + return ( + <Form + className={styled.datas_source_create_form} + initialValues={formData} + layout="vertical" + form={formInstance} + onSubmit={onSubmit} + onValuesChange={onFormValueChange} + scrollToFirstError + > + {renderBaseConfigLayout()} + {renderDataImport()} + {renderFooterButton()} + </Form> + ); + + function onSubmit(values: FormData) { + if (connectionState === 'connecting' || connectionState === 'fail') { + return; + } + setIsCreating(true); + values.dataset_type = formInstance.getFieldValue('dataset_type'); + + if (values.dataset_format !== DataSourceDataType.STRUCT) { + delete values.store_format; + } + onOk?.(values).finally(() => { + setIsCreating(false); + }); + } + function renderBaseConfigLayout() { + return ( + <section className={styled.data_source_create_section}> + <h3>基本配置</h3> + <Form.Item + field="name" + label="数据源名称" + hasFeedback + rules={[ + { required: true, message: '请输入' }, + { + match: validNamePattern, + message: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ]} + > + <Input placeholder="请输入" maxLength={60} /> + </Form.Item> + <Form.Item + field="comment" + label="描述" + rules={[{ maxLength: MAX_COMMENT_LENGTH, message: '最多为 200 个字符' }]} + > + <Input.TextArea rows={3} placeholder="最多为 200 个字符" showWordLimit /> + </Form.Item> + <Form.Item field="dataset_format" label="数据类型" rules={[{ required: true }]}> + <BlockRadio + options={dataSourceDataTypeOptions} + isOneHalfMode={false} + flexGrow={0} + blockItemWidth={232} + renderBlockInner={(item, { label, isActive }) => ( + <GridRow + style={{ + height: '55px', + }} + gap="10" + > + <div className="dataset-type-indicator" data-is-active={isActive}> + {dataSourceDataTypeAssets[item.value as DataSourceDataType].icon} + </div> + + <div> + {label} + <div className="dataset-type-explain"> + {dataSourceDataTypeAssets[item.value as DataSourceDataType].explain} + </div> + </div> + </GridRow> + )} + /> + </Form.Item> + {formData.dataset_format === DataSourceDataType.STRUCT ? ( + <Form.Item + field="store_format" + label="数据格式" + rules={[{ required: true, message: '请选择数据集类型' }]} + > + <BlockRadio options={structDataOptions} isOneHalfMode={true} isCenter={true} /> + </Form.Item> + ) : null} + </section> + ); + } + + function renderDataImport() { + return ( + <section className={styled.data_source_create_section}> + <h3>数据源导入</h3> + {formData.dataset_format === DataSourceDataType.STRUCT && ( + <Form.Item field="is_update" label="增量更新"> + <Space> + <Switch onChange={handleIsUpdateChange} /> + <TitleWithIcon + title="开启后,将校验数据源路径下子目录数据格式的文件," + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + <Popover + trigger="click" + title="数据源格式要求" + content={ + <span className={styled.data_source_is_update_rule}> + <p> + 1. + 请确认将当前数据路径下包含子文件夹,并且子文件夹以YYYYMMDD(如20201231)或YYYYMMDD-HH(如20201231-12)命名 + </p> + <p>2. 请确认包含 raw_id 列</p> + </span> + } + > + <span className={styled.data_source_is_update_text}>查看格式要求</span> + </Popover> + </Space> + </Form.Item> + )} + <Form.Item + style={{ position: 'relative' }} + hasFeedback + field="data_source_url" + label={ + <div className={styled.data_source_url}> + <span>数据来源</span> + <StateIndicator + containerStyle={{ + position: 'absolute', + right: 0, + top: 0, + visibility: connectionState ? 'visible' : 'hidden', + }} + {...stateIndicatorProps} + /> + </div> + } + rules={[ + { required: true, message: '请输入' }, + { + validator: handleCheckDataSource, + }, + ]} + > + <Input + placeholder="请填写有效文件目录地址,非文件,如 hdfs:///home/folder" + onClear={onDataSourceUrlClear} + allowClear + /> + </Form.Item> + <Form.Item field="__file_name_list" label="文件名预览"> + <Space> + <span className={styled.data_source_form_label}> + {fileNameList.length > 0 ? fileNameList.join('、') : '暂无数据'} + </span> + {Boolean(extraFileCount) && <Tag>+{extraFileCount}</Tag>} + </Space> + </Form.Item> + </section> + ); + } + function renderFooterButton() { + return ( + <Space> + <Button type="primary" htmlType="submit" loading={isCreating}> + 确认创建 + </Button> + <ButtonWithModalConfirm onClick={onCancel} isShowConfirmModal={isFormValueChanged}> + 取消 + </ButtonWithModalConfirm> + </Space> + ); + } + function handleIsUpdateChange(value: boolean) { + formInstance.setFieldValue('dataset_type', value ? DatasetType.STREAMING : DatasetType.PSI); + handleCheckDataSource(value, () => {}); + } + function onFormChange(_: Partial<FormData>, values: FormData) { + onChange?.(values); + setFormData(values); + } + function onDataSourceUrlClear() { + setConnectionState(undefined); + setFileNameList([]); + setExtraFileCount(0); + } +}; + +export default FormModal; diff --git a/web_console_v2/client/src/views/Datasets/CreateDataSource/index.tsx b/web_console_v2/client/src/views/Datasets/CreateDataSource/index.tsx new file mode 100644 index 000000000..2dd9e2fd3 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateDataSource/index.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BackButton from 'components/BackButton'; +import { useHistory, useParams } from 'react-router'; +import { useIsFormValueChange, useGetCurrentProjectId } from 'hooks'; +import { Spin, Message } from '@arco-design/web-react'; +import FormModal, { FormData } from './FormModel/index'; +import { createDataSource } from 'services/dataset'; +import { DataSourceCreatePayload } from 'typings/dataset'; +import { useMutation } from 'react-query'; + +const NewCreateDataSource: React.FC = function () { + const history = useHistory(); + const { action } = useParams<{ + action: 'create' | 'edit'; + }>(); + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(); + const projectId = useGetCurrentProjectId(); + + const isLoading = false; + const isEdit = action === 'edit'; + + const createMutation = useMutation( + (payload: DataSourceCreatePayload) => { + return createDataSource(payload); + }, + { + onSuccess() { + Message.success('创建成功'); + goBackToListPage(); + }, + onError(e: any) { + Message.error(e.message); + }, + }, + ); + + return ( + <SharedPageLayout + title={ + <BackButton isShowConfirmModal={isFormValueChanged} onClick={goBackToListPage}> + 数据源 + </BackButton> + } + centerTitle={isEdit ? '编辑数据源' : '创建数据源'} + > + <Spin loading={isLoading}> + <FormModal + isEdit={isEdit} + onCancel={backToList} + onChange={onFormValueChange} + onOk={onFormModalSubmit} + /> + </Spin> + </SharedPageLayout> + ); + + function goBackToListPage() { + history.push('/datasets/data_source'); + } + + function backToList() { + history.goBack(); + } + + async function onFormModalSubmit(values: FormData) { + createMutation.mutate({ + project_id: projectId!, + data_source: values, + }); + } +}; + +export default NewCreateDataSource; diff --git a/web_console_v2/client/src/views/Datasets/CreateDataset/DatasetChecker/index.tsx b/web_console_v2/client/src/views/Datasets/CreateDataset/DatasetChecker/index.tsx new file mode 100644 index 000000000..97ba4087e --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateDataset/DatasetChecker/index.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { DATASET_SCHEMA_CHECKER } from 'typings/dataset'; +import { Checkbox, Space } from '@arco-design/web-react'; +import TitleWithIcon from 'components/TitleWithIcon'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; + +interface IDatasetChecker { + value?: DATASET_SCHEMA_CHECKER[]; + onChange?: (val: DATASET_SCHEMA_CHECKER[]) => void; +} + +export default function DatasetChecker(props: IDatasetChecker) { + const { value, onChange } = props; + const [checkState, setCheckState] = useState({ + join: true, + numeric: false, + }); + useEffect(() => { + const newValue: DATASET_SCHEMA_CHECKER[] = []; + !!checkState.join && newValue.push(DATASET_SCHEMA_CHECKER.RAW_ID_CHECKER); + !!checkState.numeric && newValue.push(DATASET_SCHEMA_CHECKER.NUMERIC_COLUMNS_CHECKER); + onChange?.(newValue); + }, [checkState, onChange]); + + const updateState = (key: 'join' | 'numeric') => { + setCheckState((pre) => ({ + ...pre, + [key]: !pre[key], + })); + }; + + const isJoinChecked = useMemo(() => { + return Array.isArray(value) && value.includes(DATASET_SCHEMA_CHECKER.RAW_ID_CHECKER); + }, [value]); + const isNumericChecked = useMemo(() => { + return Array.isArray(value) && value.includes(DATASET_SCHEMA_CHECKER.NUMERIC_COLUMNS_CHECKER); + }, [value]); + return ( + <Space size="large"> + <Checkbox + checked={isJoinChecked} + onChange={() => { + updateState('join'); + }} + > + { + <TitleWithIcon + isShowIcon={true} + isBlock={false} + title="求交数据校验" + icon={IconInfoCircle} + tip="当数据集需用于求交时,需勾选该选项,将要求数据集必须有raw_id 列且没有重复值" + /> + } + </Checkbox> + <Checkbox + checked={isNumericChecked} + onChange={() => { + updateState('numeric'); + }} + > + { + <TitleWithIcon + isShowIcon={true} + isBlock={false} + title="全数值特征校验" + icon={IconInfoCircle} + tip="当数据集需用于树模型训练时,需勾选该选项,将要求数据集特征必须为全数值" + /> + } + </Checkbox> + </Space> + ); +} diff --git a/web_console_v2/client/src/views/Datasets/CreateDataset/PublishChecker/index.less b/web_console_v2/client/src/views/Datasets/CreateDataset/PublishChecker/index.less new file mode 100644 index 000000000..faed6f4c8 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateDataset/PublishChecker/index.less @@ -0,0 +1,38 @@ +.publish-checker-container{ + height: 64px; + background-image: url('../../../../assets/images/dataset-publish-bg.png'); + background-size: cover; + position: relative; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + .publish-text{ + display: flex; + flex-direction: column; + align-items: flex-start; + margin-left: 12px; + font-family: 'PingFang SC'; + font-style: normal; + font-weight: 500; + font-size: 12px; + line-height: 20px; + color: #1d2129; + span:nth-child(2) { + color: #4e5969; + font-weight: 400; + } + } + .credit-card{ + position: absolute; + right: 12px; + top: 12px; + background: #ffffff; + opacity: 0.7; + border-radius: 40px; + padding: 0 8px; + } + .credit-icon{ + display: inline-block; + } +} diff --git a/web_console_v2/client/src/views/Datasets/CreateDataset/PublishChecker/index.tsx b/web_console_v2/client/src/views/Datasets/CreateDataset/PublishChecker/index.tsx new file mode 100644 index 000000000..309b4aa07 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateDataset/PublishChecker/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Checkbox } from '@arco-design/web-react'; +import creditsIcon from 'assets/icons/credits-icon.svg'; +import { useGetAppFlagValue } from 'hooks'; +import { FlagKey } from 'typings/flag'; +import './index.less'; + +type TPublishChecker = { + value?: boolean; + onChange?: (val: boolean) => void; +}; + +export default function PublishChecker(prop: TPublishChecker) { + const { value, onChange } = prop; + const handleOnChange = (val: boolean) => { + onChange?.(val); + }; + const bcs_support_enabled = useGetAppFlagValue(FlagKey.BCS_SUPPORT_ENABLED); + return ( + <div className="publish-checker-container"> + <Checkbox checked={value} onChange={handleOnChange} /> + <div className="publish-text"> + <span>发布至工作区</span> + <span>发布后,工作区中合作伙伴可使用该数据集</span> + </div> + {!!bcs_support_enabled && ( + <div className="credit-card"> + <img className="credit-icon" src={creditsIcon} alt="credit-icon" /> + 100积分 + </div> + )} + </div> + ); +} diff --git a/web_console_v2/client/src/views/Datasets/CreateDataset/index.less b/web_console_v2/client/src/views/Datasets/CreateDataset/index.less new file mode 100644 index 000000000..2e05fee21 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateDataset/index.less @@ -0,0 +1,35 @@ +.dataset-type-indicator{ + width: 28px; + height: 28px; + border-radius: 50%; + justify-content: center; + align-items: center; + display: flex; + background-color: rgb(var(--gray-2)); +} + +.dataset-type-explain{ + font-size: 12px; + height: 16px; + color: var(--textColorSecondary); + transform: scale(0.9); + transform-origin: 0% 50%; +} + +.dataset-raw-create-form{ + width: 480px; + margin: 0 auto; + .form-section{ + overflow: hidden; // bfc + > h3 { + margin-bottom: 20px; + font-weight: 500; + font-size: 14px; + color: #1d252f; + } + } +} + +.dataset-raw-input-number{ + width: 112px; +} diff --git a/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/DatasetInfo/index.module.less b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/DatasetInfo/index.module.less new file mode 100644 index 000000000..6cee9875e --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/DatasetInfo/index.module.less @@ -0,0 +1,9 @@ +.dataset_processed_desc{ + font-weight: 500; + color: #1D2129; + font-size: 12px; + line-height: 20px; + .dataset_processed_name{ + margin-right: 8px; + } +} diff --git a/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/DatasetInfo/index.tsx b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/DatasetInfo/index.tsx new file mode 100644 index 000000000..3dbc55f1a --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/DatasetInfo/index.tsx @@ -0,0 +1,90 @@ +/* istanbul ignore file */ + +import React, { FC, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { Tag } from '@arco-design/web-react'; +import { DATASET_LIST_QUERY_KEY } from 'views/Datasets/DatasetList'; +import { fetchDatasetList, fetchParticipantDatasetList } from 'services/dataset'; +import { Dataset, ParticipantDataset, DatasetKindBackEndType } from 'typings/dataset'; +import { useRecoilValue } from 'recoil'; +import { projectState } from 'stores/project'; +import { PageMeta } from 'typings/app'; +import { FILTER_OPERATOR_MAPPER, filterExpressionGenerator } from 'views/Datasets/shared'; +import styled from './index.module.less'; + +interface Props { + datasetUuid: string; + participantId?: ID; + isParticipant?: boolean; +} + +const DatasetInfo: FC<Props> = ({ isParticipant = false, datasetUuid, participantId }) => { + const selectedProject = useRecoilValue(projectState); + const [currentDataset, setCurrentDataset] = useState<Dataset | ParticipantDataset>(); + const query = useQuery<{ + data: Array<Dataset | ParticipantDataset>; + page_meta?: PageMeta; + }>( + [ + DATASET_LIST_QUERY_KEY, + selectedProject.current?.id, + datasetUuid, + participantId, + isParticipant, + ], + () => { + const filter = filterExpressionGenerator( + { + project_id: selectedProject.current?.id, + is_published: isParticipant ? undefined : true, + uuid: datasetUuid, + }, + FILTER_OPERATOR_MAPPER, + ); + if (isParticipant) { + return fetchParticipantDatasetList(selectedProject.current?.id!, { + uuid: datasetUuid, + participant_id: participantId, + }); + } + return fetchDatasetList({ + filter, + }); + }, + { + enabled: Boolean(selectedProject.current), + retry: 2, + refetchOnWindowFocus: false, + onSuccess: (res) => { + setCurrentDataset(res.data[0]); + }, + }, + ); + // Empty only if there is no keyword, and the 1st page is requested, and there is no data + const isEmpty = !query.isFetching && !currentDataset; + const tagText = useMemo(() => { + let tagText = ''; + if (!currentDataset) return tagText; + if (currentDataset.dataset_kind === DatasetKindBackEndType.RAW) { + tagText = '原始'; + } + if (currentDataset.dataset_kind === DatasetKindBackEndType.PROCESSED) { + tagText = '结果'; + } + return tagText; + }, [currentDataset]); + return ( + <> + {isEmpty ? ( + <div>暂无数据集</div> + ) : ( + <div className={styled.dataset_processed_desc}> + <span className={styled.dataset_processed_name}>{currentDataset?.name}</span> + {tagText ? <Tag>{tagText}</Tag> : <></>} + </div> + )} + </> + ); +}; + +export default DatasetInfo; diff --git a/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/TitleWithRecommendedParam/index.less b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/TitleWithRecommendedParam/index.less new file mode 100644 index 000000000..e67d5584a --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/TitleWithRecommendedParam/index.less @@ -0,0 +1,17 @@ +.params-title-tag{ + margin-bottom: 4px; + width: 50px; +} +.recommend-param-drawer{ + width: 1000px; + .main-title{ + font-size: 14px; + margin-right: 8px; + } + .params-tips{ + margin-bottom: 12px; + .arco-alert-content{ + font-size: 12px; + } + } +} diff --git a/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/TitleWithRecommendedParam/index.tsx b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/TitleWithRecommendedParam/index.tsx new file mode 100644 index 000000000..4cc08d938 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/TitleWithRecommendedParam/index.tsx @@ -0,0 +1,476 @@ +import React, { useMemo } from 'react'; +import { Alert, Button, Drawer, Table, Tag } from '@arco-design/web-react'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { TABLE_COL_WIDTH } from 'shared/constants'; +import ClickToCopy from 'components/ClickToCopy'; +import { useToggle } from 'react-use'; +import { DataJoinType } from 'typings/dataset'; +import './index.less'; + +type Props = { + joinType: DataJoinType; +}; + +type LevelInfo = { + name: string; + desc: string; +}; +type ParamsInfo = { + sender: { + cpu: string; + mem: string; + }; + receiver: { + cpu: string; + mem: string; + }; +}; + +type ParamsTableData = { + id: number; + level: LevelInfo; + replicas?: string | number; + num_partition?: number | string; + part_num?: number | string; + master?: ParamsInfo | string | number; + raw_data_worker?: ParamsInfo; + psi_join_worker?: ParamsInfo; + totalCost: ParamsInfo | string[]; +}; + +/** + * TODO: Temporary table component, which will be removed later + */ +const recommendedParams: ParamsTableData[] = [ + { + id: 1, + level: { + name: '微型数据集', + desc: '(数据量 ≤ 1万且数据大小 ≤ 1g)', + }, + num_partition: 1, + master: { + sender: { cpu: '2000m', mem: '4Gi' }, + receiver: { cpu: '2000m', mem: '4Gi' }, + }, + raw_data_worker: { + sender: { cpu: '4000m', mem: '8Gi' }, + receiver: { cpu: '1000m', mem: '2Gi' }, + }, + psi_join_worker: { + sender: { cpu: '4000m', mem: '8Gi' }, + receiver: { cpu: '1000m', mem: '2Gi' }, + }, + totalCost: { + sender: { cpu: '6000m', mem: '12Gi' }, + receiver: { cpu: '3000m', mem: '6Gi' }, + }, + }, + { + id: 2, + level: { + name: '小型数据集', + desc: '(1万 < 数据集样本量 ≤ 100万或1g ≤ 数据大小 ≤ 10g)', + }, + num_partition: 4, + master: { + sender: { cpu: '2000m', mem: '4Gi' }, + receiver: { cpu: '2000m', mem: '4Gi' }, + }, + raw_data_worker: { + sender: { cpu: '4000m', mem: '8Gi' }, + receiver: { cpu: '1000m', mem: '2Gi' }, + }, + psi_join_worker: { + sender: { cpu: '4000m', mem: '8Gi' }, + receiver: { cpu: '1000m', mem: '2Gi' }, + }, + totalCost: { + sender: { cpu: '18000m', mem: '36Gi' }, + receiver: { cpu: '6000m', mem: '12Gi' }, + }, + }, + { + id: 3, + level: { + name: '中型数据集', + desc: '(100万 < 数据集样本量 ≤ 1亿或10g < 数据大小 ≤ 100g)', + }, + num_partition: 16, + master: { + sender: { cpu: '2000m', mem: '4Gi' }, + receiver: { cpu: '2000m', mem: '4Gi' }, + }, + raw_data_worker: { + sender: { cpu: '8000m', mem: '16Gi' }, + receiver: { cpu: '1000m', mem: '2Gi' }, + }, + psi_join_worker: { + sender: { cpu: '8000m', mem: '16Gi' }, + receiver: { cpu: '1000m', mem: '2Gi' }, + }, + totalCost: { + sender: { cpu: '130000m', mem: '260Gi' }, + receiver: { cpu: '18000m', mem: '36Gi' }, + }, + }, + { + id: 4, + level: { + name: '大型数据集', + desc: '(数据集样本量 > 1亿或数据大小 > 100g)', + }, + num_partition: 32, + master: { + sender: { cpu: '2000m', mem: '4Gi' }, + receiver: { cpu: '2000m', mem: '4Gi' }, + }, + raw_data_worker: { + sender: { cpu: '16000m', mem: '32Gi' }, + receiver: { cpu: '2000m', mem: '4Gi' }, + }, + psi_join_worker: { + sender: { cpu: '16000m', mem: '32Gi' }, + receiver: { cpu: '2000m', mem: '4Gi' }, + }, + totalCost: { + sender: { cpu: '514000m', mem: '1028Gi' }, + receiver: { cpu: '66000m', mem: '132Gi' }, + }, + }, +]; + +const recommendedOTPSIParams: ParamsTableData[] = [ + { + id: 1, + level: { + name: '小型数据集', + desc: '(0万 < 数据集样本量 ≤ 400万)', + }, + num_partition: 1, + replicas: 1, + totalCost: ['spark任务动态分配资源', 'OtPsi:2c4g'], + }, + { + id: 2, + level: { + name: '中型数据集', + desc: '(数据集样本量 > 400w)', + }, + num_partition: 'max(发起方样本量,合作方样本量)/400万', + replicas: '5或10', + totalCost: ['spark任务动态分配资源', 'OtPsi:10c20g或20c40g'], + }, +]; + +const recommendedHASHParams: ParamsTableData[] = [ + { + id: 1, + level: { + name: '小型数据集', + desc: '(0万 < 数据集样本量 ≤ 400万)', + }, + num_partition: 1, + replicas: 1, + totalCost: ['spark任务动态分配资源', 'OtPsi:2c4g'], + }, + { + id: 2, + level: { + name: '中型数据集', + desc: '(数据集样本量 > 400w)', + }, + num_partition: 'max(发起方样本量,合作方样本量)/400万', + replicas: '5或10', + totalCost: ['spark任务动态分配资源', 'OtPsi:10c20g或20c40g'], + }, +]; + +const recommendedLightRSAPSIParams: ParamsTableData[] = [ + { + id: 1, + level: { + name: '小型数据集', + desc: '(0万 < 数据集样本量 ≤ 200万)', + }, + part_num: 1, + totalCost: ['spark任务动态分配资源', 'rsa求交:16c20g'], + }, + { + id: 2, + level: { + name: '中型数据集', + desc: '(数据集样本量 > 200w)', + }, + part_num: 'max(发起方样本量,合作方样本量)/200万', + totalCost: ['spark任务动态分配资源', 'rsa求交:16c20g'], + }, +]; + +const RECOMMENDED_PARAMS = { + [DataJoinType.PSI]: recommendedParams, + [DataJoinType.OT_PSI_DATA_JOIN]: recommendedOTPSIParams, + [DataJoinType.HASH_DATA_JOIN]: recommendedHASHParams, + [DataJoinType.NORMAL]: [], + [DataJoinType.LIGHT_CLIENT]: recommendedLightRSAPSIParams, + [DataJoinType.LIGHT_CLIENT_OT_PSI_DATA_JOIN]: [], +}; + +const TIPS_MAPPER = { + [DataJoinType.PSI]: + '请根据各方可用资源情况,选择数据集配置。当样本量和数据大小命中不同的规则时,请选择更大的资源规格。', + [DataJoinType.OT_PSI_DATA_JOIN]: '请根据各方可用资源情况,选择数据集配置。', + [DataJoinType.HASH_DATA_JOIN]: '请根据各方可用资源情况,选择数据集配置。', + [DataJoinType.NORMAL]: '', + [DataJoinType.LIGHT_CLIENT]: '请根据各方可用资源情况,选择数据集配置。', + [DataJoinType.LIGHT_CLIENT_OT_PSI_DATA_JOIN]: '', +}; + +const Title: React.FC<Props> = ({ joinType }) => { + const [drawerVisible, setDrawerVisible] = useToggle(false); + const columns: ColumnProps<any>[] = useMemo(() => { + switch (joinType) { + case DataJoinType.PSI: + return [ + { + title: '所有参与方最大数据量级', + dataIndex: 'level', + fixed: 'left', + width: TABLE_COL_WIDTH.NORMAL, + render: (_) => { + return renderLevel(_); + }, + }, + { + title: 'num_partition', + dataIndex: 'num_partition', + width: TABLE_COL_WIDTH.THIN, + }, + { + title: 'master', + dataIndex: 'master', + width: TABLE_COL_WIDTH.THIN, + render: (_) => { + return renderParams(_); + }, + }, + { + title: 'raw_worker', + dataIndex: 'raw_data_worker', + width: TABLE_COL_WIDTH.THIN, + render: (_) => { + return renderParams(_); + }, + }, + { + title: 'psi_worker', + dataIndex: 'psi_join_worker', + width: TABLE_COL_WIDTH.THIN, + render: (_) => { + return renderParams(_); + }, + }, + { + title: '总资源消耗', + dataIndex: 'totalCost', + width: TABLE_COL_WIDTH.THIN, + render: (_) => { + return renderParams(_); + }, + }, + ]; + case DataJoinType.OT_PSI_DATA_JOIN: + return [ + { + title: '所有参与方最大数据量级', + dataIndex: 'level', + fixed: 'left', + width: TABLE_COL_WIDTH.THIN, + render: (_) => { + return renderLevel(_); + }, + }, + { + title: 'num_partition', + dataIndex: 'num_partition', + width: TABLE_COL_WIDTH.THIN, + }, + { + title: 'replicas', + dataIndex: 'replicas', + width: TABLE_COL_WIDTH.THIN / 2, + }, + { + title: '总资源消耗', + dataIndex: 'totalCost', + width: TABLE_COL_WIDTH.THIN, + render: (_: string[]) => { + return ( + <ul> + {_.map((item) => ( + <li key={item}>{item}</li> + ))} + </ul> + ); + }, + }, + ]; + case DataJoinType.LIGHT_CLIENT: + return [ + { + title: '所有参与方最大数据量级', + dataIndex: 'level', + fixed: 'left', + width: TABLE_COL_WIDTH.THIN, + render: (_) => { + return renderLevel(_); + }, + }, + { + title: 'part_num', + dataIndex: 'part_num', + width: TABLE_COL_WIDTH.THIN, + }, + { + title: '总资源消耗', + dataIndex: 'totalCost', + width: TABLE_COL_WIDTH.THIN, + render: (_: string[]) => { + return ( + <ul> + {_.map((item) => ( + <li key={item}>{item}</li> + ))} + </ul> + ); + }, + }, + ]; + case DataJoinType.HASH_DATA_JOIN: + return [ + { + title: '所有参与方最大数据量级', + dataIndex: 'level', + fixed: 'left', + width: TABLE_COL_WIDTH.THIN, + render: (_) => { + return renderLevel(_); + }, + }, + { + title: 'num_partition', + dataIndex: 'num_partition', + width: TABLE_COL_WIDTH.THIN, + }, + { + title: 'replicas', + dataIndex: 'replicas', + width: TABLE_COL_WIDTH.THIN / 2, + }, + { + title: '总资源消耗', + dataIndex: 'totalCost', + width: TABLE_COL_WIDTH.THIN, + render: (_: string[]) => { + return ( + <ul> + {_.map((item) => ( + <li key={item}>{item}</li> + ))} + </ul> + ); + }, + }, + ]; + default: + return []; + } + }, [joinType]); + const dataSource = useMemo(() => { + if (!joinType) { + return []; + } + return RECOMMENDED_PARAMS[joinType] || []; + }, [joinType]); + return ( + <div className="recommended-param-table"> + <Drawer + title={<span className="main-title">推荐配置</span>} + className="recommend-param-drawer" + style={{ + width: 800, + }} + visible={drawerVisible} + onCancel={() => setDrawerVisible(false)} + > + <Alert className={'params-tips'} content={TIPS_MAPPER[joinType]} /> + <Table + pagination={false} + scroll={{ x: 1000, y: 600 }} + border={{ wrapper: true, cell: true }} + className="custom-table custom-table-left-side-filter" + rowKey={'id'} + columns={columns} + data={dataSource} + /> + </Drawer> + <Button + onClick={() => { + setDrawerVisible(true); + }} + size={'mini'} + type="text" + > + 推荐配置参数 + </Button> + </div> + ); + + function renderLevel(levelInfo: LevelInfo) { + return ( + <div> + <h5>{levelInfo.name}</h5> + <span>{levelInfo.desc}</span> + </div> + ); + } + + function renderParams(paramInfo: ParamsInfo) { + return ( + <div> + <h5>发起方:</h5> + <span> + <ClickToCopy text={paramInfo?.sender?.cpu}> + <Tag className="params-title-tag" color={'arcoblue'}> + {'CPU:'} + </Tag> + {paramInfo?.sender?.cpu} + </ClickToCopy> + <ClickToCopy text={paramInfo?.sender?.mem}> + <Tag className="params-title-tag" color={'green'}> + {'MEM:'} + </Tag> + {paramInfo?.sender?.mem} + </ClickToCopy> + </span> + <h5>合作伙伴:</h5> + <span> + <ClickToCopy text={paramInfo?.receiver?.cpu}> + <Tag className="params-title-tag" color={'arcoblue'}> + {'CPU:'} + </Tag> + {paramInfo?.receiver?.cpu} + </ClickToCopy> + <ClickToCopy text={paramInfo?.receiver?.mem}> + <Tag className="params-title-tag" color={'green'}> + {'MEM:'} + </Tag> + {paramInfo?.receiver?.mem} + </ClickToCopy> + </span> + </div> + ); + } +}; + +export default Title; diff --git a/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/index.module.less b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/index.module.less new file mode 100644 index 000000000..57a16d197 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/index.module.less @@ -0,0 +1,61 @@ +@import '~styles/mixins.less'; + +.dataset_processed_create_form{ + max-width: 600px; + margin: 0 auto; +} +.dataset_processed_create_section{ + margin-bottom: 20px; + overflow: hidden; // bfc + > h3 { + margin-bottom: 20px; + font-weight: 500; + font-size: 14px; + color: #1d252f; + } + .title-tag{ + margin: 0 12px 0 12px; + } +} +.dataset_processed_avatar{ + .MixinSquare(44px); + background-color: var(--primary-1); + color: white; + border-radius: 4px; + font-size: 18px; + text-align: center; + + &::before { + display: inline-block; + width: 100%; + height: 100%; + content: ''; + background: url('~assets/icons/atom-icon-algorithm-management.svg') no-repeat; + background-size: contain; + } +} + +.dataset_processed_card{ + :global(.arco-card-body){ + padding: 32px 40px; + } +} + +.dataset_processed_desc{ + font-weight: 500; + color: #1D2129; + font-size: 12px; + line-height: 20px; +} + +.dataset_processed_form_label{ + height: 32px; + display: flex; + justify-content: flex-end !important; + align-items: center; +} + +.dataset_processed_footer_button{ + padding-left: 130px; +} + diff --git a/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/index.tsx b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/index.tsx new file mode 100644 index 000000000..1ea93650e --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/CreateProcessedDataset/index.tsx @@ -0,0 +1,1010 @@ +import { + Alert, + Button, + Form, + Input, + Message, + Space, + Spin, + Tag, + Switch, + Card, +} from '@arco-design/web-react'; +import BackButton from 'components/BackButton'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BlockRadio from 'components/_base/BlockRadio'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import DatasetSelect from 'components/DatasetSelect'; +import DatasetInfo from './DatasetInfo'; +import ConfigForm, { ExposedRef, ItemProps } from 'components/ConfigForm'; +import { Tag as TagEnum } from 'typings/workflow'; +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { useHistory, useParams } from 'react-router'; +import { + createDatasetJobs, + createDataset, + fetchDataJobVariableDetail, + fetchDatasetDetail, + fetchDatasetJobDetail, + authorizeDataset, +} from 'services/dataset'; +import { fetchSysInfo } from 'services/settings'; +import { isStringCanBeParsed, to } from 'shared/helpers'; +import { + useGetAppFlagValue, + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useIsFormValueChange, + useGetCurrentProjectAbilityConfig, +} from 'hooks'; +import { MAX_COMMENT_LENGTH, validNamePattern } from 'shared/validator'; +import TitleWithIcon from 'components/TitleWithIcon'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import { useQuery } from 'react-query'; +import FormLabel from 'components/FormLabel'; +import { LabelStrong } from 'styles/elements'; +import { + DataJobBackEndType, + DataJobType, + DataJobVariable, + DataJoinType, + Dataset, + DatasetJobCreatePayload, + DatasetCreatePayload, + DatasetKindV2, + DATASET_COPY_CHECKER, + DataSourceStructDataType, + DataJoinToolTipText, + DatasetType, + DatasetDataType, + DatasetType__archived, + DatasetKindBackEndType, +} from 'typings/dataset'; +import { + Variable, + VariableComponent, + VariableValueType, + VariableWidgetSchema, +} from 'typings/variable'; +import { Participant, ParticipantType } from 'typings/participant'; +import { hydrate } from 'views/Workflows/shared'; +import { + NO_CATEGORY, + SYNCHRONIZATION_VARIABLE, + TAG_MAPPER, + VARIABLE_TIPS_MAPPER, + isDataAlignment, + isDataLightClient, + isDataOtPsiJoin, + isDataHashJoin, + isHoursCronJoin, + CronType, + cronTypeOptions, +} from '../shared'; +import Title from './TitleWithRecommendedParam'; +import { FlagKey } from 'typings/flag'; +import styled from './index.module.less'; + +type Props = Record<string, unknown>; +type Params = { + [key: string]: any; +}; + +type FormData = { + name: string; + comment: string; + data_job_type: DataJobType; + data_join_type: DataJoinType; + dataset_info: Dataset; + params: Params; + cron_type: CronType; + participant: { + [participantName: string]: { + dataset_info: Dataset; + params: Params; + }; + }; +}; + +type DataJoinTypeOption = { + value: `${DataJoinType}`; + label: string; + tooltip?: string; +}; + +const dataJoinTypeOptionLightClient = [ + { + value: DataJoinType.LIGHT_CLIENT, + label: 'RSA-PSI 求交', + }, + { + value: DataJoinType.LIGHT_CLIENT_OT_PSI_DATA_JOIN, + label: 'OT-PSI 求交', + }, +]; + +const initialFormValues: Partial<FormData> = { + name: '', + data_join_type: DataJoinType.PSI, + cron_type: CronType.DAY, +}; + +const CreateDataset: FC<Props> = () => { + const { id, action } = useParams<{ + action: 'create' | 'edit' | 'authorize'; + id: string; + }>(); + const isAuthorize = action === 'authorize'; + const [formInstance] = Form.useForm<FormData>(); + const history = useHistory(); + const hash_data_join_enabled = useGetAppFlagValue(FlagKey.HASH_DATA_JOIN_ENABLED); + const participantList = useGetCurrentProjectParticipantList(); + const { hasIdAlign, hasVertical, hasHorizontal } = useGetCurrentProjectAbilityConfig(); + + const [dataJobType, setDataJobType] = useState<DataJobType>( + initialFormValues?.data_job_type ?? DataJobType.JOIN, + ); + const [dataJoinType, setDataJoinType] = useState<DataJoinType>( + initialFormValues?.data_join_type ?? DataJoinType.PSI, + ); + const [isDoing, setIsDoing] = useState(false); + const [isCron, setIsCron] = useState(false); + const [cronType, setCornType] = useState<CronType>(initialFormValues?.cron_type ?? CronType.DAY); + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(); + const currentProjectId = useGetCurrentProjectId(); + const configFormRefList = useRef<Array<ExposedRef | null>>([]); + const [globalConfigMap, setGlobalConfigMap] = useState<any>(); + + const finalDataJobType = useMemo<DataJobBackEndType>(() => { + if (dataJobType === DataJobType.ALIGNMENT) { + return DataJobBackEndType.DATA_ALIGNMENT; + } + if (dataJoinType === DataJoinType.LIGHT_CLIENT) { + return DataJobBackEndType.LIGHT_CLIENT_RSA_PSI_DATA_JOIN; + } + if (dataJoinType === DataJoinType.LIGHT_CLIENT_OT_PSI_DATA_JOIN) { + return DataJobBackEndType.LIGHT_CLIENT_OT_PSI_DATA_JOIN; + } + if (dataJoinType === DataJoinType.OT_PSI_DATA_JOIN) { + return DataJobBackEndType.OT_PSI_DATA_JOIN; + } + if (dataJoinType === DataJoinType.HASH_DATA_JOIN) { + return DataJobBackEndType.HASH_DATA_JOIN; + } + return dataJoinType === DataJoinType.PSI + ? DataJobBackEndType.RSA_PSI_DATA_JOIN + : DataJobBackEndType.DATA_JOIN; + }, [dataJobType, dataJoinType]); + + // ======= Dataset query ============ + const datasetQuery = useQuery(['fetchDatasetDetail', id], () => fetchDatasetDetail(id), { + refetchOnWindowFocus: false, + retry: 2, + enabled: isAuthorize && Boolean(id), + }); + + // 获取当前数据任务信息, 包括本方和合作伙伴方 + const datasetJobDetailQuery = useQuery( + ['fetchDatasetJobDetail', currentProjectId, datasetQuery.data?.data.parent_dataset_job_id], + () => fetchDatasetJobDetail(currentProjectId!, datasetQuery.data?.data.parent_dataset_job_id!), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: + isAuthorize && Boolean(currentProjectId && datasetQuery.data?.data.parent_dataset_job_id), + onSuccess(res) { + const { + name, + kind, + global_configs: { global_configs }, + } = res.data; + const { comment, dataset_type } = datasetQuery.data?.data!; + if (!isAuthorize) return; + // 设置参数信息, 支持多方 + Object.keys(global_configs).forEach((key) => { + const globalConfig = global_configs[key]; + (globalConfig as any).variables = handleParseToConfigFrom( + handleParseDefinition(globalConfig.variables), + isAuthorize, + ); + }); + setGlobalConfigMap(global_configs); + + setDataJobType(isDataAlignment(kind) ? DataJobType.ALIGNMENT : DataJobType.JOIN); + setDataJoinType( + isDataLightClient(kind) + ? DataJoinType.LIGHT_CLIENT + : isDataOtPsiJoin(kind) + ? DataJoinType.OT_PSI_DATA_JOIN + : isDataHashJoin(kind) + ? DataJoinType.HASH_DATA_JOIN + : DataJoinType.PSI, + ); + setIsCron(dataset_type === DatasetType__archived.STREAMING); + isHoursCronJoin(res.data) ? setCornType(CronType.HOUR) : setCornType(CronType.DAY); + formInstance.setFieldsValue({ + name, + comment, + data_job_type: isDataAlignment(kind) ? DataJobType.ALIGNMENT : DataJobType.JOIN, + data_join_type: isDataLightClient(kind) + ? DataJoinType.LIGHT_CLIENT + : isDataOtPsiJoin(kind) + ? DataJoinType.OT_PSI_DATA_JOIN + : isDataHashJoin(kind) + ? DataJoinType.HASH_DATA_JOIN + : DataJoinType.PSI, + }); + }, + }, + ); + + const dataJobVariableDetailQuery = useQuery( + ['fetchDataJobVariableDetail', finalDataJobType], + () => fetchDataJobVariableDetail(finalDataJobType), + { + enabled: !isAuthorize && Boolean(finalDataJobType), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + const sysInfoQuery = useQuery(['fetchSysInfo'], () => fetchSysInfo(), { + retry: 2, + refetchOnWindowFocus: false, + }); + + const myDomainName = useMemo<string>(() => { + return sysInfoQuery.data?.data?.domain_name ?? ''; + }, [sysInfoQuery.data]); + + const myPureDomainName = useMemo<string>(() => { + return sysInfoQuery.data?.data?.pure_domain_name ?? ''; + }, [sysInfoQuery.data]); + + const participantName = useMemo<string>(() => { + if (!datasetJobDetailQuery.data?.data.coordinator_id) return ''; + const participant = participantList.find( + (item) => item.id === datasetJobDetailQuery.data?.data.coordinator_id, + ); + return participant?.pure_domain_name ?? ''; + }, [datasetJobDetailQuery.data, participantList]); + + const dataJobVariableList = useMemo<Variable[]>(() => { + if (!dataJobVariableDetailQuery.data?.data?.variables) { + return []; + } + return handleParseDefinition(dataJobVariableDetailQuery.data.data.variables); + }, [dataJobVariableDetailQuery.data]); + + const datasetSelectFilterOptions = useMemo(() => { + // todo: 目前对齐要求只展示非增量数据集, 后续这块逻辑需要优化 + const options: any = { + dataset_type: DatasetType.PSI, + dataset_format: [DatasetDataType.STRUCT, DatasetDataType.PICTURE], + dataset_kind: [DatasetKindBackEndType.RAW, DatasetKindBackEndType.PROCESSED], + }; + if (isCron) { + options.dataset_type = DatasetType.STREAMING; + options.cron_interval = [cronType === CronType.DAY ? 'DAYS' : 'HOURS']; + } + return options; + }, [isCron, cronType]); + + const [paramsList, collapseParamsList] = useMemo(() => { + return handleParseToConfigFrom(dataJobVariableList, isAuthorize); + }, [dataJobVariableList, isAuthorize]); + + const dataJobTypeOptionsGenerator = useMemo(() => { + const dataJobTypeOptions = []; + if (hasIdAlign || hasVertical) { + dataJobTypeOptions.push({ + value: DataJobType.JOIN, + label: hasIdAlign ? '轻客户端求交' : '求交', + }); + } + if (hasHorizontal) { + dataJobTypeOptions.push({ + value: DataJobType.ALIGNMENT, + label: '对齐', + }); + } + return dataJobTypeOptions; + }, [hasIdAlign, hasVertical, hasHorizontal]); + + const dataJoinTypeOptionsGenerator = useMemo(() => { + const dataJoinTypeOptions: DataJoinTypeOption[] = [ + { + value: DataJoinType.OT_PSI_DATA_JOIN, + label: 'OT-PSI 求交', + tooltip: DataJoinToolTipText.OT_PSI_DATA_JOIN, + }, + { + value: DataJoinType.PSI, + label: 'RSA-PSI 求交', + tooltip: DataJoinToolTipText.PSI, + }, + ]; + if (hash_data_join_enabled) { + dataJoinTypeOptions.push({ + value: DataJoinType.HASH_DATA_JOIN, + label: '哈希求交', + tooltip: DataJoinToolTipText.HASH_DATA_JOIN, + }); + } + if (hasIdAlign) { + return dataJoinTypeOptionLightClient; + } + if (hasVertical) { + return dataJoinTypeOptions; + } + return dataJoinTypeOptions; + }, [hash_data_join_enabled, hasIdAlign, hasVertical]); + + useEffect(() => { + const defaultJoinType = dataJoinTypeOptionsGenerator[0]?.value as DataJoinType; + const defaultJobType = dataJobTypeOptionsGenerator[0]?.value; + formInstance.setFieldsValue({ + data_join_type: defaultJoinType, + data_job_type: defaultJobType, + }); + setDataJoinType(defaultJoinType); + setDataJobType(defaultJobType); + }, [dataJobTypeOptionsGenerator, dataJoinTypeOptionsGenerator, formInstance]); + + const authorizeDataJobType = useMemo(() => { + return dataJobTypeOptionsGenerator.find((item) => item.value === dataJobType)?.label; + }, [dataJobType, dataJobTypeOptionsGenerator]); + + const authorizeDataJoinType = useMemo(() => { + return (dataJoinTypeOptionsGenerator as DataJoinTypeOption[]).find( + (item) => item.value === dataJoinType, + )?.label; + }, [dataJoinType, dataJoinTypeOptionsGenerator]); + + return ( + <SharedPageLayout + title={ + <BackButton onClick={backToList} isShowConfirmModal={isFormValueChanged}> + 结果数据集 + </BackButton> + } + contentWrapByCard={false} + centerTitle={isAuthorize ? '授权结果数据集' : '创建数据集'} + > + <Spin loading={dataJobVariableDetailQuery.isFetching || datasetJobDetailQuery.isFetching}> + {isAuthorize && renderBannerCard()} + {renderCardFrom()} + </Spin> + </SharedPageLayout> + ); + + function renderBannerCard() { + const title = `${participantName}向您发起${datasetJobDetailQuery.data?.data.name}的数据集授权申请`; + return ( + <Card className="card" bordered={false} style={{ marginBottom: 20 }}> + <Space size="medium"> + <div className={styled.dataset_processed_avatar} /> + <> + <LabelStrong fontSize={16}>{title}</LabelStrong> + <TitleWithIcon + title="确认授权后,数据任务将自动运行,可在结果数据集页查看详细信息。" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </> + </Space> + </Card> + ); + } + + function renderCardFrom() { + return ( + <Card className={styled.dataset_processed_card} bordered={false}> + <Form + className={styled.dataset_processed_create_form} + disabled={isAuthorize} + initialValues={initialFormValues} + form={formInstance} + onSubmit={onSubmit} + onValuesChange={onFormValueChange} + scrollToFirstError={true} + > + {renderBaseConfigLayout()} + {renderParticipantConfigLayout()} + {renderFooterButton()} + </Form> + </Card> + ); + } + + function renderBaseConfigLayout() { + return ( + <section className={styled.dataset_processed_create_section}> + <h3>基本配置</h3> + <Form.Item + field="name" + label="数据集名称" + hasFeedback + rules={[ + { required: true, message: '请输入' }, + { + match: validNamePattern, + message: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ]} + > + <Input placeholder="请输入" maxLength={60} /> + </Form.Item> + <Form.Item + field="comment" + label="数据集描述" + rules={[{ maxLength: MAX_COMMENT_LENGTH, message: '最多为 200 个字符' }]} + > + <Input.TextArea rows={3} placeholder="最多为 200 个字符" showWordLimit /> + </Form.Item> + <Form.Item field="data_job_type" label="数据任务" rules={[{ required: true }]}> + {isAuthorize ? ( + <div className={styled.dataset_processed_desc}>{authorizeDataJobType}</div> + ) : ( + <BlockRadio + flexGrow={0} + isCenter={true} + isOneHalfMode={false} + options={dataJobTypeOptionsGenerator} + onChange={(val: DataJobType) => { + setDataJobType(val); + setIsCron(false); + }} + /> + )} + </Form.Item> + {dataJobType === DataJobType.JOIN && ( + <Form.Item field="data_join_type" label="求交方式" rules={[{ required: true }]}> + {isAuthorize ? ( + <div className={styled.dataset_processed_desc}>{authorizeDataJoinType}</div> + ) : ( + <BlockRadio.WithToolTip + flexGrow={0} + isCenter={true} + isOneHalfMode={false} + options={dataJoinTypeOptionsGenerator} + onChange={(val: DataJoinType) => { + setDataJoinType(val); + }} + /> + )} + </Form.Item> + )} + {!hasIdAlign && dataJobType === DataJobType.JOIN && ( + <Form.Item field="is_cron" label="定时求交"> + {isAuthorize ? ( + <div className={styled.dataset_processed_desc}>{isCron ? '已开启' : '未开启'}</div> + ) : ( + <Space> + <Switch onChange={handleCronChange} /> + <TitleWithIcon + title="开启后仅支持选择增量数据集" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </Space> + )} + </Form.Item> + )} + {dataJobType === DataJobType.JOIN && isCron && ( + <Form.Item + field="cron_type" + label={ + <FormLabel + className={styled.dataset_processed_form_label} + label="求交周期" + tooltip="会在提交后立即求交,后续任务将按照设定的时间定期进行" + /> + } + > + {isAuthorize ? ( + <div className={styled.dataset_processed_desc}> + {cronType === CronType.DAY ? '每天' : '每小时'} + </div> + ) : ( + <BlockRadio + flexGrow={0} + isCenter={false} + isOneHalfMode={false} + options={cronTypeOptions} + onChange={(val: CronType) => { + handleCronTypeChange(val); + }} + /> + )} + </Form.Item> + )} + {isAuthorize ? ( + <Form.Item + field="dataset_info" + label="我方数据集" + rules={[{ required: true, message: '请选择' }]} + > + <DatasetInfo + isParticipant={false} + datasetUuid={ + isAuthorize && globalConfigMap?.[myPureDomainName]?.dataset_uuid + ? globalConfigMap[myPureDomainName].dataset_uuid + : '' + } + /> + </Form.Item> + ) : ( + <Form.Item + field="dataset_info" + label="我方数据集" + rules={[{ required: true, message: '请选择' }]} + > + <DatasetSelect + lazyLoad={{ + enable: true, + page_size: 10, + }} + filterOptions={datasetSelectFilterOptions} + isParticipant={false} + /> + </Form.Item> + )} + <Form.Item field="params" label="我方参数"> + {dataJobVariableDetailQuery.isError ? ( + <Alert type="info" content="暂不支持该类型的数据任务" /> + ) : ( + <ConfigForm + filter={variableTagFilter} + groupBy={'tag'} + hiddenGroupTag={false} + cols={2} + configFormExtra={ + dataJobType === DataJobType.JOIN ? <Title joinType={dataJoinType} /> : undefined + } + formProps={{ + style: { + marginTop: 7, + }, + }} + formItemList={ + isAuthorize + ? globalConfigMap && globalConfigMap[myPureDomainName] + ? globalConfigMap[myPureDomainName].variables[0] + : [] + : paramsList + } + collapseFormItemList={ + isAuthorize + ? globalConfigMap && globalConfigMap[myPureDomainName] + ? globalConfigMap[myPureDomainName].variables[1] + : [] + : collapseParamsList + } + ref={(ref) => { + configFormRefList.current[0] = ref; + }} + isResetOnFormItemListChange={true} + onChange={(val) => { + syncConfigFormValue( + val, + [ + SYNCHRONIZATION_VARIABLE.NUM_PARTITIONS, + SYNCHRONIZATION_VARIABLE.PART_NUM, + SYNCHRONIZATION_VARIABLE.REPLICAS, + ], + false, + ); + }} + /> + )} + </Form.Item> + </section> + ); + } + + function variableTagFilter(item: ItemProps) { + return ( + !!item.tag && + [TAG_MAPPER[TagEnum.INPUT_PARAM], TAG_MAPPER[TagEnum.RESOURCE_ALLOCATION]].includes(item.tag) + ); + } + + function renderParticipantConfigLayout() { + return participantList?.map((item, index) => { + const { type, pure_domain_name, id } = item; + const isLightClient = type === ParticipantType.LIGHT_CLIENT; + return isLightClient ? ( + renderLightClientInfo(item) + ) : ( + <section key={item.domain_name}> + <h3>{item.name}</h3> + {isAuthorize ? ( + <Form.Item + field={`participant.${item.name}.dataset_info`} + label={`合作伙伴数据集`} + rules={[{ required: true, message: '请选择' }]} + > + <DatasetInfo + isParticipant={true} + participantId={id} + datasetUuid={ + isAuthorize && globalConfigMap?.[pure_domain_name!]?.dataset_uuid + ? globalConfigMap[pure_domain_name!].dataset_uuid + : '' + } + /> + </Form.Item> + ) : ( + <Form.Item + field={`participant.${item.name}.dataset_info`} + label={`合作伙伴数据集`} + rules={[{ required: true, message: '请选择' }]} + > + <DatasetSelect + queryParams={{ + //TODO Temporarily obtain full data and will be removed soon + participant_id: id, + page_size: 0, + }} + filterOptions={datasetSelectFilterOptions} + isParticipant={true} + /> + </Form.Item> + )} + <Form.Item field={`participant.${item.name}.params`} label={`合作伙伴参数`}> + {dataJobVariableDetailQuery.isError ? ( + <Alert type="info" content="暂不支持该类型的数据任务" /> + ) : ( + <ConfigForm + filter={variableTagFilter} + groupBy={'tag'} + hiddenGroupTag={false} + cols={2} + configFormExtra={ + dataJobType === DataJobType.JOIN ? <Title joinType={dataJoinType} /> : undefined + } + formProps={{ + style: { + marginTop: 7, + }, + }} + formItemList={ + isAuthorize + ? globalConfigMap && globalConfigMap[myPureDomainName] + ? globalConfigMap[myPureDomainName].variables[0] + : [] + : paramsList + } + collapseFormItemList={ + isAuthorize + ? globalConfigMap && globalConfigMap[myPureDomainName] + ? globalConfigMap[myPureDomainName].variables[1] + : [] + : collapseParamsList + } + ref={(ref) => { + configFormRefList.current[index + 1] = ref; + }} + isResetOnFormItemListChange={true} + onChange={(val) => { + syncConfigFormValue( + val, + [ + SYNCHRONIZATION_VARIABLE.NUM_PARTITIONS, + SYNCHRONIZATION_VARIABLE.PART_NUM, + SYNCHRONIZATION_VARIABLE.REPLICAS, + ], + true, + item.name, + ); + }} + /> + )} + </Form.Item> + </section> + ); + }); + } + + function renderFooterButton() { + return ( + <Space className={styled.dataset_processed_footer_button}> + {isAuthorize ? ( + <Button type="primary" loading={isDoing} onClick={onAuthorize}> + 确认授权 + </Button> + ) : ( + <Button + type="primary" + htmlType="submit" + loading={isDoing} + disabled={dataJobVariableDetailQuery.isError} + > + 确认创建 + </Button> + )} + + <ButtonWithModalConfirm onClick={backToList} isShowConfirmModal={isFormValueChanged}> + 取消 + </ButtonWithModalConfirm> + </Space> + ); + } + + function renderLightClientInfo(lightClientInfo: Participant) { + return ( + <section key={lightClientInfo.domain_name}> + <h3> + {' '} + {lightClientInfo.domain_name} + <Tag className={styled.title_tag} color="arcoblue"> + 轻量 + </Tag> + </h3> + <Form.Item + field={`participant.${lightClientInfo.name}.dataset_info`} + label="合作伙伴数据集" + rules={[{ message: '请选择' }]} + > + <div>由客户侧本地上传</div> + </Form.Item> + </section> + ); + } + + function backToList() { + history.goBack(); + } + + function handleParseDefinition(definitions: DataJobVariable[]) { + return definitions.map((item) => { + let widget_schema: VariableWidgetSchema = {}; + + try { + widget_schema = JSON.parse(item.widget_schema); + } catch (error) {} + return { + ...item, + widget_schema, + }; + }); + } + + function handleParseToConfigFrom(variableList: Variable[], disabled: boolean) { + const formItemList: ItemProps[] = []; + const collapseFormItemList: ItemProps[] = []; + variableList + .filter((item) => !item.widget_schema.hidden) + .forEach((item) => { + const baseRuleList = item.widget_schema.required + ? [ + { + required: true, + message: '必填项', + }, + ] + : []; + const formItemConfig = { + disabled, // 在授权时将参数配置禁用修改 + tip: VARIABLE_TIPS_MAPPER[item.name], + label: item.name, + tag: TAG_MAPPER[item.tag as TagEnum] || NO_CATEGORY, + field: item.name, + initialValue: + item.widget_schema.component === VariableComponent.Input + ? item.value + : item.typed_value, + componentType: item.widget_schema.component, + rules: + item.widget_schema.component === VariableComponent.Input && + [VariableValueType.LIST, VariableValueType.OBJECT].includes(item.value_type!) + ? [ + ...baseRuleList, + { + validator: (value: any, callback: (error?: string | undefined) => void) => { + if ((value && typeof value === 'object') || isStringCanBeParsed(value)) { + callback(); + return; + } + callback(`JSON ${item.value_type!} 格式错误`); + }, + }, + ] + : baseRuleList, + }; + + if (formItemConfig.tag === TAG_MAPPER[TagEnum.INPUT_PARAM]) { + formItemList.push(formItemConfig); + } + if (formItemConfig.tag === TAG_MAPPER[TagEnum.RESOURCE_ALLOCATION]) { + collapseFormItemList.push(formItemConfig); + } + }); + + return [formItemList, collapseFormItemList]; + } + + /** + * This function is used to synchronize advanced parameters of the sender and participants(at least one participant) + * @param value config values; + * @param keyList the variable name needs to be kept same; + * @param isParticipant is called by participant ro not; + * @param currentParticipant current participant name; + */ + function syncConfigFormValue( + value: { [prop: string]: any }, + keyList: string[], + isParticipant: boolean, + currentParticipant?: string, + ) { + if (!keyList || !keyList.length || !value) { + return; + } + const senderParams: any = formInstance.getFieldValue('params') || {}; + const participantParams: any = formInstance.getFieldValue('participant'); + keyList.forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + return; + } + if (isParticipant) { + senderParams[key] = value[key]; + } + participantList.forEach((item) => { + if (isParticipant && item.name === currentParticipant) { + return; + } + const params = participantParams?.[item.name]?.params || {}; + params[key] = value[key]; + }); + }); + formInstance.setFieldsValue({ + params: { + ...senderParams, + }, + participant: { + ...participantParams, + }, + }); + } + + function handleCronChange(value: boolean) { + // 切换定时求交之后, 需要将之前选中的数据集内容清空掉 + formInstance.setFieldValue('dataset_info', {}); + participantList.forEach((item) => { + formInstance.setFieldValue(`participant.${item.name}.dataset_info` as keyof FormData, {}); + }); + setIsCron(value); + setCornType(CronType.DAY); + } + + function handleCronTypeChange(value: CronType) { + // 切换定时求交之后, 需要将之前选中的数据集内容清空掉 + formInstance.setFieldValue('dataset_info', {}); + participantList.forEach((item) => { + formInstance.setFieldValue(`participant.${item.name}.dataset_info` as keyof FormData, {}); + }); + setCornType(value); + } + + async function onSubmit(values: FormData) { + if (!currentProjectId) { + return Message.error('请选择工作区'); + } + if (!myDomainName) { + return Message.error('获取本系统 domain_name 失败'); + } + + // Validate <ConfigForm/> form + try { + await Promise.all( + configFormRefList.current.filter((i) => i).map((i) => i?.formInstance.validate()), + ); + } catch (error) { + return Message.info('必填项'); + } + + setIsDoing(true); + + const { dataset_type, dataset_format, store_format, import_type } = formInstance.getFieldValue( + 'dataset_info', + ) as Dataset; + + // create dataset + const [res, addDataSetError] = await to( + createDataset({ + kind: DatasetKindV2.PROCESSED, + project_id: currentProjectId, + name: values.name, + comment: values.comment || '', + dataset_type, + dataset_format, + store_format: + import_type === DATASET_COPY_CHECKER.COPY + ? DataSourceStructDataType.TFRECORDS + : store_format, + import_type: DATASET_COPY_CHECKER.COPY, + is_published: true, + } as DatasetCreatePayload), + ); + + if (addDataSetError) { + setIsDoing(false); + Message.error(addDataSetError.message); + return; + } + const datasetId = res.data.id; + const payload: DatasetJobCreatePayload = { + dataset_job_parameter: { + dataset_job_kind: finalDataJobType, + global_configs: { + [myDomainName]: { + dataset_uuid: values?.dataset_info?.uuid, + variables: hydrate(dataJobVariableList, values.params, { + isStringifyVariableValue: true, + isStringifyVariableWidgetSchema: true, + isProcessVariableTypedValue: true, + }) as DataJobVariable[], + }, + ...participantList?.reduce( + (acc, item) => { + const participantValues = values.participant[item.name]; + acc[item.domain_name] = { + dataset_uuid: participantValues?.dataset_info?.uuid, + variables: hydrate(dataJobVariableList, participantValues.params, { + isStringifyVariableValue: true, + isStringifyVariableWidgetSchema: true, + isProcessVariableTypedValue: true, + }) as DataJobVariable[], + }; + + return acc; + }, + {} as { + [domainName: string]: { + dataset_uuid: ID; + variables: DataJobVariable[]; + }; + }, + ), + }, + }, + output_dataset_id: datasetId, + }; + + if (isCron) { + payload.time_range = {}; + if (cronType === CronType.DAY) { + payload.time_range.days = 1; + } + if (cronType === CronType.HOUR) { + payload.time_range.hours = 1; + } + } + + const [, error] = await to(createDatasetJobs(currentProjectId, payload)); + if (error) { + Message.error(error.message); + setIsDoing(false); + return; + } + + setIsDoing(false); + backToList(); + } + + async function onAuthorize() { + setIsDoing(true); + const [, error] = await to(authorizeDataset(id)); + if (error) { + Message.error(error.message); + setIsDoing(false); + return; + } + setIsDoing(false); + backToList(); + } +}; + +export default CreateDataset; diff --git a/web_console_v2/client/src/views/Datasets/DataSourceDetail/PreviewFile/index.module.less b/web_console_v2/client/src/views/Datasets/DataSourceDetail/PreviewFile/index.module.less new file mode 100644 index 000000000..9e4d740c4 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DataSourceDetail/PreviewFile/index.module.less @@ -0,0 +1,7 @@ +.preview_update{ + margin-top: 5px; + margin-bottom: 20px; + .preview_update_text{ + margin-left: 7px; + } +} diff --git a/web_console_v2/client/src/views/Datasets/DataSourceDetail/PreviewFile/index.tsx b/web_console_v2/client/src/views/Datasets/DataSourceDetail/PreviewFile/index.tsx new file mode 100644 index 000000000..510027b68 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DataSourceDetail/PreviewFile/index.tsx @@ -0,0 +1,44 @@ +import React, { FC, useState } from 'react'; +import FileExplorer from 'components/FileExplorer'; +import { fetchDataSourceFileTreeList } from 'services/dataset'; +import { useParams } from 'react-router'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import { formatTimestamp } from 'shared/date'; +import { CONSTANTS } from 'shared/constants'; +import styled from './index.module.less'; + +type Props = {}; + +const PreviewFile: FC<Props> = () => { + const { id } = useParams<{ + id: string; + subtab: string; + }>(); + const [updateAt, setUpdateAt] = useState(0); + return ( + <div> + <div className={styled.preview_update}> + <IconInfoCircle /> + <span className={styled.preview_update_text}> + 最新更新时间 : {updateAt ? formatTimestamp(updateAt) : CONSTANTS.EMPTY_PLACEHOLDER} + </span> + </div> + <FileExplorer + isAsyncMode={true} + isReadOnly={true} + isShowNodeTooltip={false} + isAutoSelectFirstFile={false} + isExpandAll={false} + getFileTreeList={getFileTreeList} + /> + </div> + ); + function getFileTreeList() { + return fetchDataSourceFileTreeList(id).then((res) => { + setUpdateAt(res.data.mtime); + return res.data.files; + }); + } +}; + +export default PreviewFile; diff --git a/web_console_v2/client/src/views/Datasets/DataSourceDetail/index.module.less b/web_console_v2/client/src/views/Datasets/DataSourceDetail/index.module.less new file mode 100644 index 000000000..15ddfabd4 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DataSourceDetail/index.module.less @@ -0,0 +1,57 @@ +@import '~styles/mixins.less'; + +.data_source_detail_padding_box{ + padding: 20px; + padding-bottom: 0; + :global(.arco-spin) { + width: 100%; + } +} + +.data_source_detail_avatar{ + .MixinSquare(44px); + background-color: var(--primaryColor); + color: white; + border-radius: 2px; + font-size: 18px; + text-align: center; + + &::before { + content: attr(data-name); + line-height: 44px; + font-weight: bold; + } +} + +.data_source_name_container{ + display: flex; + align-items: center; +} + +.data_source_name{ + .MixinEllipsis('40vw'); + display: inline-block; + margin-bottom: 0; + margin-right: 7px; + font-size: 16px; + height: 24px; + font-weight: 600; +} + +.comment{ + font-size: 12px; + line-height: 18px; + color: var(--textColorSecondary); +} + +.data_source_text{ + .MixinEllipsis(); +} + +.data_source_detail_tab_pane{ + display: grid; +} + +.data_detail_tab{ + margin-bottom: 0 !important; +} diff --git a/web_console_v2/client/src/views/Datasets/DataSourceDetail/index.tsx b/web_console_v2/client/src/views/Datasets/DataSourceDetail/index.tsx new file mode 100644 index 000000000..2bc088fe7 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DataSourceDetail/index.tsx @@ -0,0 +1,217 @@ +import { Grid, Space, Spin, Tabs, Tooltip, Tag, Message } from '@arco-design/web-react'; +import BackButton from 'components/BackButton'; +import PropertyList from 'components/PropertyList'; +import SharedPageLayout from 'components/SharedPageLayout'; +import GridRow from 'components/_base/GridRow'; +import React, { FC, useEffect, useState } from 'react'; +import { useQuery, useMutation } from 'react-query'; +import { Redirect, Route, useHistory, useParams } from 'react-router'; +import { fetchDataSourceDetail, deleteDataSource } from 'services/dataset'; +import { formatTimestamp } from 'shared/date'; +import { DatasetType, DataSource } from 'typings/dataset'; +import { CONSTANTS } from 'shared/constants'; +import ClickToCopy from 'components/ClickToCopy'; +import MoreActions from 'components/MoreActions'; +import Modal from 'components/Modal'; +import PreviewFile from './PreviewFile'; +import styled from './index.module.less'; + +const { Row } = Grid; + +const { TabPane } = Tabs; + +export enum DataSourceDetailSubTabs { + PreviewFile = 'preview', + RawDataset = 'raw_dataset', +} + +const DataSourceDetail: FC<any> = () => { + const history = useHistory(); + + const { id, subtab } = useParams<{ + id: string; + subtab: string; + }>(); + const [activeTab, setActiveTab] = useState(subtab || DataSourceDetailSubTabs.PreviewFile); + + // ======= Data Source query ============ + const query = useQuery(['fetchDataSourceDetail', id], () => fetchDataSourceDetail({ id }), { + refetchOnWindowFocus: false, + }); + + const dataSource = query.data?.data; + + const { type, url, created_at, dataset_type } = dataSource ?? {}; + + const isStreaming = dataset_type === DatasetType.STREAMING; + + useEffect(() => { + setActiveTab(subtab || DataSourceDetailSubTabs.PreviewFile); + }, [subtab]); + + const deleteMutation = useMutation( + (dataSourceId: ID) => { + return deleteDataSource(dataSourceId); + }, + { + onSuccess() { + history.push('/datasets/data_source'); + Message.success('删除成功'); + }, + onError(e: any) { + Message.error(e.message); + }, + }, + ); + + /** IF no subtab be set, defaults to preview */ + if (!subtab) { + return <Redirect to={`/datasets/data_source/${id}/${DataSourceDetailSubTabs.PreviewFile}`} />; + } + + const displayedProps = [ + { + value: String(type).toLocaleUpperCase(), + label: '文件系统', + proport: 0.5, + }, + { + value: ( + <ClickToCopy text={url || ''}> + <Tooltip content={url}> + <div className={styled.data_source_text}>{url || CONSTANTS.EMPTY_PLACEHOLDER}</div> + </Tooltip> + </ClickToCopy> + ), + label: '数据来源', + proport: 1.5, + }, + { + value: getDataFormat(dataSource! ?? {}), + label: '数据格式', + proport: 1, + }, + { + value: created_at ? formatTimestamp(created_at) : CONSTANTS.EMPTY_PLACEHOLDER, + label: '创建时间', + proport: 1, + }, + ].filter(Boolean); + + return ( + <SharedPageLayout title={<BackButton onClick={backToList}>数据源</BackButton>} cardPadding={0}> + <div className={styled.data_source_detail_padding_box}> + <Spin loading={query.isFetching}> + <Row align="center" justify="space-between"> + <GridRow gap="12" style={{ maxWidth: '75%' }}> + <div + className={styled.data_source_detail_avatar} + data-name={query.data?.data.name.slice(0, 2)} + /> + <div> + <div className={styled.data_source_name_container}> + <h3 className={styled.data_source_name}>{query.data?.data.name ?? '....'}</h3> + </div> + {(isStreaming || query.data?.data.comment) && ( + <Space> + {isStreaming && <Tag color="blue">增量</Tag>} + {query.data?.data.comment && ( + <small className={styled.comment}>{query.data?.data.comment}</small> + )} + </Space> + )} + </div> + </GridRow> + + <Space> + <MoreActions + actionList={[ + { + label: '删除', + onClick: onDeleteClick, + danger: true, + }, + ]} + /> + </Space> + </Row> + </Spin> + <PropertyList + properties={displayedProps} + cols={displayedProps.length} + minWidth={150} + align="center" + colProportions={displayedProps.map((item) => item.proport)} + /> + </div> + <Tabs activeTab={activeTab} onChange={onSubtabChange} className={styled.data_detail_tab}> + <TabPane + className={styled.data_source_detail_tab_pane} + title="文件预览" + key={DataSourceDetailSubTabs.PreviewFile} + /> + {/* <TabPane + className={styled.data_source_detail_tab_pane} + title="原始数据集" + key={DataSourceDetailSubTabs.RawDataset} + /> */} + </Tabs> + <div className={`${styled.data_source_detail_padding_box}`}> + <Route + path={`/datasets/data_source/:id/${DataSourceDetailSubTabs.PreviewFile}`} + exact + render={() => { + return <PreviewFile />; + }} + /> + {/* <Route + path={`/datasets/data_source/:id/${DataSourceDetailSubTabs.RawDataset}`} + exact + render={() => { + return <div>原始数据集</div>; + }} + /> */} + </div> + </SharedPageLayout> + ); + + function getDataFormat(dataSource: DataSource) { + let dataDescText = ''; + switch (dataSource.dataset_format) { + case 'TABULAR': + dataDescText = `结构化数据${dataSource.store_format ? '/' + dataSource.store_format : ''}`; + break; + case 'NONE_STRUCTURED': + dataDescText = '非结构化数据'; + break; + case 'IMAGE': + dataDescText = '图片'; + break; + default: + dataDescText = '未知'; + break; + } + return dataDescText; + } + + function backToList() { + history.goBack(); + } + + function onDeleteClick() { + Modal.delete({ + title: '确认删除数据源?', + content: '删除后,当该数据源将无法恢复,请谨慎操作。', + onOk: async () => { + deleteMutation.mutate(id); + }, + }); + } + + function onSubtabChange(val: string) { + setActiveTab(val as DataSourceDetailSubTabs); + history.replace(`/datasets/data_source/${id}/${val}`); + } +}; + +export default DataSourceDetail; diff --git a/web_console_v2/client/src/views/Datasets/DataSourceList/index.module.less b/web_console_v2/client/src/views/Datasets/DataSourceList/index.module.less new file mode 100644 index 000000000..17a906758 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DataSourceList/index.module.less @@ -0,0 +1,14 @@ +.styled_plus_icon{ + margin-right: 4px; + vertical-align: 0.03em; +} + +.data_source_name{ + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + max-width: 120px; + margin-right: 5px; +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/DataSourceList/index.tsx b/web_console_v2/client/src/views/Datasets/DataSourceList/index.tsx new file mode 100644 index 000000000..1d383393a --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DataSourceList/index.tsx @@ -0,0 +1,303 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useQuery, useMutation } from 'react-query'; +import { uniqBy } from 'lodash-es'; + +import { useGetCurrentProjectId, useTablePaginationWithUrlState, useUrlState } from 'hooks'; + +import { TIME_INTERVAL } from 'shared/constants'; +import { formatTimestamp } from 'shared/date'; +import { transformRegexSpecChar } from 'shared/helpers'; +import { CONSTANTS } from 'shared/constants'; +import { useHistory, generatePath } from 'react-router'; +import routes from '../routes'; +import { Link } from 'react-router-dom'; +import { fetchDataSourceList, deleteDataSource } from 'services/dataset'; + +import { Button, Input, Message, Table, Tag, Tooltip, Typography } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import SharedPageLayout from 'components/SharedPageLayout'; +import MoreActions from 'components/MoreActions'; +import Modal from 'components/Modal'; +import { IconPlus } from '@arco-design/web-react/icon'; + +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { DataSource, DatasetType, DataSourceDataType } from 'typings/dataset'; +import { DataSourceDetailSubTabs } from 'views/Datasets/DataSourceDetail'; +import styled from './index.module.less'; + +const { Text } = Typography; + +type TProps = {}; +const { Search } = Input; + +const List: FC<TProps> = function (props: TProps) { + const history = useHistory(); + // const [isEdit, setIsEdit] = useState(false); + // const [selectedData, setSelectedData] = useState<DataSource>(); + const [pageTotal, setPageTotal] = useState(0); + const [urlState, setUrlState] = useUrlState({ + keyword: '', + types: [], + created_at_sort: '', + }); + const { paginationProps } = useTablePaginationWithUrlState(); + + const projectId = useGetCurrentProjectId(); + + const listQuery = useQuery( + ['fetchDataSourceList', projectId], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return Promise.resolve({ data: [] }); + } + return fetchDataSourceList({ + projectId, + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + refetchOnWindowFocus: false, + }, + ); + + const deleteMutation = useMutation( + (dataSourceId: ID) => { + return deleteDataSource(dataSourceId); + }, + { + onSuccess() { + listQuery.refetch(); + Message.success('删除成功'); + }, + onError(e: any) { + Message.error(e.message); + }, + }, + ); + + const list = useMemo(() => { + if (!listQuery.data?.data) return []; + + let list = listQuery.data.data; + + if (urlState.keyword) { + const regx = new RegExp(`^.*${transformRegexSpecChar(urlState.keyword)}.*$`); // support fuzzy matching + list = list.filter((item) => regx.test(item.name)); + } + setPageTotal(Math.ceil(list.length / paginationProps.pageSize)); + return list; + }, [listQuery.data, urlState.keyword, paginationProps.pageSize]); + + const typeFilters = useMemo(() => { + if (!listQuery.data?.data) return []; + + const list = listQuery.data.data || []; + + return { + filters: uniqBy(list, 'type').map((item) => { + return { text: item.type, value: item.type }; + }), + onFilter: (value: string, record: DataSource) => { + return record?.type === value; + }, + }; + }, [listQuery.data]); + + const columns = useMemo<ColumnProps<DataSource>[]>(() => { + return [ + { + title: '名称', + dataIndex: 'name', + width: 200, + render: (value: any, record: any) => { + const to = `/datasets/data_source/${record.id}/${DataSourceDetailSubTabs.PreviewFile}`; + if (record.dataset_type === DatasetType.STREAMING) { + return ( + <> + <Tooltip + content={ + <Text style={{ color: '#fff' }} copyable> + {value} + </Text> + } + > + <Link to={to} className={styled.data_source_name}> + {value} + </Link> + </Tooltip> + <Tag color="blue" size="small"> + 增量 + </Tag> + </> + ); + } + return <Link to={to}>{value}</Link>; + }, + }, + { + title: '类型', + dataIndex: 'type', + width: 100, + ...typeFilters, + defaultFilters: urlState.types ?? [], + render: (value: any) => value ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + title: '数据来源', + dataIndex: 'url', + width: 200, + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 150, + sorter(a: DataSource, b: DataSource) { + return a.created_at - b.created_at; + }, + defaultSortOrder: urlState?.created_at_sort, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + { + title: '格式', + dataIndex: 'dataset_format', + width: 150, + render: (value: any, record: any) => { + switch (value) { + case DataSourceDataType.STRUCT: + return record.store_format ? `结构化数据/${record.store_format}` : '结构化数据'; + case DataSourceDataType.NONE_STRUCTURED: + return '非结构化数据'; + case DataSourceDataType.PICTURE: + return '图片'; + default: + return '未知'; + } + }, + }, + { + title: '操作', + dataIndex: 'operation', + fixed: 'right', + width: 100, + render: (_: any, record) => ( + <> + <button + className="custom-text-button" + style={{ marginRight: 10 }} + onClick={() => { + onEditButtonClick(record); + }} + // No support for edit data source now + disabled={true} + > + 编辑 + </button> + <MoreActions + actionList={[ + { + label: '删除', + danger: true, + onClick() { + Modal.delete({ + title: `确认要删除「${record.name}」?`, + content: '删除后,当该数据源将无法恢复,请谨慎操作。', + onOk() { + deleteMutation.mutate(record.id); + }, + }); + }, + }, + ]} + /> + </> + ), + }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlState, typeFilters]); + + const pagination = useMemo(() => { + return pageTotal <= 1 + ? false + : { + ...paginationProps, + }; + }, [paginationProps, pageTotal]); + + return ( + <SharedPageLayout + title="数据源" + tip="数据源指数据的来源,创建数据源即定义访问数据存储空间的地址" + > + <GridRow justify="space-between" align="center"> + <Button + className={'custom-operation-button'} + type="primary" + onClick={onCreateButtonClick} + icon={<IconPlus />} + > + 添加数据源 + </Button> + <Search + className={'custom-input'} + allowClear + placeholder="输入数据源名称" + defaultValue={urlState.keyword} + onSearch={onSearch} + onClear={() => onSearch('')} + /> + </GridRow> + <Table + className="custom-table custom-table-left-side-filter" + rowKey="id" + loading={listQuery.isFetching} + data={list} + scroll={{ x: '100%' }} + columns={columns} + pagination={pagination} + onChange={(pagination, sorter, filters, extra) => { + switch (extra.action) { + case 'sort': + setUrlState((prevState) => ({ + ...prevState, + [`${sorter.field}_sort`]: sorter.direction, + })); + break; + case 'filter': + setUrlState((prevState) => ({ + ...prevState, + page: 1, + types: filters?.type ?? [], + })); + break; + default: + } + }} + /> + </SharedPageLayout> + ); + + function onCreateButtonClick() { + history.push( + generatePath(routes.DatasetCreate, { + action: 'create', + }), + ); + } + function onEditButtonClick(selectedDataSource: DataSource) { + // setSelectedData(selectedDataSource); + // setIsEdit(true); + } + + function onSearch(value: string) { + setUrlState((prevState) => ({ + ...prevState, + keyword: value, + page: 1, + })); + } +}; + +export default List; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/DataBatchAnalyzeModal/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/DataBatchAnalyzeModal/index.module.less new file mode 100644 index 000000000..c84e1ec08 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/DataBatchAnalyzeModal/index.module.less @@ -0,0 +1,4 @@ +.footer_row{ + padding-top: 15px; + border-top: 1px solid var(--backgroundColorGray); +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/DataBatchAnalyzeModal/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/DataBatchAnalyzeModal/index.tsx new file mode 100644 index 000000000..297cd85cf --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/DataBatchAnalyzeModal/index.tsx @@ -0,0 +1,198 @@ +import React, { FC, useMemo } from 'react'; +import ConfigForm, { ItemProps } from 'components/ConfigForm'; +import { to, isStringCanBeParsed } from 'shared/helpers'; +import { fetchDataJobVariableDetail, analyzeDataBatch } from 'services/dataset'; +import { Modal, Button, Message, Form } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import ButtonWithPopconfirm from 'components/ButtonWithPopconfirm'; +import { useQuery } from 'react-query'; +import { DataBatchV2, DataJobBackEndType, DataJobVariable } from 'typings/dataset'; +import { useParams } from 'react-router'; +import { + Variable, + VariableComponent, + VariableValueType, + VariableWidgetSchema, +} from 'typings/variable'; +import { TAG_MAPPER, VARIABLE_TIPS_MAPPER, NO_CATEGORY } from '../../../shared'; +import { Tag as TagEnum } from 'typings/workflow'; +import { hydrate } from 'views/Workflows/shared'; +import styled from './index.module.less'; + +type Props = { + dataBatch: DataBatchV2; + visible: boolean; + toggleVisible: (v: boolean) => void; + onSuccess?: (res: any) => void; +} & React.ComponentProps<typeof Modal>; +type Params = { + [key: string]: any; +}; +type FormData = { + params: Params; +}; + +const DataBatchAnalyzeModal: FC<Props> = ({ + dataBatch, + visible, + toggleVisible, + onSuccess, + ...props +}) => { + const [form] = Form.useForm<FormData>(); + const { id: dataBatchId } = dataBatch; + const { id } = useParams<{ + id: string; + }>(); + const dataJobVariableDetailQuery = useQuery( + ['getDataJobVariableDetail', DataJobBackEndType.ANALYZER], + () => fetchDataJobVariableDetail(DataJobBackEndType.ANALYZER), + { + enabled: true, + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const dataJobVariableList = useMemo<Variable[]>(() => { + if (!dataJobVariableDetailQuery.data?.data?.variables) { + return []; + } + + return dataJobVariableDetailQuery.data.data.variables.map((item) => { + let widget_schema: VariableWidgetSchema = {}; + + try { + widget_schema = JSON.parse(item.widget_schema); + } catch (error) {} + + return { + ...item, + widget_schema, + }; + }); + }, [dataJobVariableDetailQuery.data]); + + const paramsList = useMemo<ItemProps[]>(() => { + const list: ItemProps[] = []; + dataJobVariableList + .filter((item) => !item.widget_schema.hidden) + .forEach((item) => { + const baseRuleList = item.widget_schema.required + ? [ + { + required: true, + message: '必填项', + }, + ] + : []; + + list.push({ + tip: VARIABLE_TIPS_MAPPER[item.name], + label: item.name, + tag: TAG_MAPPER[item.tag as TagEnum] || NO_CATEGORY, + field: item.name, + initialValue: + item.widget_schema.component === VariableComponent.Input + ? item.value + : item.typed_value, + componentType: item.widget_schema.component, + rules: + item.widget_schema.component === VariableComponent.Input && + [VariableValueType.LIST, VariableValueType.OBJECT].includes(item.value_type!) + ? [ + ...baseRuleList, + { + validator: (value, callback) => { + if ((value && typeof value === 'object') || isStringCanBeParsed(value)) { + callback(); + return; + } + callback(`JSON ${item.value_type} 格式错误`); + }, + }, + ] + : baseRuleList, + }); + }); + return list; + }, [dataJobVariableList]); + return ( + <Modal + title="发起数据探查" + visible={visible} + maskClosable={false} + maskStyle={{ backdropFilter: 'blur(4px)' }} + afterClose={afterClose} + onCancel={closeModal} + okText="探查" + footer={null} + {...props} + > + <Form layout="vertical" form={form} onSubmit={submit}> + <Form.Item label="参数配置" field="params"> + <ConfigForm + filter={variableTagFilter} + groupBy={'tag'} + hiddenGroupTag={true} + hiddenCollapse={true} + cols={2} + formItemList={paramsList} + isResetOnFormItemListChange={true} + /> + </Form.Item> + + <Form.Item wrapperCol={{ span: 24 }} style={{ marginBottom: 0 }}> + <GridRow className={styled.footer_row} justify="end" gap="12"> + <ButtonWithPopconfirm buttonText="取消" onConfirm={closeModal} /> + <Button type="primary" htmlType="submit"> + 探查 + </Button> + </GridRow> + </Form.Item> + </Form> + </Modal> + ); + + function variableTagFilter(item: ItemProps) { + return ( + !!item.tag && + [TAG_MAPPER[TagEnum.INPUT_PARAM], TAG_MAPPER[TagEnum.RESOURCE_ALLOCATION]].includes(item.tag) + ); + } + + function closeModal() { + toggleVisible(false); + } + + async function submit(values: { params: Params }) { + if (!form) { + return; + } + const { params } = values; + const [res, error] = await to( + analyzeDataBatch(id, dataBatchId, { + dataset_job_config: { + variables: hydrate(dataJobVariableList, params, { + isStringifyVariableValue: true, + isStringifyVariableWidgetSchema: true, + isProcessVariableTypedValue: true, + }) as DataJobVariable[], + }, + }), + ); + if (error) { + Message.error(error.message); + return; + } + Message.success('数据探查发起成功'); + closeModal(); + onSuccess?.(res); + } + + function afterClose() { + form.resetFields(); + } +}; + +export default DataBatchAnalyzeModal; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/FeatureDrawer.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/FeatureDrawer.tsx new file mode 100644 index 000000000..ab182ddc5 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/FeatureDrawer.tsx @@ -0,0 +1,77 @@ +import React, { FC, useMemo } from 'react'; +import FeatureInfoDrawer, { + formatChartData, + METRIC_KEY_TRANSLATE_MAP, +} from 'components/DataPreview/StructDataTable/FeatureInfoDrawer'; +import { useQuery } from 'react-query'; +import { fetchFeatureInfo } from 'services/dataset'; +import { floor } from 'lodash-es'; + +type Props = { + id: ID; + batchId: ID; + activeKey?: string; + visible: boolean; + onClose: () => void; + toggleDrawerVisible: (val: boolean) => void; +}; + +const StructDataPreview: FC<Props> = ({ + id, + batchId, + activeKey, + visible, + onClose, + toggleDrawerVisible, +}) => { + const featInfoQuery = useQuery( + ['fetchFeatureInfo', activeKey, id, batchId], + () => fetchFeatureInfo(id, batchId, activeKey!), + { + enabled: Boolean(activeKey) && visible, + refetchOnWindowFocus: false, + }, + ); + const featData = useMemo(() => { + if (!activeKey || !featInfoQuery.data) return undefined; + + const data = featInfoQuery.data?.data; + + // Add custom filed missing_rate + const metrics = data?.metrics ?? {}; + if ( + Object.prototype.hasOwnProperty.call(metrics, 'count') && + Object.prototype.hasOwnProperty.call(metrics, 'missing_count') && + !Object.prototype.hasOwnProperty.call(metrics, 'missing_rate') + ) { + const missingCount = Number(metrics.missing_count) || 0; + const allCount = missingCount + (Number(metrics.count) || 0); + // Calc missing_rate + metrics['missing_rate'] = String(floor((missingCount / allCount) * 100, 2)); + } + + const table = Object.entries(metrics).map(([key, value]) => { + return { + key: METRIC_KEY_TRANSLATE_MAP[key], + value: key === 'missing_rate' ? value + '%' : floor(Number(value), 3), + }; + }); + + const hist = formatChartData(data.hist.x ?? [], [{ data: data.hist.y ?? [], label: '数据集' }]); + + return { table, hist }; + }, [featInfoQuery.data, activeKey]); + return ( + <FeatureInfoDrawer + data={featData?.table} + histData={featData?.hist} + featureKey={activeKey} + loading={featInfoQuery.isFetching} + visible={visible} + toggleVisible={toggleDrawerVisible} + onClose={onClose} + /> + ); +}; + +export default StructDataPreview; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/index.module.less new file mode 100644 index 000000000..b74ef2fe2 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/index.module.less @@ -0,0 +1,138 @@ +.data_batch_analyze{ + height: 100%; + display: flex; + justify-content: flex-start; +} + +.data_batch_list_wrapper{ + height: 100%; + border-right: 1px solid #e5e8ef; + position: relative; + .data_batch_list{ + overflow: scroll; + height: calc(100% - 40px); + .data_batch_list_header{ + color: #1D2129; + font-weight: 500; + font-size: 14px; + height: 60px; + line-height: 60px; + padding-left: 20px; + } + .data_batch_list_item{ + height: 56px; + border-top: 1px solid #E5E8EF; + padding-top: 8px; + padding-left: 20px; + position: relative; + cursor: pointer; + & .data_batch_list_item_title{ + font-size: 12px; + line-height: 20px; + height: 20px; + } + & .data_batch_list_item_action{ + position: absolute; + top: 3px; + right: 17px; + :global{ + .arco-btn-text:not(.arco-btn-disabled){ + color: #4E5969; + } + } + } + &.active { + background-color: #F2F3F8; + & .data_batch_list_item_title{ + font-size: 12px; + line-height: 20px; + height: 20px; + color: #1664FF; + } + } + } + } + .data_batch_list_count{ + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 40px; + padding: 10px 0; + z-index: 10; + text-align: center; + background: #fff; + font-weight: 400; + font-size: 12px; + line-height: 20px; + color: #86909C; + } + .collapse { + transition: 0.1s background-color; + position: absolute; + top: 285px; + left: 200px; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + padding: 2px 0 1px; + border-radius: 50%; + cursor: pointer; + background: #FFFFFF; + border: 0.857143px solid #E5E8EF; + box-shadow: 0px 0px 7px #F2F3F5; + } + .is_reverse { + transition: 0.1s background-color cubic-bezier(0.4, 0, 0.2, 1); + position: absolute; + left: 5px; + top: 285px; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + transform: rotate(180deg); + padding: 1px 0 2px; + border-radius: 50%; + cursor: pointer; + background: #FFFFFF; + border: 0.857143px solid #E5E8EF; + box-shadow: 0px 0px 7px #F2F3F5; + } +} + +.data_batch_content{ + height: 100%; + flex: 1; + overflow: scroll; + .data_batch_no_success{ + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + .empty{ + width: 200px; + height: 200px; + } + .no_batch_preview{ + display: inline-block; + margin: 8px 0 10px 0; + font-size: 16px; + line-height: 22px; + color: #606A78; + } + .data_batch_loading{ + width: auto; + } + } +} + +.data_batch_preview{ + overflow: scroll; +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/index.tsx new file mode 100644 index 000000000..0f0f25845 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchAnalyze/index.tsx @@ -0,0 +1,313 @@ +import React, { FC, useState, useMemo, useCallback } from 'react'; +import { useQuery } from 'react-query'; +import { + fetchDataBatchs, + fetchDataBatchPreviewData, + fetchDatasetJobStageById, +} from 'services/dataset'; +import { useParams, useHistory } from 'react-router'; +import { Left } from 'components/IconPark'; +import { TIME_INTERVAL } from 'shared/constants'; +import MoreActions from 'components/MoreActions'; +import StructDataPreviewTable from 'components/DataPreview/StructDataTable'; +import { DataBatchV2, DatasetStateFront } from 'typings/dataset'; +import { Progress, Button, Spin, Message } from '@arco-design/web-react'; +import emptyIcon from 'assets/images/empty.png'; +import { useToggle } from 'react-use'; +import DataBatchAnalyzeModal from './DataBatchAnalyzeModal'; +import { formatTimestamp } from 'shared/date'; +import { useGetCurrentProjectId } from 'hooks'; +import { + isDatasetJobStagePending, + isDatasetJobStageFailed, + isDatasetJobStageSuccess, +} from '../../shared'; +import { JobDetailSubTabs } from 'views/Datasets/NewDatasetJobDetail'; +import FeatureDrawer from './FeatureDrawer'; +import { useFeatureDrawerClickOutside } from 'components/DataPreview/StructDataTable/hooks'; + +import styled from './index.module.less'; + +type TProps = { + datasetJobId: ID; + isOldData: boolean; + onAnalyzeBatch: () => void; +}; +const DataBatchAnalyze: FC<TProps> = function (props: TProps) { + const projectId = useGetCurrentProjectId(); + const [collapsed, setCollapsed] = useState(true); + const [total, setTotal] = useState(0); + const [activeBatch, setActiveBatch] = useState<DataBatchV2>(); + const [visible, toggleVisible] = useToggle(false); + const [activeKey, setActiveFeatKey] = useState<string | undefined>(); + const [drawerVisible, toggleDrawerVisible] = useToggle(false); + const history = useHistory(); + const { id } = useParams<{ + id: string; + }>(); + + // generator listQuery + const listQuery = useQuery( + ['fetchDataBatchs', id], + () => { + return fetchDataBatchs(id!); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + onSuccess: (res) => { + const { page_meta, data } = res || {}; + setTotal((pre) => page_meta?.total_items || pre); + if (!activeBatch) { + setActiveBatch(data[0]); + } else { + const newActiveBatch = data.find((item) => item.id === activeBatch.id); + setActiveBatch(newActiveBatch || data[0]); + } + }, + }, + ); + + const queryBatchState = useQuery( + [ + 'fetchDatasetJobStageById', + projectId, + props.datasetJobId, + activeBatch?.latest_analyzer_dataset_job_stage_id, + ], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchDatasetJobStageById( + projectId, + props.datasetJobId, + activeBatch?.latest_analyzer_dataset_job_stage_id!, + ); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.EXPORT_STATE_CHECK, + enabled: + Boolean(props.datasetJobId) && + Boolean(activeBatch) && + Boolean(activeBatch?.latest_analyzer_dataset_job_stage_id), + }, + ); + + const batchAnalyzeState = queryBatchState.data?.data; + + // ======= Preivew data query ============ + const previewDataQuery = useQuery( + ['fetchDataBatchPreviewData', id, activeBatch?.id], + () => fetchDataBatchPreviewData(id, activeBatch!?.id), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: + Boolean(id) && Boolean(activeBatch) && Boolean(isDatasetJobStageSuccess(batchAnalyzeState)), + }, + ); + const list = useMemo(() => { + return listQuery.data?.data || []; + }, [listQuery.data]); + + const onDrawerClose = useCallback(() => { + setActiveFeatKey(undefined); + }, [setActiveFeatKey]); + + // When click outside struct data atable | feature drawer, close the drawer + useFeatureDrawerClickOutside({ setActiveFeatKey, toggleDrawerVisible }); + return ( + <div className={styled.data_batch_analyze}> + <div + className={styled.data_batch_list_wrapper} + style={{ + width: collapsed ? '212px' : '0px', + }} + > + {collapsed && ( + <div className={styled.data_batch_list}> + <div className={styled.data_batch_list_header}>批次列表</div> + {list.map((item, index) => ( + <div + key={index} + className={`${styled.data_batch_list_item} ${ + item.id === activeBatch?.id ? styled.active : '' + }`} + onClick={() => { + handleChangeBatch(item); + }} + > + <div className={styled.data_batch_list_item_title}>批次 {item.name}</div> + <div className={styled.data_batch_list_item_time}> + {formatTimestamp(item.updated_at, 'YYYY-MM-DD HH:mm')} + </div> + <MoreActions + className={styled.data_batch_list_item_action} + actionList={[ + { + label: '查看任务详情', + onClick: () => onDetailClick(item), + }, + ]} + /> + </div> + ))} + <div className={styled.data_batch_list_count}>{total}个记录</div> + </div> + )} + <div + onClick={() => setCollapsed(!collapsed)} + className={collapsed ? styled.collapse : styled.is_reverse} + > + <Left /> + </div> + </div> + <div className={styled.data_batch_content}> + {!activeBatch ? renderNoBatch() : renderBatchDetail()} + </div> + {activeBatch && ( + <DataBatchAnalyzeModal + visible={visible} + toggleVisible={toggleVisible} + dataBatch={activeBatch} + onSuccess={handleAnalyzeSuccess} + /> + )} + {activeBatch && ( + <FeatureDrawer + id={id} + batchId={activeBatch.id} + toggleDrawerVisible={toggleDrawerVisible} + visible={drawerVisible} + activeKey={activeKey} + onClose={onDrawerClose} + /> + )} + </div> + ); + + function renderNoBatch() { + return ( + <div className={styled.data_batch_no_success}> + <img alt="" src={emptyIcon} className={styled.empty} /> + <span className={styled.no_batch_preview}>无数据批次</span> + </div> + ); + } + + function renderBatchDetail() { + return ( + <> + {activeBatch?.latest_analyzer_dataset_job_stage_id === 0 ? ( + renderNoDoBatch() + ) : ( + <> + {isDatasetJobStagePending(batchAnalyzeState) && renderProcessBatch()} + {isDatasetJobStageFailed(batchAnalyzeState) && renderFailedBatch()} + {isDatasetJobStageSuccess(batchAnalyzeState) && renderSuccessBatch()} + </> + )} + </> + ); + } + + function renderNoDoBatch() { + const batchActionMap = { + [DatasetStateFront.SUCCEEDED]: () => ( + <Button type="primary" style={{ width: '136px' }} onClick={openAnalyzeModel}> + 发起探查 + </Button> + ), + [DatasetStateFront.PENDING]: () => ( + <span className={styled.no_batch_preview}>当前数据批次待处理, 请稍后探查</span> + ), + [DatasetStateFront.PROCESSING]: () => ( + <span className={styled.no_batch_preview}>当前数据批次正在处理, 请稍后探查</span> + ), + [DatasetStateFront.FAILED]: () => ( + <span className={styled.no_batch_preview}>当前数据批次处理失败, 无法探查</span> + ), + [DatasetStateFront.DELETING]: () => ( + <span className={styled.no_batch_preview}>当前数据批次正在删除, 无法探查</span> + ), + }; + return ( + <div className={styled.data_batch_no_success}> + <img alt="" src={emptyIcon} className={styled.empty} /> + <span className={styled.no_batch_preview}>无探查数据</span> + {activeBatch?.state && batchActionMap[activeBatch.state]()} + </div> + ); + } + + function renderProcessBatch() { + return ( + <div className={styled.data_batch_no_success}> + <Spin size={50} className={styled.data_batch_loading} /> + <span className={styled.no_batch_preview}>数据探查中,可能会耗时较长…</span> + </div> + ); + } + + function renderFailedBatch() { + return ( + <div className={styled.data_batch_no_success}> + <Progress type="circle" size="large" percent={50} status="error" /> + <span className={styled.no_batch_preview}>数据探查失败</span> + <Button type="text" style={{ width: 136 }} onClick={openAnalyzeModel}> + 点击重试 + </Button> + </div> + ); + } + function renderSuccessBatch() { + return ( + <StructDataPreviewTable + data={previewDataQuery.data?.data} + loading={previewDataQuery.isFetching} + isError={previewDataQuery.isError} + onActiveFeatChange={onActiveFeatChange} + {...props} + /> + ); + } + function handleChangeBatch(batch: DataBatchV2) { + setActiveBatch(batch); + } + + function openAnalyzeModel() { + toggleVisible(true); + } + + function handleAnalyzeSuccess() { + // hook 方法, 现在analyze_job_id 存在了 dataset 实体上, 所以需要刷新下dataset数据 + props.onAnalyzeBatch(); + listQuery.refetch(); + } + + function onActiveFeatChange(featKey: string) { + setActiveFeatKey(featKey); + toggleDrawerVisible(true); + } + + function onDetailClick(dataBatch: DataBatchV2) { + if (dataBatch.latest_analyzer_dataset_job_stage_id === 0) { + Message.warning('发起数据探查后才可查看详情'); + } else { + if (props.isOldData) { + history.push(`/datasets/job_detail/${props.datasetJobId}`); + } else { + history.push( + `/datasets/${id}/new/job_detail/${queryBatchState.data?.data.dataset_job_id}/${JobDetailSubTabs.TaskList}`, + ); + } + } + } + + // function onDeleteClick() {} +}; + +export default DataBatchAnalyze; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DataBatchActions/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DataBatchActions/index.tsx new file mode 100644 index 000000000..3c202242b --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DataBatchActions/index.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { DataBatchV2, DatasetKindLabel, DatasetStateFront } from 'typings/dataset'; +import { Space } from '@arco-design/web-react'; +import MoreActions from 'components/MoreActions'; +interface IProp { + data: DataBatchV2; + onDelete?: () => void; + onStop?: () => void; + onExport?: (batchId: ID) => void; + onRerun?: (batchId: ID, batchName: string) => void; + kindLabel: DatasetKindLabel; +} + +export default function TaskActions(prop: IProp) { + const { data, kindLabel, onExport, onRerun } = prop; + + const isProcessedDataset = kindLabel === DatasetKindLabel.PROCESSED; + const isSuccess = data.state === DatasetStateFront.SUCCEEDED; + const isFailed = data.state === DatasetStateFront.FAILED; + return ( + <Space> + <button + className="custom-text-button" + style={{ + marginRight: 10, + }} + type="button" + key="rerun-batch" + disabled={!isFailed} + onClick={() => onRerun?.(data.id, data.name)} + > + 重新运行 + </button> + {isProcessedDataset && ( + <button + className="custom-text-button" + style={{ + marginRight: 10, + }} + type="button" + key="export-batch" + disabled={!isSuccess} + onClick={() => onExport?.(data.id)} + > + 导出 + </button> + )} + <MoreActions + actionList={[ + { + disabled: true, + label: '删除', + danger: true, + onClick() {}, + }, + ]} + /> + </Space> + ); +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DataBatchRate/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DataBatchRate/index.tsx new file mode 100644 index 000000000..a4a210a23 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DataBatchRate/index.tsx @@ -0,0 +1,42 @@ +import React, { FC, useState } from 'react'; +import { fetchDatasetJobStageById } from 'services/dataset'; +import { useQuery } from 'react-query'; +import { getIntersectionRate } from 'shared/dataset'; +import { useGetCurrentProjectId } from 'hooks'; +import { Message } from '@arco-design/web-react'; + +type Props = { + datasetJobId: ID; + datasetJobStageId: ID; +}; +const DataBatchRate: FC<Props> = function ({ datasetJobId, datasetJobStageId }: Props) { + const projectId = useGetCurrentProjectId(); + const [rate, setRate] = useState('-'); + const queryBatchState = useQuery( + ['fetchDatasetJobStageById', projectId, datasetJobId, datasetJobStageId], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchDatasetJobStageById(projectId, datasetJobId, datasetJobStageId); + }, + { + retry: 2, + enabled: Boolean(datasetJobId) && Boolean(datasetJobStageId), + onSuccess(res) { + if (!res) return; + const { input_data_batch_num_example, output_data_batch_num_example } = res.data; + setRate( + getIntersectionRate({ + input: input_data_batch_num_example, + output: output_data_batch_num_example, + }), + ); + }, + }, + ); + return <>{queryBatchState.isFetching ? '-' : rate}</>; +}; + +export default DataBatchRate; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DatasetBatchRerunModal/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DatasetBatchRerunModal/index.module.less new file mode 100644 index 000000000..ed9603421 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DatasetBatchRerunModal/index.module.less @@ -0,0 +1,13 @@ +.footer_grid_row{ + padding-top: 15px; + border-top: 1px solid var(--backgroundColorGray); +} +.model{ + width: 700px; + :global{ + .arco-form{ + max-height: 50vh; + overflow: scroll; + } + } +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DatasetBatchRerunModal/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DatasetBatchRerunModal/index.tsx new file mode 100644 index 000000000..b87eecd22 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/DatasetBatchRerunModal/index.tsx @@ -0,0 +1,331 @@ +import React, { FC, useState, useEffect } from 'react'; +import { to, isStringCanBeParsed } from 'shared/helpers'; +import { rerunDatasetBatchById } from 'services/dataset'; +import { Modal, Form, Input, Message } from '@arco-design/web-react'; +import { DataJobVariable, GlobalConfigs, DataJobBackEndType } from 'typings/dataset'; +import { + Variable, + VariableComponent, + VariableValueType, + VariableWidgetSchema, +} from 'typings/variable'; +import ConfigForm, { ItemProps } from 'components/ConfigForm'; +import { + TAG_MAPPER, + VARIABLE_TIPS_MAPPER, + NO_CATEGORY, + SYNCHRONIZATION_VARIABLE, + isSingleParams, +} from '../../../shared'; +import { Tag as TagEnum } from 'typings/workflow'; +import { + useGetCurrentProjectParticipantList, + useGetCurrentPureDomainName, + useGetCurrentDomainName, +} from 'hooks'; +import { hydrate } from 'views/Workflows/shared'; +import styled from './index.module.less'; + +export interface Props { + visible: boolean; + id: ID; + batchId: ID; + batchName: string; + kind: DataJobBackEndType; + globalConfigs: GlobalConfigs; + onSuccess?: () => void; + onFail?: () => void; + onCancel?: () => void; +} + +type Params = { + [key: string]: any; +}; +type FormData = { + batch_name: string; + params: Params; + participant: { + [participantName: string]: { + params: Params; + }; + }; +}; + +const DatasetBatchRerunModal: FC<Props> = ({ + id, + batchId, + batchName, + kind, + visible, + globalConfigs, + onSuccess, + onFail, + onCancel, +}) => { + const participantList = useGetCurrentProjectParticipantList(); + const myPureDomainName = useGetCurrentPureDomainName(); + const myDomainName = useGetCurrentDomainName(); + const [globalConfigMap, setGlobalConfigMap] = useState<any>({}); + + const [formInstance] = Form.useForm<FormData>(); + const isSingle = isSingleParams(kind); + useEffect(() => { + const globalConfigParseMap: any = {}; + Object.keys(globalConfigs).forEach((key) => { + const globalConfig = globalConfigs[key]; + globalConfigParseMap[key] = handleParseToConfigFrom( + handleParseDefinition(globalConfig.variables), + false, + ); + }); + setGlobalConfigMap(globalConfigParseMap); + }, [globalConfigs, batchId]); + + useEffect(() => { + formInstance.setFieldValue('batch_name', batchName); + }, [batchName, formInstance]); + return ( + <Modal + title="重新运行" + visible={visible} + maskClosable={false} + afterClose={afterClose} + onCancel={onCancel} + onOk={handleOnOk} + // footer={null} + className={styled.model} + > + <Form form={formInstance} onSubmit={onSubmit}> + <Form.Item field="batch_name" label="数据集批次" disabled={true}> + <Input /> + </Form.Item> + <Form.Item label="我方参数" field="params"> + <ConfigForm + filter={variableTagFilter} + groupBy={'tag'} + hiddenGroupTag={true} + hiddenCollapse={true} + cols={2} + formItemList={globalConfigMap[myPureDomainName]} + isResetOnFormItemListChange={true} + onChange={(val) => { + syncConfigFormValue( + val, + [ + SYNCHRONIZATION_VARIABLE.NUM_PARTITIONS, + SYNCHRONIZATION_VARIABLE.PART_NUM, + SYNCHRONIZATION_VARIABLE.REPLICAS, + ], + false, + ); + }} + /> + </Form.Item> + {!isSingle && renderParticipantConfigLayout()} + </Form> + </Modal> + ); + function renderParticipantConfigLayout() { + return participantList?.map((item, index) => { + const { pure_domain_name } = item; + return ( + <Form.Item field={`participant.${item.name}.params`} label={`${item.name}参数`} key={index}> + <ConfigForm + filter={variableTagFilter} + groupBy={'tag'} + hiddenGroupTag={true} + hiddenCollapse={true} + cols={2} + formItemList={globalConfigMap[pure_domain_name!]} + isResetOnFormItemListChange={true} + onChange={(val) => { + syncConfigFormValue( + val, + [ + SYNCHRONIZATION_VARIABLE.NUM_PARTITIONS, + SYNCHRONIZATION_VARIABLE.PART_NUM, + SYNCHRONIZATION_VARIABLE.REPLICAS, + ], + true, + item.name, + ); + }} + /> + </Form.Item> + ); + }); + } + function variableTagFilter(item: ItemProps) { + return !!item.tag && [TAG_MAPPER[TagEnum.RESOURCE_ALLOCATION]].includes(item.tag); + } + + function syncConfigFormValue( + value: { [prop: string]: any }, + keyList: string[], + isParticipant: boolean, + currentParticipant?: string, + ) { + if (!keyList || !keyList.length || !value) { + return; + } + const senderParams: any = formInstance.getFieldValue('params') || {}; + const participantParams: any = formInstance.getFieldValue('participant'); + keyList.forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + return; + } + if (isParticipant) { + senderParams[key] = value[key]; + } + participantList.forEach((item) => { + if (isParticipant && item.name === currentParticipant) { + return; + } + const params = participantParams?.[item.name]?.params || {}; + params[key] = value[key]; + }); + }); + formInstance.setFieldsValue({ + params: { + ...senderParams, + }, + participant: { + ...participantParams, + }, + }); + } + + function handleParseDefinition(definitions: DataJobVariable[]) { + return definitions.map((item) => { + let widget_schema: VariableWidgetSchema = {}; + + try { + widget_schema = JSON.parse(item.widget_schema); + } catch (error) {} + return { + ...item, + widget_schema, + }; + }); + } + + function handleParseToConfigFrom(variableList: Variable[], disabled: boolean) { + return variableList + .filter((item) => !item.widget_schema.hidden) + .map((item) => { + const baseRuleList = item.widget_schema.required + ? [ + { + required: true, + message: '必填项', + }, + ] + : []; + return { + disabled, // 在授权时将参数配置禁用修改 + tip: VARIABLE_TIPS_MAPPER[item.name], + label: item.name, + tag: TAG_MAPPER[item.tag as TagEnum] || NO_CATEGORY, + field: item.name, + initialValue: + item.widget_schema.component === VariableComponent.Input + ? item.value + : item.typed_value, + componentType: item.widget_schema.component, + rules: + item.widget_schema.component === VariableComponent.Input && + [VariableValueType.LIST, VariableValueType.OBJECT].includes(item.value_type!) + ? [ + ...baseRuleList, + { + validator: (value: any, callback: (error?: string | undefined) => void) => { + if ((value && typeof value === 'object') || isStringCanBeParsed(value)) { + callback(); + return; + } + callback(`JSON ${item.value_type!} 格式错误`); + }, + }, + ] + : baseRuleList, + }; + }); + } + + async function onSubmit(values: FormData) { + if (!formInstance) { + return; + } + const participantParams: { + [domainName: string]: { + dataset_uuid: ID; + variables: DataJobVariable[]; + }; + } = {}; + + if (!isSingle) { + participantList?.reduce( + (acc, item) => { + const participantValues = values.participant[item.name]; + acc[item.domain_name] = { + dataset_uuid: globalConfigs[item.pure_domain_name!]?.dataset_uuid, + variables: hydrate( + globalConfigs[item.pure_domain_name!]?.variables, + participantValues.params, + { + isStringifyVariableValue: true, + isStringifyVariableWidgetSchema: true, + isProcessVariableTypedValue: true, + }, + ) as DataJobVariable[], + }; + + return acc; + }, + {} as { + [domainName: string]: { + dataset_uuid: ID; + variables: DataJobVariable[]; + }; + }, + ); + } + + const [, err] = await to( + rerunDatasetBatchById(id!, batchId!, { + dataset_job_parameter: { + global_configs: { + [myDomainName]: { + dataset_uuid: globalConfigs[myPureDomainName].dataset_uuid, + variables: hydrate(globalConfigs[myPureDomainName].variables, values.params, { + isStringifyVariableValue: true, + isStringifyVariableWidgetSchema: true, + isProcessVariableTypedValue: true, + }) as DataJobVariable[], + }, + ...participantParams, + }, + }, + }), + ); + if (err) { + onFail?.(); + Message.error(err.message || '重新运行失败'); + return; + } + + Message.success('重新运行成功'); + onSuccess?.(); + } + + function handleOnOk() { + formInstance.submit(); + } + + function afterClose() { + // Clear all fields + formInstance.resetFields(); + } +}; + +export default DatasetBatchRerunModal; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/index.module.less new file mode 100644 index 000000000..2a473dc37 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/index.module.less @@ -0,0 +1,5 @@ +@import '~styles/mixins.less'; +.data_batch_table_path{ + .MixinEllipsis('200px'); + cursor: pointer; +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/index.tsx new file mode 100644 index 000000000..af9199731 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DataBatchTable/index.tsx @@ -0,0 +1,390 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useTablePaginationWithUrlState, useUrlState } from 'hooks'; +import { fetchDataBatchs } from 'services/dataset'; +import { TABLE_COL_WIDTH, TIME_INTERVAL } from 'shared/constants'; +import { formatTimestamp } from 'shared/date'; +import { useParams, useHistory } from 'react-router'; +import { + dataBatchStateFilters, + FILTER_DATA_BATCH_OPERATOR_MAPPER, + filterExpressionGenerator, + getSortOrder, +} from '../../shared'; +import { Table, Tooltip, Typography, Message, Tag } from '@arco-design/web-react'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import ExportModal from 'components/DatasetExportModal'; +import DatasetBatchRerunModal from './DatasetBatchRerunModal'; +import { Link } from 'react-router-dom'; +import { JobDetailSubTabs } from 'views/Datasets/NewDatasetJobDetail'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { + DataBatchV2, + DatasetStateFront, + DatasetKindLabel, + GlobalConfigs, + DataJobBackEndType, +} from 'typings/dataset'; +import { expression2Filter } from 'shared/filter'; +import DataBatchActions from './DataBatchActions'; +import DataBatchRate from './DataBatchRate'; +import { humanFileSize } from 'shared/file'; +import styled from './index.module.less'; + +const { Text } = Typography; + +type Props = { + datasetJobId: ID; + isOldData: boolean; + isDataJoin: boolean; + isCopy: boolean; + isInternalProcessed: boolean; + kind: DataJobBackEndType; + datasetRate: string; + globalConfigs: GlobalConfigs; +}; +const DataBatchTable: FC<Props> = function ({ + isOldData, + isDataJoin, + isCopy, + isInternalProcessed, + kind, + datasetRate, + datasetJobId, + globalConfigs, +}: Props) { + const { id, kind_label } = useParams<{ + id: string; + kind_label: DatasetKindLabel; + }>(); + const { paginationProps } = useTablePaginationWithUrlState(); + const [total, setTotal] = useState(0); + const [pageTotal, setPageTotal] = useState(0); + const [currentExportBatchId, setCurrentExportBatchId] = useState<ID>(); + const [isShowExportModal, setIsShowExportModal] = useState(false); + const [currentRerunBatchId, setCurrentRerunBatchId] = useState<ID>(); + const [currentRerunBatchName, setCurrentRerunBatchName] = useState<string>(''); + const [isShowRerunModal, setIsShowRerunModal] = useState(false); + const history = useHistory(); + + // store filter status into urlState + const [urlState, setUrlState] = useUrlState({ + filter: '', + order_by: '', + page: 1, + pageSize: 10, + }); + + // generator listQuery + const listQuery = useQuery( + ['fetchDataBatchs', id, urlState], + () => { + return fetchDataBatchs(id!, { + page: urlState.page, + page_size: urlState.pageSize, + order_by: urlState.order_by, + filter: urlState.filter, + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + refetchOnWindowFocus: false, + onSuccess: (res) => { + const { page_meta } = res || {}; + setTotal((pre) => page_meta?.total_items || pre); + setPageTotal(page_meta?.total_pages ?? 0); + }, + }, + ); + + // generator listData from listQuery and watch listQuery + const list = useMemo(() => { + return listQuery.data?.data || []; + }, [listQuery.data]); + + const columns = useMemo<ColumnProps<DataBatchV2>[]>(() => { + return [ + { + title: '批次名称', + dataIndex: 'name', + key: 'name', + width: TABLE_COL_WIDTH.NAME, + ellipsis: true, + render: (name: string, record: DataBatchV2) => { + const to = isOldData + ? `/datasets/job_detail/${datasetJobId}` + : `/datasets/${id}/new/job_detail/${datasetJobId}/${JobDetailSubTabs.TaskList}?stageId=${record.latest_parent_dataset_job_stage_id}`; + return <Link to={to}>{name}</Link>; + }, + }, + { + title: '状态', + dataIndex: 'state', + key: 'state', + width: TABLE_COL_WIDTH.NORMAL, + ...dataBatchStateFilters, + filteredValue: expression2Filter(urlState.filter).state, + render: (_: any, record: DataBatchV2) => { + const { state, file_size } = record; + const isEmptyDataset = (file_size || 0) <= 0 && state === DatasetStateFront.SUCCEEDED; + const isErrorFileSize = record.file_size === -1; + let type: StateTypes; + let text: string; + switch (state) { + case DatasetStateFront.PENDING: + type = 'processing'; + text = '待处理'; + break; + case DatasetStateFront.PROCESSING: + type = 'processing'; + text = '处理中'; + break; + case DatasetStateFront.SUCCEEDED: + type = 'success'; + text = '可用'; + break; + case DatasetStateFront.DELETING: + type = 'processing'; + text = '删除中'; + break; + case DatasetStateFront.FAILED: + type = 'error'; + text = '处理失败'; + break; + + default: + type = 'default'; + text = '状态未知'; + break; + } + return ( + <div className="indicator-with-tip"> + <StateIndicator type={type} text={text} /> + {isEmptyDataset && !isErrorFileSize && !isInternalProcessed && ( + <Tag className={'dataset-empty-tag'} color="purple" size="small"> + 空集 + </Tag> + )} + </div> + ); + }, + }, + { + title: '文件大小', + dataIndex: 'file_size', + key: 'file_size', + width: TABLE_COL_WIDTH.THIN, + render: (_: any, record: DataBatchV2) => { + const isErrorFileSize = record.file_size === -1; + if (isErrorFileSize) { + return '未知'; + } + return <span>{isInternalProcessed ? '-' : humanFileSize(_ || 0)}</span>; + }, + }, + { + title: '样本量', + dataIndex: 'num_example', + key: 'num_example', + width: TABLE_COL_WIDTH.THIN, + render: (num_example: number, record: DataBatchV2) => { + const isErrorFileSize = record.file_size === -1; + if (isErrorFileSize) { + return '未知'; + } + return !isCopy || isInternalProcessed ? '-' : num_example; + }, + }, + isDataJoin && + ({ + title: '求交率', + dataIndex: 'latest_parent_dataset_job_stage_id', + key: 'latest_parent_dataset_job_stage_id', + width: TABLE_COL_WIDTH.ID, + render: (latest_parent_dataset_job_stage_id: ID, record: DataBatchV2) => { + const isErrorFileSize = record.file_size === -1; + if (isErrorFileSize) { + return '未知'; + } + if (isOldData) { + return datasetRate; + } else { + return ( + <DataBatchRate + datasetJobId={datasetJobId} + datasetJobStageId={latest_parent_dataset_job_stage_id} + /> + ); + } + }, + } as any), + { + title: '数据批次路径', + dataIndex: 'path', + key: 'path', + width: TABLE_COL_WIDTH.NORMAL, + render: (path: string) => ( + <Tooltip + content={ + <Text style={{ color: '#fff' }} copyable> + {path} + </Text> + } + > + <div className={styled.data_batch_table_path}>{path}</div> + </Tooltip> + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + key: 'updated_at', + width: TABLE_COL_WIDTH.TIME, + sorter(a: DataBatchV2, b: DataBatchV2) { + return a.updated_at - b.updated_at; + }, + defaultSortOrder: getSortOrder(urlState, 'updated_at'), + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + { + title: '操作', + dataIndex: 'state', + key: 'operation', + fixed: 'right', + width: TABLE_COL_WIDTH.OPERATION, + render: (state: DatasetStateFront, record: any) => ( + <DataBatchActions + kindLabel={kind_label} + data={record} + onDelete={listQuery.refetch} + onStop={listQuery.refetch} + onExport={onExport} + onRerun={onRerun} + /> + ), + }, + ].filter(Boolean); + }, [ + urlState, + listQuery.refetch, + datasetJobId, + isOldData, + datasetRate, + isDataJoin, + isCopy, + isInternalProcessed, + kind_label, + id, + ]); + + const pagination = useMemo(() => { + return pageTotal <= 0 + ? false + : { + ...paginationProps, + total, + }; + }, [paginationProps, pageTotal, total]); + + return ( + <> + <Table + className={'custom-table custom-table-left-side-filter'} + rowKey="id" + loading={listQuery.isFetching} + data={list} + scroll={{ x: '100%' }} + columns={columns} + pagination={pagination} + onChange={( + pagination, + sorter, + filters: Partial<Record<keyof DataBatchV2, any[]>>, + extra, + ) => { + switch (extra.action) { + case 'sort': { + let orderValue: string; + if (sorter.direction) { + orderValue = sorter.direction === 'ascend' ? 'asc' : 'desc'; + } + setUrlState((prevState) => ({ + ...prevState, + order_by: orderValue ? `${sorter.field} ${orderValue}` : '', + })); + break; + } + case 'filter': + setUrlState((prevState) => ({ + ...prevState, + filter: filterExpressionGenerator( + { + ...filters, + name: expression2Filter(urlState.filter).name, + }, + FILTER_DATA_BATCH_OPERATOR_MAPPER, + ), + page: 1, + })); + break; + default: + } + }} + /> + <ExportModal + id={id} + batchId={currentExportBatchId} + visible={isShowExportModal} + onCancel={onExportModalClose} + onSuccess={onExportSuccess} + /> + <DatasetBatchRerunModal + id={id} + batchId={currentRerunBatchId!} + batchName={currentRerunBatchName} + kind={kind} + visible={isShowRerunModal} + globalConfigs={globalConfigs} + onCancel={onRerunModalClose} + onSuccess={onRerunSuccess} + onFail={onRerunModalClose} + /> + </> + ); + function onExport(batchId: ID) { + setCurrentExportBatchId(batchId); + setIsShowExportModal(true); + } + function onExportSuccess(datasetId: ID, datasetJobId: ID) { + onExportModalClose(); + if (!datasetJobId && datasetJobId !== 0) { + Message.info('导出任务ID缺失,请手动跳转「任务管理」查看详情'); + } else { + history.push(`/datasets/${datasetId}/new/job_detail/${datasetJobId}`); + } + } + function onExportModalClose() { + setCurrentExportBatchId(undefined); + setIsShowExportModal(false); + } + + function onRerun(batchId: ID, batchName: string) { + setCurrentRerunBatchId(batchId); + setIsShowRerunModal(true); + setCurrentRerunBatchName(batchName); + } + + function onRerunModalClose() { + setCurrentRerunBatchId(undefined); + setIsShowRerunModal(false); + setCurrentRerunBatchName(''); + } + + function onRerunSuccess() { + onRerunModalClose(); + listQuery.refetch(); + } +}; + +export default DataBatchTable; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DatasetJobStageList/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetDetail/DatasetJobStageList/index.module.less new file mode 100644 index 000000000..6599740a7 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DatasetJobStageList/index.module.less @@ -0,0 +1,3 @@ +.table{ + margin: 12px 0px; +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/DatasetJobStageList/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/DatasetJobStageList/index.tsx new file mode 100644 index 000000000..7d5420689 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/DatasetJobStageList/index.tsx @@ -0,0 +1,205 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useTablePaginationWithUrlState, useUrlState, useGetCurrentProjectId } from 'hooks'; +import { fetchDatasetJobStageList } from 'services/dataset'; +import { TABLE_COL_WIDTH, TIME_INTERVAL } from 'shared/constants'; +import { formatTimestamp } from 'shared/date'; +import { + datasetJobStateFilters, + FILTER_DATA_BATCH_OPERATOR_MAPPER, + filterExpressionGenerator, + getSortOrder, + getJobKindByFilter, + getJobStateByFilter, +} from '../../shared'; +import { Table, Message } from '@arco-design/web-react'; +import StateIndicator from 'components/StateIndicator'; +import DatasetJobsType from 'components/DatasetJobsType'; +import { Link } from 'react-router-dom'; +import { getDatasetJobState } from 'shared/dataset'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { DatasetJobStage, DataJobBackEndType } from 'typings/dataset'; +import { expression2Filter } from 'shared/filter'; +import { LabelStrong } from 'styles/elements'; +import { JobDetailSubTabs } from 'views/Datasets/NewDatasetJobDetail'; +import styled from './index.module.less'; + +type TProps = { + datasetId: ID; + datasetJobId: ID; +}; +const DataBatchTable: FC<TProps> = function (props: TProps) { + const { datasetJobId, datasetId } = props; + const projectId = useGetCurrentProjectId(); + const { paginationProps } = useTablePaginationWithUrlState(); + const [total, setTotal] = useState(0); + const [pageTotal, setPageTotal] = useState(0); + + // store filter status into urlState + const [urlState, setUrlState] = useUrlState({ + filter: '', + order_by: '', + page: 1, + pageSize: 10, + }); + + // generator listQuery + const listQuery = useQuery( + ['fetchDatasetJobStageList', projectId, datasetJobId, urlState], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + const filter = expression2Filter(urlState.filter); + filter.state = getJobStateByFilter(filter.state); + filter.kind = getJobKindByFilter(filter.kind); + return fetchDatasetJobStageList(projectId!, datasetJobId, { + page: urlState.page, + page_size: urlState.pageSize, + order_by: urlState.order_by, + filter: filterExpressionGenerator(filter, FILTER_DATA_BATCH_OPERATOR_MAPPER), + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + refetchOnWindowFocus: false, + onSuccess: (res) => { + const { page_meta } = res || {}; + setTotal((pre) => page_meta?.total_items || pre); + setPageTotal(page_meta?.total_pages ?? 0); + }, + }, + ); + + // generator listData from listQuery and watch listQuery + const list = useMemo(() => { + return listQuery.data?.data || []; + }, [listQuery.data]); + + const columns = useMemo<ColumnProps<DatasetJobStage>[]>(() => { + return [ + { + title: '任务名称', + dataIndex: 'name', + key: 'name', + width: TABLE_COL_WIDTH.NAME, + ellipsis: true, + render: (name: string, record) => { + return ( + <Link + to={(location) => ({ + ...location, + pathname: `/datasets/${datasetId}/new/job_detail/${record.dataset_job_id}/${JobDetailSubTabs.TaskList}`, + search: location.search + ? `${location.search}&stageId=${record.id}` + : `?stageId=${record.id}`, + })} + > + {name} + </Link> + ); + }, + }, + { + title: '任务类型', + dataIndex: 'kind', + key: 'kind', + width: TABLE_COL_WIDTH.NORMAL, + // ...datasetJobTypeFilters, + // filteredValue: expression2Filter(urlState.filter).kind, + render: (type) => { + return <DatasetJobsType type={type as DataJobBackEndType} />; + }, + }, + { + title: '状态', + dataIndex: 'state', + key: 'state', + width: TABLE_COL_WIDTH.NORMAL, + ...datasetJobStateFilters, + filteredValue: expression2Filter(urlState.filter).state, + render: (_: any, record: DatasetJobStage) => { + return ( + <div className="indicator-with-tip"> + <StateIndicator {...getDatasetJobState(record)} /> + </div> + ); + }, + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: TABLE_COL_WIDTH.TIME, + sorter(a: DatasetJobStage, b: DatasetJobStage) { + return a.created_at - b.created_at; + }, + defaultSortOrder: getSortOrder(urlState, 'created_at'), + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + ]; + }, [urlState, datasetId]); + + const pagination = useMemo(() => { + return pageTotal <= 1 + ? false + : { + ...paginationProps, + total, + }; + }, [paginationProps, pageTotal, total]); + + return ( + <> + <LabelStrong fontSize={14} isBlock={true}> + 任务列表 + </LabelStrong> + <Table + className={`custom-table custom-table-left-side-filter ${styled.table}`} + rowKey="id" + loading={listQuery.isFetching} + data={list} + scroll={{ x: '100%' }} + columns={columns} + pagination={pagination} + onChange={( + pagination, + sorter, + filters: Partial<Record<keyof DatasetJobStage, any[]>>, + extra, + ) => { + switch (extra.action) { + case 'sort': { + let orderValue: string; + if (sorter.direction) { + orderValue = sorter.direction === 'ascend' ? 'asc' : 'desc'; + } + setUrlState((prevState) => ({ + ...prevState, + order_by: orderValue ? `${sorter.field} ${orderValue}` : '', + })); + break; + } + case 'filter': + setUrlState((prevState) => ({ + ...prevState, + filter: filterExpressionGenerator( + { + ...filters, + }, + FILTER_DATA_BATCH_OPERATOR_MAPPER, + ), + page: 1, + })); + break; + default: + } + }} + /> + </> + ); +}; + +export default DataBatchTable; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/ProcessedDatasetTable.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/ProcessedDatasetTable.tsx new file mode 100644 index 000000000..67ec7e6e6 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/ProcessedDatasetTable.tsx @@ -0,0 +1,102 @@ +import { Table } from '@arco-design/web-react'; +import React, { FC, useMemo } from 'react'; +import { useQuery } from 'react-query'; +import { fetchChildrenDatasetList } from 'services/dataset'; +import { formatTimestamp } from 'shared/date'; +import { DatasetJobListItem, DatasetKindLabel } from 'typings/dataset'; +import { TIME_INTERVAL } from 'shared/constants'; +import { useGetCurrentProjectId, useGetCurrentProjectParticipantList } from 'hooks'; +import { Link } from 'react-router-dom'; +import { DatasetDetailSubTabs } from '.'; +import ImportProgress from '../DatasetList/ImportProgress'; + +const getTableColumns = (allParticipantName: string) => { + return [ + { + title: '名称', + dataIndex: 'name', + name: 'name', + render: (id: string, record: DatasetJobListItem) => { + return ( + <Link + to={`/datasets/${DatasetKindLabel.PROCESSED}/detail/${record.id}/${DatasetDetailSubTabs.DatasetJobDetail}`} + > + {record.name} + </Link> + ); + }, + }, + { + title: '数据集状态', + dataIndex: 'state_frontend', + width: 180, + render: (_: any, record: any) => { + return <ImportProgress dataset={record} />; + }, + }, + { + title: '参与方', + dataIndex: '__participant_name__', + name: '__participant_name__', + render: () => allParticipantName, + }, + { + title: '创建时间', + dataIndex: 'created_at', + name: 'created_at', + sorter(a: DatasetJobListItem, b: DatasetJobListItem) { + return a.created_at - b.created_at; + }, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + ]; +}; + +type Props = { + datasetId: ID; +}; + +const ProcessedDatasetTable: FC<Props> = ({ datasetId }) => { + const projectId = useGetCurrentProjectId(); + const participantList = useGetCurrentProjectParticipantList(); + + const listQuery = useQuery( + ['fetchChildrenDatasetList', datasetId], + () => { + return fetchChildrenDatasetList(datasetId!); + }, + { + enabled: Boolean(projectId && datasetId), + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + }, + ); + + const filteredList = useMemo(() => { + if (!listQuery.data) { + return []; + } + const list = listQuery.data.data || []; + + // desc sort + list.sort((a, b) => b.created_at - a.created_at); + + return list; + }, [listQuery.data]); + + const allParticipantName = useMemo(() => { + return participantList.map((item) => item.name).join('\n'); + }, [participantList]); + + return ( + <Table + loading={listQuery.isFetching} + data={filteredList || []} + scroll={{ x: '100%' }} + columns={getTableColumns(allParticipantName)} + rowKey="uuid" + /> + ); +}; + +export default ProcessedDatasetTable; diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetDetail/index.module.less new file mode 100644 index 000000000..50db3e14f --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/index.module.less @@ -0,0 +1,79 @@ +@import '~styles/mixins.less'; + +.dataset_detail_padding_box{ + padding: 20px; + padding-bottom: 0; + :global(.arco-spin) { + width: 100%; + } +} + +.dataset_detail_box{ + padding: 0px; + flex: 1; +} + +.dataset_detail_batch_box{ + padding-bottom: 20px; +} + +.dataset_detail_avatar{ + .MixinSquare(44px); + background-color: var(--primaryColor); + color: white; + border-radius: 2px; + font-size: 18px; + text-align: center; + + &::before { + content: attr(data-name); + line-height: 44px; + font-weight: bold; + } +} + +.dataset_name_container{ + display: flex; + align-items: center; +} + +.dataset_name{ + .MixinEllipsis('40vw'); + display: inline-block; + margin-bottom: 0; + margin-right: 7px; + font-size: 16px; + height: 24px; + font-weight: 600; +} + +.comment{ + font-size: 12px; + line-height: 18px; + color: var(--textColorSecondary); +} + +.data_source_text{ + .MixinEllipsis(); +} + +.data_detail_tab_pane{ + display: grid; +} + +.data_detail_tab{ + margin-bottom: 0 !important; +} + +.data_detail_icon_question_circle{ + margin: 0 4px; + color: var(--headerBackground); +} + +.dataset_detal_auth_status{ + width: 90px; +} + +.dataset_detail_cron{ + cursor: pointer; +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetDetail/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetDetail/index.tsx new file mode 100644 index 000000000..8e61524fe --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetDetail/index.tsx @@ -0,0 +1,712 @@ +import { Button, Grid, Message, Space, Spin, Tabs, Tooltip, Tag } from '@arco-design/web-react'; +import BackButton from 'components/BackButton'; +import PictureDataPreviewTable from 'components/DataPreview/PictureDataTable'; +import PropertyList from 'components/PropertyList'; +import SharedPageLayout from 'components/SharedPageLayout'; +import GridRow from 'components/_base/GridRow'; +import { isNil } from 'lodash-es'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { Redirect, Route, useHistory, useParams } from 'react-router'; +import { useToggle } from 'react-use'; +import { + deleteDataset, + fetchDatasetDetail, + fetchDatasetPreviewData, + fetchDataBatchs, + fetchDatasetJobDetail, + fetchDatasetFlushAuthStatus, + authorizeDataset, + cancelAuthorizeDataset, + stopDatasetStreaming, +} from 'services/dataset'; +import { + getImportStage, + getTotalDataSize, + isFrontendDeleting, + isFrontendProcessing, + isFrontendSucceeded, +} from 'shared/dataset'; +import { formatTimestamp } from 'shared/date'; +import { humanFileSize } from 'shared/file'; +import { + Dataset, + DatasetDataType, + DatasetKindLabel, + DATASET_COPY_CHECKER, + DatasetType__archived, + DatasetProcessedAuthStatus, + ParticipantInfo, + DatasetProcessedMyAuthStatus, + DatasetKindBackEndType, + DatasetJobSchedulerState, +} from 'typings/dataset'; +import { datasetPageTitles, isDataJoin, RawAuthStatusOptions, isHoursCronJoin } from '../shared'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import { CONSTANTS, TIME_INTERVAL } from 'shared/constants'; +import CodeEditorDrawer from 'components/CodeEditorDrawer'; +import TaskDetail, { NodeType } from '../TaskDetail'; +import ProcessedDatasetTable from './ProcessedDatasetTable'; +import DatasetJobStageList from './DatasetJobStageList'; +import ClickToCopy from 'components/ClickToCopy'; +import { + IconCheckCircleFill, + IconCloseCircleFill, + IconQuestionCircle, +} from '@arco-design/web-react/icon'; +import MoreActions from 'components/MoreActions'; +import Modal from 'components/Modal'; +import DatasetEditModal from '../DatasetList/DatasetEditModal'; +import DatasetPublishAndRevokeModal from 'components/DatasetPublishAndRevokeModal'; +import BlockchainStorageTable from 'components/BlockchainStorageTable'; +import StatusProgress from 'components/StatusProgress'; +import DataBatchTable from './DataBatchTable/index'; +import DataBatchAnalyze from './DataBatchAnalyze/index'; +import { useGetAppFlagValue, useGetCurrentProjectId } from 'hooks'; +import { FlagKey } from 'typings/flag'; +import ImportProgress from '../DatasetList/ImportProgress/index'; +import { getIntersectionRate } from 'shared/dataset'; +import { fetchSysInfo } from 'services/settings'; +import { to } from 'shared/helpers'; +import styled from './index.module.less'; + +const { Row } = Grid; + +const { TabPane } = Tabs; + +export enum DatasetDetailSubTabs { + PreviewData = 'preview', + Schema = 'schema', + Image = 'image', + RelativeDataset = 'relative_dataset', + Databatch = 'data_batch', + DatasetJobDetail = 'dataset_job_detail', + BlockchainStorage = 'blockchain_storage', +} + +const DatasetDetail: FC<any> = () => { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + + const { kind_label, id, subtab } = useParams<{ + kind_label: DatasetKindLabel; + id: string; + subtab: string; + }>(); + const isProcessedDataset = kind_label === DatasetKindLabel.PROCESSED; + const isRaw = kind_label === DatasetKindLabel.RAW; + const [activeTab, setActiveTab] = useState(subtab || DatasetDetailSubTabs.PreviewData); + const [isShowPublishModal, setIsShowPublishModal] = useState(false); + const [selectDataset, setSelectDataset] = useState<Dataset>(); + const [editModalVisible, toggleEditModalVisible] = useToggle(false); + const bcs_support_enabled = useGetAppFlagValue(FlagKey.BCS_SUPPORT_ENABLED); + + const sysInfoQuery = useQuery(['fetchSysInfo'], () => fetchSysInfo(), { + retry: 2, + refetchOnWindowFocus: false, + enabled: Boolean(isProcessedDataset), + }); + + const myPureDomainName = useMemo<string>(() => { + return sysInfoQuery.data?.data?.pure_domain_name ?? ''; + }, [sysInfoQuery.data]); + + // 授权兜底策略, 前端刷新下接口 + useQuery(['fetchDatasetFlushAuthStatus', id], () => fetchDatasetFlushAuthStatus(id), { + refetchOnWindowFocus: false, + enabled: Boolean(isProcessedDataset), + onSuccess() { + query.refetch(); + }, + }); + + // ======= Dataset query ============ + const query = useQuery(['fetchDatasetDetail', id], () => fetchDatasetDetail(id), { + refetchOnWindowFocus: false, + refetchInterval: TIME_INTERVAL.CONNECTION_CHECK, + }); + + const datasetJobQuery = useQuery( + ['fetchDatasetJobDetail', projectId, query.data?.data.parent_dataset_job_id], + () => fetchDatasetJobDetail(projectId!, query.data?.data.parent_dataset_job_id!), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: Boolean(projectId && query.data?.data.parent_dataset_job_id), + }, + ); + // ======= Preivew data query ============ + const previewDataQuery = useQuery( + ['fetchDatasetPreviewData', id], + () => fetchDatasetPreviewData(id), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: + Boolean(id) && [DatasetDetailSubTabs.Image].includes(activeTab as DatasetDetailSubTabs), + }, + ); + + const batchListQuery = useQuery( + ['fetchDataBatchs', id], + () => { + return fetchDataBatchs(id!); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + }, + ); + + const isAnalyzeSuccess = useMemo(() => { + const data = batchListQuery.data?.data || []; + return data.some((item) => item.latest_analyzer_dataset_job_stage_id !== 0); + }, [batchListQuery.data]); + const stateInfo = useMemo<{ + text: string; + type: StateTypes; + noResultText: string; + }>(() => { + if (query.data?.data) { + const { text, type } = getImportStage(query.data.data); + let noResultText = ''; + if (type === 'processing') { + noResultText = '数据处理中,请稍后'; + } + return { text, type, noResultText }; + } + + return { + text: '状态未知', + type: 'default', + noResultText: '抱歉,数据暂时无法显示', + }; + }, [query]); + + useEffect(() => { + setActiveTab(subtab || DatasetDetailSubTabs.PreviewData); + }, [subtab]); + + const datasetJobGlobalConfigs = useMemo(() => { + if (!datasetJobQuery.data?.data?.global_configs?.global_configs) { + return {}; + } + return datasetJobQuery.data?.data?.global_configs?.global_configs; + }, [datasetJobQuery]); + + const dataset = query.data?.data; + + const { + dataset_format, + dataset_kind, + updated_at, + num_feature, + num_example, + path, + is_published, + import_type, + dataset_type, + analyzer_dataset_job_id, + parent_dataset_job_id, + auth_frontend_state, + local_auth_status, + participants_info = { participants_map: {} }, + } = dataset ?? {}; + + const datasetJob = datasetJobQuery.data?.data; + const { + has_stages, + input_data_batch_num_example = 0, + output_data_batch_num_example = 0, + kind, + scheduler_state, + scheduler_message, + } = datasetJob ?? {}; + const isOldData = !Boolean(has_stages); + const isJoin = isDataJoin(kind); + const datasetRate = getIntersectionRate({ + input: input_data_batch_num_example, + output: output_data_batch_num_example, + }); + const isDatasetStructType = dataset_format === DatasetDataType.STRUCT; + const isDatasetPictureType = dataset_format === DatasetDataType.PICTURE; + const isDatasetNoneStructType = dataset_format === DatasetDataType.NONE_STRUCTURED; + const isCopy = !import_type || import_type === DATASET_COPY_CHECKER.COPY; + const isStreaming = dataset_type === DatasetType__archived.STREAMING; + const isStreamRunable = scheduler_state === DatasetJobSchedulerState.RUNNABLE; + const isStreamStopped = scheduler_state === DatasetJobSchedulerState.STOPPED; + const isAuthorized = local_auth_status === DatasetProcessedMyAuthStatus.AUTHORIZED; + const isInternalProcessed = dataset_kind === DatasetKindBackEndType.INTERNAL_PROCESSED; + const isHideAuth = Boolean(Object.keys(participants_info.participants_map).length === 0); + /** IF no subtab be set, defaults to preview */ + if (!subtab) { + return ( + <Redirect to={`/datasets/${kind_label}/detail/${id}/${DatasetDetailSubTabs.PreviewData}`} /> + ); + } + if (isInternalProcessed && subtab === DatasetDetailSubTabs.DatasetJobDetail) { + return ( + <Redirect to={`/datasets/${kind_label}/detail/${id}/${DatasetDetailSubTabs.Databatch}`} /> + ); + } + const isProcessing = dataset ? isFrontendProcessing(dataset) : false; + const isDeleting = dataset ? isFrontendDeleting(dataset) : false; + + const displayedProps = [ + { + value: isNil(dataset_format) + ? CONSTANTS.EMPTY_PLACEHOLDER + : isDatasetStructType + ? '结构化数据' + : isDatasetPictureType + ? '图片' + : '非结构化数据', + label: '数据格式', + proport: 0.5, + }, + { + value: isInternalProcessed ? '-' : dataset ? humanFileSize(getTotalDataSize(dataset)) : '0 B', + label: '数据大小', + proport: 0.5, + }, + { + value: !isCopy || isInternalProcessed ? '-' : num_feature?.toLocaleString('en') || '0', + label: '总列数', + proport: 0.5, + }, + { + value: !isCopy || isInternalProcessed ? '-' : num_example?.toLocaleString('en') || '0', + label: '总行数', + proport: 0.5, + }, + isStreaming && { + value: isStreamStopped ? ( + <StateIndicator type="error" text="已停止" tag={false} /> + ) : ( + <span className={styled.dataset_detail_cron}> + {isHoursCronJoin(datasetJob) ? '每小时' : '每天'} + <Tooltip content={scheduler_message}> + <IconQuestionCircle className={styled.data_detail_icon_question_circle} /> + </Tooltip> + </span> + ), + label: isRaw ? '导入周期' : '求交周期', + proport: 0.5, + }, + { + value: ( + <ClickToCopy text={path || ''}> + <Tooltip content={path}> + <div className={styled.data_source_text}>{path || CONSTANTS.EMPTY_PLACEHOLDER}</div> + </Tooltip> + </ClickToCopy> + ), + label: '数据集路径', + proport: 2, + }, + { + value: updated_at ? formatTimestamp(updated_at) : CONSTANTS.EMPTY_PLACEHOLDER, + label: '最近更新', + proport: 1, + }, + dataset?.validation_jsonschema && + Object.keys(dataset.validation_jsonschema).length > 0 && + ({ + value: ( + <CodeEditorDrawer.Button + title="校验规则" + value={JSON.stringify(dataset?.validation_jsonschema, null, 2)} + /> + ), + label: '校验规则', + proport: 0.5, + } as any), + ].filter(Boolean); + + return ( + <SharedPageLayout + title={<BackButton onClick={backToList}>{datasetPageTitles[kind_label]}</BackButton>} + cardPadding={0} + > + <div className={styled.dataset_detail_padding_box}> + <Spin loading={query.isFetching || datasetJobQuery.isFetching}> + <Row align="center" justify="space-between"> + <GridRow gap="12" style={{ maxWidth: '75%' }}> + <div + className={styled.dataset_detail_avatar} + data-name={query.data?.data.name.slice(0, 2)} + /> + <div> + <div className={styled.dataset_name_container}> + <h3 className={styled.dataset_name}>{query.data?.data.name ?? '....'}</h3> + {query.data && <ImportProgress dataset={query.data.data} tag={false} />} + </div> + {(isStreaming || !isCopy || query.data?.data.comment) && ( + <Space> + {isStreaming && <Tag color="blue">增量</Tag>} + {!isCopy && <Tag>{'非拷贝'}</Tag>} + {query.data?.data.comment && ( + <small className={styled.comment}>{query.data?.data.comment}</small> + )} + </Space> + )} + </div> + </GridRow> + + <Space> + {!isProcessedDataset && ( + <Space> + {is_published ? ( + <IconCheckCircleFill style={{ color: 'var(--successColor)' }} /> + ) : ( + <IconCloseCircleFill style={{ color: 'var(--warningColor)' }} /> + )} + <span>{is_published ? '已发布至工作区' : '未发布至工作区'}</span> + <Button + type={is_published ? 'default' : 'primary'} + onClick={onPublishClick} + disabled={dataset ? !isFrontendSucceeded(dataset) : true} + > + {is_published ? '撤销发布' : '发布'} + </Button> + </Space> + )} + {isProcessedDataset && isInternalProcessed && renderAuthStatus()} + {isProcessedDataset && !isHideAuth && ( + <Button type={isAuthorized ? 'default' : 'primary'} onClick={onAuthClick}> + {isAuthorized ? '撤销授权' : '授权'} + </Button> + )} + {isStreaming && isStreamRunable && ( + <Button type="default" onClick={onStopStreaming}> + {isProcessedDataset ? '终止定时求交' : '终止增量导入'} + </Button> + )} + <MoreActions + actionList={[ + { + label: '编辑', + onClick: onEditClick, + disabled: !dataset || isProcessing || isDeleting, + }, + { + label: '删除', + onClick: onDeleteClick, + danger: true, + disabled: !dataset || isProcessing || isDeleting, + }, + ]} + /> + </Space> + </Row> + </Spin> + <PropertyList + properties={displayedProps} + cols={displayedProps.length} + minWidth={150} + align="center" + colProportions={displayedProps.map((item) => item.proport)} + /> + </div> + <Tabs activeTab={activeTab} onChange={onSubtabChange} className={styled.data_detail_tab}> + {!isInternalProcessed && ( + <TabPane + className={styled.data_detail_tab_pane} + title="任务详情" + key={DatasetDetailSubTabs.DatasetJobDetail} + /> + )} + {isDatasetPictureType && isAnalyzeSuccess && ( + <TabPane + className={styled.data_detail_tab_pane} + title="图片预览" + key={DatasetDetailSubTabs.Image} + /> + )} + <TabPane + className={styled.data_detail_tab_pane} + title="数据批次" + key={DatasetDetailSubTabs.Databatch} + /> + {isCopy && !isDatasetNoneStructType && ( + <TabPane + className={styled.data_detail_tab_pane} + title="数据探查" + key={DatasetDetailSubTabs.PreviewData} + /> + )} + <TabPane + className={styled.data_detail_tab_pane} + title={ + <span> + 下游数据集 + <Tooltip content="通过使用本数据集所产生的数据集"> + <IconQuestionCircle className={styled.data_detail_icon_question_circle} /> + </Tooltip> + </span> + } + key={DatasetDetailSubTabs.RelativeDataset} + /> + {isRaw && bcs_support_enabled && ( + <TabPane + className={styled.data_detail_tab_pane} + title="区块链存证" + key={DatasetDetailSubTabs.BlockchainStorage} + /> + )} + </Tabs> + <div + className={`${styled.dataset_detail_padding_box} ${ + activeTab === DatasetDetailSubTabs.PreviewData ? styled.dataset_detail_box : '' + } ${activeTab === DatasetDetailSubTabs.Databatch ? styled.dataset_detail_batch_box : ''}`} + > + <Route + path={`/datasets/:kind_label/detail/:id/${DatasetDetailSubTabs.Databatch}`} + exact + render={() => { + return ( + <DataBatchTable + datasetJobId={parent_dataset_job_id!} + isOldData={isOldData} + isDataJoin={isJoin} + isCopy={isCopy} + datasetRate={datasetRate} + kind={kind!} + isInternalProcessed={isInternalProcessed} + globalConfigs={datasetJobGlobalConfigs} + /> + ); + }} + /> + {isCopy && ( + <Route + path={`/datasets/:kind_label/detail/:id/${DatasetDetailSubTabs.PreviewData}`} + exact + render={(props) => { + return ( + <DataBatchAnalyze + {...props} + datasetJobId={analyzer_dataset_job_id!} + isOldData={isOldData} + onAnalyzeBatch={() => { + query.refetch(); + }} + /> + ); + }} + /> + )} + {isDatasetPictureType && isAnalyzeSuccess && ( + <Route + path={`/datasets/:kind_label/detail/:id/${DatasetDetailSubTabs.Image}`} + exact + render={(props) => { + return ( + <PictureDataPreviewTable + data={previewDataQuery.data?.data} + loading={previewDataQuery.isFetching} + isError={previewDataQuery.isError} + noResultText={stateInfo.noResultText} + /> + ); + }} + /> + )} + + <Route + path={`/datasets/:kind_label/detail/:id/${DatasetDetailSubTabs.DatasetJobDetail}`} + exact + render={() => { + return ( + <> + <TaskDetail + middleJump={true} + datasetId={id} + datasetJobId={dataset?.parent_dataset_job_id} + isShowRatio={false} + isOldData={isOldData} + onNodeClick={(node) => { + if (node.type === NodeType.DATASET_PROCESSED) { + onSubtabChange(DatasetDetailSubTabs.PreviewData); + } + }} + // TODO: pass error message when state_frontend = DatasetStateFront.FAILED, + errorMessage="" + isProcessedDataset={isProcessedDataset} + /> + {(parent_dataset_job_id || parent_dataset_job_id === 0) && ( + <DatasetJobStageList datasetId={id} datasetJobId={parent_dataset_job_id!} /> + )} + </> + ); + }} + /> + <Route + path={`/datasets/:kind_label/detail/:id/${DatasetDetailSubTabs.RelativeDataset}`} + exact + render={() => { + return <ProcessedDatasetTable datasetId={id} />; + }} + /> + <Route + path={`/datasets/:kind_label/detail/:id/${DatasetDetailSubTabs.BlockchainStorage}`} + exact + render={() => { + return <BlockchainStorageTable datasetId={id} />; + }} + /> + </div> + {dataset && ( + <DatasetEditModal + dataset={dataset} + visible={editModalVisible} + toggleVisible={toggleEditModalVisible} + onSuccess={onEditSuccess} + /> + )} + + <DatasetPublishAndRevokeModal + onCancel={onPublishCancel} + onSuccess={onPublishSuccess} + dataset={selectDataset} + visible={isShowPublishModal} + /> + </SharedPageLayout> + ); + + function renderParticipantAuth(val: ParticipantInfo[]) { + return ( + <> + {val.map((participant, index) => ( + <div key={index}> + {participant.name}{' '} + {participant.auth_status === DatasetProcessedMyAuthStatus.AUTHORIZED + ? '已授权' + : '未授权'} + </div> + ))} + </> + ); + } + + function renderAuthStatus() { + if (auth_frontend_state === DatasetProcessedAuthStatus.AUTH_PENDING) { + const participants_info_map = Object.entries(participants_info.participants_map || {}).map( + ([key, value]) => ({ + name: key === myPureDomainName ? '我方' : key, + auth_status: value['auth_status'], + }), + ); + return ( + <StatusProgress + className={styled.dataset_detal_auth_status} + options={RawAuthStatusOptions} + status={auth_frontend_state || DatasetProcessedAuthStatus.AUTH_PENDING} + isTip={true} + toolTipContent={renderParticipantAuth(participants_info_map)} + /> + ); + } + return ( + <StatusProgress + className={styled.dataset_detal_auth_status} + options={RawAuthStatusOptions} + status={auth_frontend_state || DatasetProcessedAuthStatus.TICKET_PENDING} + /> + ); + } + + function onPublishSuccess() { + setIsShowPublishModal(false); + query.refetch(); + } + function onPublishCancel() { + setIsShowPublishModal(false); + } + + function backToList() { + history.goBack(); + } + + function onPublishClick() { + if (!dataset) { + return; + } + setSelectDataset(dataset); + setIsShowPublishModal(true); + } + async function onAuthClick() { + try { + if (isAuthorized) { + await cancelAuthorizeDataset(id); + } else { + await authorizeDataset(id); + } + query.refetch(); + } catch (err: any) { + Message.error(err.message); + } + } + + function onStopStreaming() { + Modal.delete({ + title: `确定终止${isProcessedDataset ? '定时求交' : '增量导入'}`, + content: '终止后将无法被重启,请谨慎操作', + okText: '终止', + onOk: async () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + if (!parent_dataset_job_id) { + return; + } + const [, err] = await to(stopDatasetStreaming(projectId, parent_dataset_job_id)); + if (err) { + Message.error(err.message || '终止失败'); + return; + } + Message.success('终止成功'); + datasetJobQuery.refetch(); + }, + }); + } + function onEditClick() { + toggleEditModalVisible(true); + } + function onEditSuccess() { + query.refetch(); + } + function onDeleteClick() { + Modal.delete({ + title: '确认删除数据集?', + content: '删除操作无法恢复,请谨慎操作', + onOk: async () => { + if (!dataset) { + return; + } + try { + const resp = await deleteDataset(dataset.id); + // If delete success, HTTP response status code is 204, resp is empty string + const isDeleteSuccess = !resp; + if (isDeleteSuccess) { + Message.success('删除成功'); + history.replace(`/datasets/${kind_label}`); + } else { + const errorMessage = resp?.message ?? '删除失败'; + Message.error(errorMessage!); + } + } catch (error) { + Message.error(error.message); + } + }, + }); + } + + function onSubtabChange(val: string) { + setActiveTab(val as DatasetDetailSubTabs); + history.replace(`/datasets/${kind_label}/detail/${id}/${val}`); + } +}; + +export default DatasetDetail; diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobBasicInfo/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobBasicInfo/index.tsx new file mode 100644 index 000000000..e36ebbe3d --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobBasicInfo/index.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import { formatTimeCount, formatTimestamp } from 'shared/date'; +import PropertyList from 'components/PropertyList'; +import WhichParticipant from 'components/WhichParticipant'; +import { DatasetJobState } from 'typings/dataset'; +import dayjs from 'dayjs'; +import CountTime from 'components/CountTime'; + +type TJobBasicInfo = { + coordinatorId: ID; + createTime: DateTime; + startTime: DateTime; + finishTime: DateTime; + jobState: DatasetJobState; +}; + +export default function JobBasicInfo(prop: TJobBasicInfo) { + const { coordinatorId, createTime = 0, startTime = 0, finishTime = 0, jobState } = prop; + const isRunning = [DatasetJobState.PENDING, DatasetJobState.RUNNING].includes(jobState); + const basicInfo = useMemo(() => { + function TimeRender(prop: { time: DateTime }) { + const { time } = prop; + return <span>{time <= 0 ? '-' : formatTimestamp(time)}</span>; + } + function RunningTimeRender(prop: { start: DateTime; finish: DateTime; isRunning: boolean }) { + const { start, finish, isRunning } = prop; + if (isRunning) { + return start <= 0 ? ( + <span>待运行</span> + ) : ( + <CountTime time={dayjs().unix() - start} isStatic={false} /> + ); + } + return <span>{finish - start <= 0 ? '-' : formatTimeCount(finish - start)}</span>; + } + return [ + { + label: '任务发起方', + value: coordinatorId === 0 ? '本方' : <WhichParticipant id={coordinatorId} />, + }, + { + label: '创建时间', + value: <TimeRender time={createTime} />, + }, + { + label: '开始时间', + value: <TimeRender time={startTime} />, + }, + { + label: '结束时间', + value: <TimeRender time={finishTime} />, + }, + { + label: '运行时长', + value: <RunningTimeRender start={startTime} finish={finishTime} isRunning={isRunning} />, + }, + ]; + }, [coordinatorId, createTime, startTime, finishTime, isRunning]); + return <PropertyList properties={basicInfo} cols={5} />; +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobParamsPanel/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobParamsPanel/index.module.less new file mode 100644 index 000000000..deab4d6c0 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobParamsPanel/index.module.less @@ -0,0 +1,39 @@ +.container{ + margin: 10px 0; + padding: 20px 20px 10px 20px; + border-radius: 2px; + background-color: rgb(var(--gray-1)); + width: 240px; + height: 313px; + overflow: auto; +} +.params_header{ + display: flex; + flex-direction: row; + justify-content: space-between; +} +.params_label{ + font-weight: 400; + font-size: 12px; + line-height: 18px; + color: #4e5969; + display: inline-block; + height: 18px; +} +.params_body{ + width: 100%; + border-bottom: 1px solid #e5e6eb; + margin: 8px 0; +} +.params_list_item{ + list-style: none; + display: flex; + margin-bottom: 8px; + width: 100%; +} +.styled_param_span{ + max-width: 100px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobParamsPanel/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobParamsPanel/index.tsx new file mode 100644 index 000000000..3d77f2cda --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobParamsPanel/index.tsx @@ -0,0 +1,114 @@ +import React, { FC, useMemo, useState } from 'react'; +import { Button, Dropdown, Menu, Tooltip } from '@arco-design/web-react'; +import { LabelStrong } from 'styles/elements'; +import { IconDown } from '@arco-design/web-react/icon'; +import { GlobalConfigs } from 'typings/dataset'; +import ClickToCopy from 'components/ClickToCopy'; +import { Tag } from 'typings/workflow'; +import styled from './index.module.less'; + +type TProps = { + globalConfigs: GlobalConfigs; +}; + +const JobParamsPanel: FC<TProps> = (props: TProps) => { + const { globalConfigs = {} } = props; + const [selected, setSelected] = useState(''); + + const selectRole = useMemo(() => { + if (!selected && globalConfigs && Object.keys(globalConfigs).length) { + return Object.keys(globalConfigs)[0]; + } + return selected; + }, [globalConfigs, selected]); + + const resourceAllocations = useMemo(() => { + if (!globalConfigs || !selectRole) { + return []; + } + return globalConfigs[selectRole].variables.filter( + (variable) => variable.tag === Tag.RESOURCE_ALLOCATION, + ); + }, [globalConfigs, selectRole]); + + const inputParams = useMemo(() => { + if (!globalConfigs || !selectRole) { + return []; + } + return globalConfigs[selectRole].variables.filter( + (variable) => variable.tag === Tag.INPUT_PARAM, + ); + }, [globalConfigs, selectRole]); + + const dropList = () => { + return ( + <Menu onClickMenuItem={(key) => setSelected(key)}> + {Object.keys(globalConfigs).map((item) => { + return <Menu.Item key={item}>{item}</Menu.Item>; + })} + </Menu> + ); + }; + return ( + <div> + <div className={styled.params_header}> + <LabelStrong fontSize={14} isBlock={true}> + 任务配置 + </LabelStrong> + <Dropdown droplist={dropList()} position="bl"> + <Button size={'mini'} type="text"> + {selectRole} <IconDown /> + </Button> + </Dropdown> + </div> + <div className={styled.container}> + <span className={styled.params_label}>资源配置</span> + <div className={styled.params_body}> + {resourceAllocations.map((item) => { + return <ParamListItemRender value={item} justifyContent={'space-between'} />; + })} + </div> + <span className={styled.params_label}>输入参数</span> + <div className={styled.params_body}> + {inputParams.map((item) => { + return <ParamListItemRender value={item} justifyContent={'start'} joiner={true} />; + })} + </div> + </div> + </div> + ); +}; + +function ParamListItemRender({ + value = {} as any, + justifyContent = 'space-between', + joiner = false, +}) { + return ( + <li style={{ justifyContent: justifyContent }} key={value.name}> + <span className={styled.styled_param_span}> + { + <Tooltip + position="left" + content={<ClickToCopy text={value.name}>{value.name}</ClickToCopy>} + > + <span>{value.name}</span> + </Tooltip> + } + </span> + {joiner ? <span> = </span> : ''} + <span className={styled.styled_param_span}> + { + <Tooltip + position="left" + content={<ClickToCopy text={value.value}>{value.value}</ClickToCopy>} + > + <span>{value.value}</span> + </Tooltip> + } + </span> + </li> + ); +} + +export default JobParamsPanel; diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobTitle/index.less b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobTitle/index.less new file mode 100644 index 000000000..4a984e7de --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobTitle/index.less @@ -0,0 +1,14 @@ +.job-title-icon{ + height: 44px; + width: 44px; + border-radius: 4px; + background: #686a72; + display: flex; + justify-content: center; + align-items: center; + font-size: 16px; + color: #ffffff; +} +.job-title-name{ + margin: 0 12px 0 12px; +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobTitle/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobTitle/index.tsx new file mode 100644 index 000000000..1d1ee08ed --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/JobTitle/index.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { DatasetJob, DatasetJobListItem } from 'typings/dataset'; +import StateIndicator from 'components/StateIndicator'; +import GridRow from 'components/_base/GridRow'; +import { getDatasetJobState, getDatasetJobType } from 'shared/dataset'; +import TaskActions from '../../TaskList/TaskActions'; +import { Space } from '@arco-design/web-react'; +import './index.less'; + +type TJobTitleProp = { + data: DatasetJob; + onStop?: () => void; + onDelete?: () => void; + id: ID; +}; + +export default function JobTitle(prop: TJobTitleProp) { + const { data = {} as DatasetJobListItem, onStop, onDelete, id } = prop; + const jobData: DatasetJobListItem = { + name: '', + uuid: '', + project_id: data.project_id, + kind: data.kind, + state: data.state, + result_dataset_id: '', + result_dataset_name: '', + id, + created_at: 0, + coordinator_id: 0, + has_stages: false, + }; + return ( + <GridRow + justify={'space-between'} + style={{ + marginBottom: 0, + }} + > + <Space> + <span className="job-title-icon">{getDatasetJobType(jobData.kind)}</span> + <span className="job-title-name">{data.name}</span> + <StateIndicator {...getDatasetJobState(jobData)} /> + </Space> + <TaskActions data={jobData} onDelete={onDelete} onStop={onStop} /> + </GridRow> + ); +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/WorkFlowPods/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/WorkFlowPods/index.module.less new file mode 100644 index 000000000..a6fde20b5 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/WorkFlowPods/index.module.less @@ -0,0 +1,60 @@ +.workflow_pods_header{ + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + :global(.arco-radio-button) { + &:after { + background-color: unset; + } + } +} + +.job_detail_more_title{ + color: #4e5969; + font-size: 12px; + +} +.job_detail_more_content{ + color: #1d2129; + font-size: 12px; +} + +.job_detail_more_link{ + display: inline-block; + font-weight: 400; + font-size: 12px; + line-height: 20px; + margin-bottom: 12px; +} + +.job_detail_more_button{ + margin-left: auto; + font-size: 12px; +} + +.job_detail_state_indicator{ + padding-left: 16px; + position: relative; + .dot { + display: inline-block; + position: absolute; + top: 4px; + left: 2px; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: rgb(245, 63, 63); + } +} + +.job_detail_error_wrapper{ + max-height: 300px; + overflow: scroll; + .job_detail_error_title{ + color: #fff; + } + .job_detail_error_item{ + margin-bottom: 10px; + } +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/WorkFlowPods/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/WorkFlowPods/index.tsx new file mode 100644 index 000000000..a1c91863b --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/WorkFlowPods/index.tsx @@ -0,0 +1,218 @@ +import React, { useMemo, useState } from 'react'; +import { LabelStrong } from 'styles/elements'; +import { Button, Message, Popover, Radio, Tooltip } from '@arco-design/web-react'; +import { useQuery } from 'react-query'; +import { useGetCurrentProjectId, useTablePaginationWithUrlState } from 'hooks'; +import { fetchJobById, getWorkflowDetailById } from 'services/workflow'; +import { TIME_INTERVAL } from 'shared/constants'; +import { get } from 'lodash-es'; +import { Pod, PodState } from 'typings/job'; +import { Table } from '@arco-design/web-react'; +import ClickToCopy from 'components/ClickToCopy'; +import StateIndicator from 'components/StateIndicator'; +import { getPodState, podStateFilters } from 'views/Workflows/shared'; +import { formatTimestamp } from 'shared/date'; +import { Link } from 'react-router-dom'; +import styled from './index.module.less'; +type TWorkFlowPods = { + workFlowId?: ID; +}; + +export default function WorkFlowPods(prop: TWorkFlowPods) { + const { workFlowId } = prop; + const { paginationProps, reset } = useTablePaginationWithUrlState({ + urlStateOption: { navigateMode: 'replace' }, + }); + const projectId = useGetCurrentProjectId(); + const [selectJobId, setSelectJobId] = useState<ID>(); + const workFlowDetail = useQuery( + ['fetch_workflow_detail', projectId, workFlowId], + () => { + if (!workFlowId) { + return Promise.resolve({ data: {} }); + } + if (!projectId) { + Message.info('请选择工作区'); + return Promise.resolve({ data: {} }); + } + return getWorkflowDetailById(workFlowId!, projectId); + }, + { + cacheTime: 1, + refetchInterval: TIME_INTERVAL.CONNECTION_CHECK, + onSuccess: (data) => { + const jobIds = get(data, 'data.job_ids') || []; + if (jobIds.length) { + setSelectJobId((pre) => { + return jobIds.indexOf(selectJobId) > -1 ? pre : jobIds[0]; + }); + } + }, + }, + ); + + const jobDetail = useQuery( + ['fetchJobById', selectJobId], + () => fetchJobById(Number(selectJobId)), + { + enabled: Boolean(selectJobId), + }, + ); + + const jobList = useMemo(() => { + if (!workFlowDetail.data) { + return []; + } + const jobIds = get(workFlowDetail.data, 'data.job_ids') || []; + const jobNames = get(workFlowDetail.data, 'data.config.job_definitions') || []; + const jobs = get(workFlowDetail.data, 'data.jobs') || []; + return jobIds.map((item: ID, index: number) => { + const { error_message, state } = jobs[index]; + return { + label: jobNames[index].name, + value: item, + hasError: + error_message && + (error_message.app || JSON.stringify(error_message.pods) !== '{}') && + state !== 'COMPLETED', + errorMessage: error_message, + }; + }); + }, [workFlowDetail.data]); + + const jobData = useMemo(() => { + return get(jobDetail, 'data.data.pods') || ([] as Pod[]); + }, [jobDetail]); + + const handleOnChangeJob = (val: ID) => { + setSelectJobId(() => val); + reset(); + }; + + const columns = [ + { + title: 'Pod', + dataIndex: 'name', + key: 'name', + width: 400, + render: (val: string) => { + return <ClickToCopy text={val}>{val}</ClickToCopy>; + }, + }, + { + title: '运行状态', + dataIndex: 'state', + key: 'state', + ...podStateFilters, + width: 200, + render: (_: PodState, record: Pod) => { + return <StateIndicator {...getPodState(record)} />; + }, + }, + { + title: '创建时间', + dataIndex: 'creation_timestamp', + key: 'creation_timestamp', + width: 150, + sorter(a: Pod, b: Pod) { + return a.creation_timestamp - b.creation_timestamp; + }, + render: (val: number) => { + return formatTimestamp(val); + }, + }, + { + title: '操作', + dataIndex: 'actions', + key: 'actions', + width: 150, + render: (_: any, record: Pod) => { + return ( + <Link target={'_blank'} to={`/logs/pod/${selectJobId}/${record.name}`}> + 查看日志 + </Link> + ); + }, + }, + ]; + + return ( + <> + <div className={styled.workflow_pods_header}> + <LabelStrong fontSize={14} isBlock={true}> + 实例信息 + </LabelStrong> + <Radio.Group onChange={handleOnChangeJob} size="small" type="button" value={selectJobId}> + {jobList.map((item: any) => { + return ( + <Radio key={item.value} value={item.value}> + {item.hasError ? ( + <Tooltip content={renderErrorMessage(item.errorMessage)}> + <span className={styled.job_detail_state_indicator}> + <span className={styled.dot} /> + {item.label} + </span> + </Tooltip> + ) : ( + item.label + )} + </Radio> + ); + })} + </Radio.Group> + <Popover + trigger="hover" + position="br" + content={ + <span> + <div className={styled.job_detail_more_title}>工作流</div> + <Link + className={styled.job_detail_more_link} + to={`/workflow-center/workflows/${workFlowId}`} + > + 点击查看工作流 + </Link> + <div className={styled.job_detail_more_title}>工作流 ID</div> + <div className={styled.job_detail_more_content}>{workFlowId}</div> + </span> + } + > + <Button className={styled.job_detail_more_button} type="text"> + 更多信息 + </Button> + </Popover> + </div> + {jobList.length > 0 ? ( + <Table + rowKey={'name'} + className={'custom-table custom-table-left-side-filter'} + loading={jobDetail.isFetching} + data={jobData} + columns={columns} + pagination={{ + ...paginationProps, + }} + onChange={(pagination, sorter, filters, extra) => { + if (extra.action === 'filter') { + reset(); + } + }} + /> + ) : null} + </> + ); + function renderErrorMessage(errorMessage: any) { + const { app, pods } = errorMessage; + return ( + <div className={styled.job_detail_error_wrapper}> + <h3 className={styled.job_detail_error_title}>Main Error: {app}</h3> + {Object.entries(pods).map(([pod, error], index) => ( + <div className={styled.job_detail_error_item} key={index}> + <div>Pod: {pod}</div> + <div>Error: {error}</div> + </div> + ))} + </div> + ); + } +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/index.module.less new file mode 100644 index 000000000..0c4fe8704 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/index.module.less @@ -0,0 +1,9 @@ +.flex_container{ + display: flex; + flex-direction: row; + justify-content: space-between; + :global(.arco-spin) { + flex-grow: 1; + margin-right: 12px; + } +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetJobDetail/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/index.tsx new file mode 100644 index 000000000..1050a4f4b --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetJobDetail/index.tsx @@ -0,0 +1,111 @@ +import React, { FC, useMemo, useState } from 'react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import TaskDetail, { NodeType } from '../TaskDetail'; +import JobParamsPanel from './JobParamsPanel'; +import { useParams } from 'react-router-dom'; +import JobTitle from './JobTitle'; +import { useQuery } from 'react-query'; +import { fetchDatasetJobDetail } from 'services/dataset'; +import { useGetCurrentProjectId } from 'hooks'; +import { DatasetJob, DatasetJobState } from 'typings/dataset'; +import { get } from 'lodash-es'; +import JobBasicInfo from './JobBasicInfo'; +import WorkFlowPods from './WorkFlowPods'; +import { DatasetDetailSubTabs } from '../DatasetDetail'; +import { useHistory } from 'react-router'; +import { isDataJoin } from '../shared'; +import BackButton from 'components/BackButton'; +import styled from './index.module.less'; + +type TProps = {}; + +const DatasetJobDetail: FC<TProps> = function (props: TProps) { + const { job_id } = useParams<{ job_id: string }>(); + const projectId = useGetCurrentProjectId(); + const [workFlowId, setWorkFlowId] = useState<ID>(); + const history = useHistory(); + const [jobBasicInfo, setJobBasicInfo] = useState( + {} as { + coordinatorId: ID; + createTime: DateTime; + startTime: DateTime; + finishTime: DateTime; + jobState: DatasetJobState; + }, + ); + const jobDetailQuery = useQuery( + ['fetch_dataset_jobDetail', projectId, job_id], + () => fetchDatasetJobDetail(projectId!, job_id!), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: Boolean(projectId && job_id), + onSuccess: (data) => { + const { workflow_id, coordinator_id, created_at, started_at, finished_at, state } = + data.data || {}; + setJobBasicInfo({ + coordinatorId: coordinator_id, + createTime: created_at, + startTime: started_at, + finishTime: finished_at, + jobState: state, + }); + setWorkFlowId(workflow_id); + }, + }, + ); + const jobDetail = useMemo(() => { + if (!jobDetailQuery.data) { + return {}; + } + return jobDetailQuery.data.data; + }, [jobDetailQuery.data]); + const isJoin = isDataJoin(jobDetailQuery.data?.data.kind); + const globalConfigs = useMemo(() => { + if (!jobDetailQuery.data) { + return {}; + } + return get(jobDetailQuery.data, 'data.global_configs.global_configs'); + }, [jobDetailQuery.data]); + + const backToList = () => { + history.goBack(); + }; + + return ( + <SharedPageLayout title={<BackButton onClick={backToList}>任务详情</BackButton>}> + <JobTitle + id={job_id} + data={jobDetail as DatasetJob} + onStop={jobDetailQuery.refetch} + onDelete={backToList} + /> + <JobBasicInfo {...jobBasicInfo} /> + <div className={styled.flex_container}> + <TaskDetail + middleJump={false} + datasetJobId={job_id} + isShowRatio={isJoin} + onNodeClick={(node, datasetMapper = {}) => { + if (node.type === NodeType.DATASET_PROCESSED) { + const datasetInfo = datasetMapper[node?.data?.dataset_uuid ?? '']; + datasetInfo && + datasetInfo.id && + history.push( + `/datasets/processed/detail/${datasetInfo.id}/${DatasetDetailSubTabs.PreviewData}`, + ); + } + }} + // TODO: pass error message when state_frontend = DatasetStateFront.FAILED, + errorMessage="" + // TODO: confirm the dataset is processed or not by the state of job + isProcessedDataset={true} + /> + <JobParamsPanel globalConfigs={globalConfigs} /> + </div> + <WorkFlowPods workFlowId={workFlowId} /> + </SharedPageLayout> + ); +}; + +export default DatasetJobDetail; diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/DatasetActions/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetActions/index.module.less new file mode 100644 index 000000000..6dc37bfb0 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetActions/index.module.less @@ -0,0 +1,4 @@ +.disabled{ + cursor: not-allowed !important; + color: var(--color-text-4) !important; +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/DatasetActions/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetActions/index.tsx new file mode 100644 index 000000000..47d645f89 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetActions/index.tsx @@ -0,0 +1,155 @@ +import React, { FC, useMemo } from 'react'; +import { Dataset, DatasetKindLabel } from 'typings/dataset'; +import GridRow from 'components/_base/GridRow'; +import { ButtonProps, Popconfirm } from '@arco-design/web-react'; +import DatasetEditModal from '../DatasetEditModal'; +import { useToggle } from 'react-use'; +import MoreActions from 'components/MoreActions'; +import { isFrontendDeleting, isFrontendProcessing, isFrontendSucceeded } from 'shared/dataset'; +import { isDatasetTicket, isDatasetPublished } from 'views/Datasets/shared'; +import { isFrontendAuthorized } from 'views/Datasets/shared'; +import styled from './index.module.less'; + +export type DatasetAction = + | 'delete' + | 'publish-to-project' + | 'export' + | 'authorize' + | 'cancel-authorize'; +type Props = { + dataset: Dataset; + type: ButtonProps['type']; + onPerformAction: (args: { action: DatasetAction; dataset: Dataset }) => void; + kindLabel: DatasetKindLabel; +}; + +const DatasetActions: FC<Props> = ({ dataset, type = 'default', onPerformAction, kindLabel }) => { + const [editModalVisible, toggleEditModalVisible] = useToggle(false); + + const isProcessing = isFrontendProcessing(dataset); + const isSuccess = isFrontendSucceeded(dataset); + const isDeleting = isFrontendDeleting(dataset); + const isAuthorized = isFrontendAuthorized(dataset); + const isProcessedDataset = kindLabel === DatasetKindLabel.PROCESSED; + // 表示当前数据集是否处于审批环节 + const isTicket = isDatasetTicket(dataset); + const isPublished = isDatasetPublished(dataset); + // 老数据禁用授权及撤销授权功能 + const isDisabledAuth = useMemo(() => { + const participantsMap = dataset?.participants_info?.participants_map; + if (!participantsMap) return true; + return !Boolean(Object.keys(participantsMap).length > 0); + }, [dataset]); + + const actionList = [ + { + label: '编辑', + onClick: onEditClick, + disabled: isProcessing || isDeleting, + }, + { + label: '删除', + onClick: onDeleteClick, + danger: true, + disabled: isProcessing || isDeleting, + }, + ]; + + return ( + <> + <GridRow {...{ type }}> + {isProcessedDataset && isAuthorized && ( + <Popconfirm + title={`确认撤销对 ${dataset.name} 的授权?`} + disabled={isDisabledAuth} + onOk={onCancelAuthorizeClick} + > + <button + className={`custom-text-button ${isDisabledAuth ? styled.disabled : ''}`} + style={{ + marginRight: 10, + }} + type="button" + > + 撤销授权 + </button> + </Popconfirm> + )} + {isProcessedDataset && !isAuthorized && ( + <button + className="custom-text-button" + style={{ + marginRight: 10, + }} + type="button" + onClick={onAuthorizeClick} + disabled={isDisabledAuth} + > + 授权 + </button> + )} + {!isProcessedDataset && !isTicket && ( + <button + className="custom-text-button" + style={{ + marginRight: 10, + }} + type="button" + disabled={!isSuccess} + onClick={onPublishClick} + > + {isPublished ? '撤销发布' : '发布'} + </button> + )} + {isProcessedDataset && ( + <button + className="custom-text-button" + style={{ + marginRight: 10, + }} + type="button" + key="edit-dataset" + disabled={!isSuccess} // new processed dataset can't be exported + onClick={onExportClick} + > + 导出 + </button> + )} + <MoreActions actionList={actionList} /> + </GridRow> + <DatasetEditModal + dataset={dataset} + visible={editModalVisible} + toggleVisible={toggleEditModalVisible} + onSuccess={onEditSuccess} + /> + </> + ); + + function onEditClick() { + toggleEditModalVisible(true); + } + function onDeleteClick() { + onPerformAction?.({ action: 'delete', dataset }); + } + + function onEditSuccess() {} + + function onPublishClick() { + onPerformAction?.({ action: 'publish-to-project', dataset }); + } + + function onExportClick() { + onPerformAction?.({ action: 'export', dataset }); + } + + function onAuthorizeClick() { + onPerformAction?.({ action: 'authorize', dataset }); + } + + function onCancelAuthorizeClick() { + onPerformAction?.({ action: 'cancel-authorize', dataset }); + } +}; + +export default DatasetActions; diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/DatasetEditModal/index.module.less b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetEditModal/index.module.less new file mode 100644 index 000000000..c984639e1 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetEditModal/index.module.less @@ -0,0 +1,4 @@ +.footer_row{ + padding-top: 15px; + border-top: 1px solid var(--backgroundColorGray); +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/DatasetEditModal/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetEditModal/index.tsx new file mode 100644 index 000000000..32fe35991 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetEditModal/index.tsx @@ -0,0 +1,112 @@ +import React, { FC, useEffect } from 'react'; +import { to } from 'shared/helpers'; +import { MAX_COMMENT_LENGTH, validNamePattern } from 'shared/validator'; +import { forceToRefreshQuery } from 'shared/queryClient'; +import { editDataset } from 'services/dataset'; + +import { Modal, Button, Message, Form, Input } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; + +import ButtonWithPopconfirm from 'components/ButtonWithPopconfirm'; +import { DATASET_LIST_QUERY_KEY } from '../DatasetTable'; + +import { Dataset, DatasetEditDisplay } from 'typings/dataset'; +import styled from './index.module.less'; + +type Props = { + dataset: Dataset; + visible: boolean; + toggleVisible: (v: boolean) => void; + onSuccess: Function; +} & React.ComponentProps<typeof Modal>; + +const DatasetEditModal: FC<Props> = ({ dataset, visible, toggleVisible, onSuccess, ...props }) => { + const [form] = Form.useForm<DatasetEditDisplay>(); + const { id, name, comment } = dataset; + + useEffect(() => { + if (visible && form && dataset) { + form.setFieldsValue({ + name: dataset.name, + comment: dataset.comment, + }); + } + }, [visible, form, dataset]); + + return ( + <Modal + title="编辑数据集" + visible={visible} + maskClosable={false} + maskStyle={{ backdropFilter: 'blur(4px)' }} + afterClose={afterClose} + onCancel={closeModal} + footer={null} + {...props} + > + <Form initialValues={{ name, comment }} layout="vertical" form={form} onSubmit={submit}> + <Form.Item + label="数据集名称" + field="name" + rules={[ + { required: true, message: 'Please input dataset name!' }, + { + match: validNamePattern, + message: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ]} + > + <Input placeholder="请输入数据集名称" disabled={true} /> + </Form.Item> + <Form.Item + label="数据集描述" + field="comment" + rules={[{ maxLength: MAX_COMMENT_LENGTH, message: '最多为 200 个字符' }]} + > + <Input.TextArea + placeholder="最多为 200 个字符" + maxLength={MAX_COMMENT_LENGTH} + showWordLimit + /> + </Form.Item> + + <Form.Item wrapperCol={{ span: 24 }} style={{ marginBottom: 0 }}> + <GridRow className={styled.footer_row} justify="end" gap="12"> + <ButtonWithPopconfirm buttonText="取消" onConfirm={closeModal} /> + <Button type="primary" htmlType="submit"> + 保存 + </Button> + </GridRow> + </Form.Item> + </Form> + </Modal> + ); + + function closeModal() { + toggleVisible(false); + } + + async function submit(values: { name: string; comment?: string }) { + if (!form) { + return; + } + const { comment } = values; + const [res, error] = await to(editDataset(id, { comment })); + if (error) { + Message.error(error.message); + return; + } + Message.success('数据集编辑成功'); + closeModal(); + // Force to refresh the dataset list + forceToRefreshQuery(DATASET_LIST_QUERY_KEY); + onSuccess(res); + } + + function afterClose() { + // Clear all fields + form.resetFields(); + } +}; + +export default DatasetEditModal; diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/DatasetTable/index.less b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetTable/index.less new file mode 100644 index 000000000..8f602d201 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetTable/index.less @@ -0,0 +1,15 @@ +.dataset-list-plus-button{ + margin-right: 4px; + vertical-align: 0.03em !important; +} + +.dataset_list_name{ + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + max-width: 120px; + margin-right: 5px; + cursor: pointer; +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/DatasetTable/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetTable/index.tsx new file mode 100644 index 000000000..722d2ae88 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/DatasetTable/index.tsx @@ -0,0 +1,924 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { + Button, + Input, + Message, + Space, + Statistic, + Table, + TableColumnProps, + Tabs, + Tooltip, + Tag, + Typography, +} from '@arco-design/web-react'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import { formatTimestamp } from 'shared/date'; +import { + Dataset, + DatasetDataType, + DatasetDataTypeText, + DatasetKind, + DatasetKindLabel, + DatasetKindLabelCapitalMapper, + DatasetStateFront, + DatasetTabType, + ParticipantDataset, + DatasetRawPublishStatus, + DatasetProcessedAuthStatus, + DatasetProcessedMyAuthStatus, + ParticipantInfo, + DatasetKindBackEndType, + DatasetType__archived, + DATASET_COPY_CHECKER, +} from 'typings/dataset'; +import { useQuery } from 'react-query'; +import { + deleteDataset, + fetchDatasetList, + fetchParticipantDatasetList, + authorizeDataset, + cancelAuthorizeDataset, +} from 'services/dataset'; +import { getTotalDataSize } from 'shared/dataset'; +import { noop } from 'lodash-es'; +import DatasetActions, { DatasetAction } from '../DatasetActions'; +import ImportProgress from '../ImportProgress'; +import { Link, Redirect } from 'react-router-dom'; +import { + datasetKindLabelValueMap, + FILTER_OPERATOR_MAPPER, + filterExpressionGenerator, + getSortOrder, + RawAuthStatusOptions, + RawPublishStatusOptions, +} from '../../shared'; +import { expression2Filter } from 'shared/filter'; +import GridRow from 'components/_base/GridRow'; +import { generatePath, useHistory, useParams } from 'react-router'; +import { humanFileSize } from 'shared/file'; +import Modal from 'components/Modal'; +import { + useGetAppFlagValue, + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useGetCurrentProjectType, + useTablePaginationWithUrlState, + useUrlState, + useGetCurrentProjectAbilityConfig, +} from 'hooks'; +import { TIME_INTERVAL, CONSTANTS } from 'shared/constants'; +import WhichParticipant from 'components/WhichParticipant'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import ExportModal from 'components/DatasetExportModal'; +import StatusProgress from 'components/StatusProgress'; +import { DatasetDetailSubTabs } from '../../DatasetDetail'; +import { IconPlus } from '@arco-design/web-react/icon'; +import { transformRegexSpecChar } from 'shared/helpers'; +import DatasetPublishAndRevokeModal from 'components/DatasetPublishAndRevokeModal'; +import { ParticipantType } from 'typings/participant'; +import { PageMeta } from 'typings/app'; +import { PaginationProps } from '@arco-design/web-react/es/Pagination/pagination'; +import { SorterResult } from '@arco-design/web-react/es/Table/interface'; +import { FlagKey } from 'typings/flag'; +import { fetchSysInfo } from 'services/settings'; +import { FilterOp } from 'typings/filter'; +import './index.less'; + +const { Text } = Typography; + +type ColumnsGetterOptions = { + onDeleteClick?: any; + onPublishClick: (dataset: Dataset) => void; + onExportClick: (dataset: Dataset) => void; + onAuthorize: (dataset: Dataset) => void; + onCancelAuthorize: (dataset: Dataset) => void; + onSuccess?: any; + withoutActions?: boolean; +}; + +type TableFilterConfig = Pick<TableColumnProps, 'filters' | 'onFilter'>; + +const FILTER_OPERATOR_MAPPER_List = { + ...FILTER_OPERATOR_MAPPER, + dataset_kind: FilterOp.IN, +}; + +/** + * table columns generator + * TODO: there are too many 「if-else」 and need to chore + * @param projectId + * @param tab + * @param kindLabel + * @param options callback of operation + * @param bcsSupportEnabled Whether to access blockchain + */ +export const getDatasetTableColumns = ( + projectId: ID | undefined, + tab: DatasetTabType | undefined, + kindLabel: DatasetKindLabel, + options: ColumnsGetterOptions, + urlState: Partial<{ + page: number; + pageSize: number; + filter: string; + order_by: string; + state_frontend: DatasetStateFront[]; + }>, + datasetParticipantFilters: TableFilterConfig, + myPureDomainName: string, + bcsSupportEnabled: boolean, + reviewCenterConfiguration: string, +) => { + const onPerformAction = (payload: { action: DatasetAction; dataset: Dataset }) => { + return { + delete: options.onDeleteClick, + 'publish-to-project': options.onPublishClick, + export: options.onExportClick, + authorize: options.onAuthorize, + 'cancel-authorize': options.onCancelAuthorize, + }[payload.action](payload.dataset); + }; + const renderStatistic = (val: number | string) => { + return typeof val === 'number' ? ( + <Statistic + groupSeparator={true} + styleValue={{ fontSize: '12px', fontWeight: 400 }} + value={val} + /> + ) : ( + '-' + ); + }; + + const renderParticipantAuth = (val: ParticipantInfo[]) => { + return ( + <> + {val.map((participant, index) => ( + <div key={index}> + {participant.name}{' '} + {participant.auth_status === DatasetProcessedMyAuthStatus.AUTHORIZED + ? '已授权' + : '未授权'} + </div> + ))} + </> + ); + }; + + const getPublishStatusFilters = () => { + if (reviewCenterConfiguration === '{}') { + return [ + { + text: '未发布', + value: DatasetRawPublishStatus.UNPUBLISHED, + }, + { + text: '已发布', + value: DatasetRawPublishStatus.PUBLISHED, + }, + ]; + } + return [ + { + text: '未发布', + value: DatasetRawPublishStatus.UNPUBLISHED, + }, + { + text: '待审批', + value: DatasetRawPublishStatus.TICKET_PENDING, + }, + { + text: '审批拒绝', + value: DatasetRawPublishStatus.TICKET_DECLINED, + }, + { + text: '已发布', + value: DatasetRawPublishStatus.PUBLISHED, + }, + ]; + }; + + const cols: ColumnProps[] = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + width: 240, + render: (name: string, record: Dataset) => { + const to = `/datasets/${kindLabel}/detail/${record.id}/${DatasetDetailSubTabs.DatasetJobDetail}`; + if (record.dataset_type === DatasetType__archived.STREAMING) { + return ( + <> + <Tooltip + content={ + <Text style={{ color: '#fff' }} copyable> + {name} + </Text> + } + > + {tab === DatasetTabType.PARTICIPANT ? ( + <span className="dataset_list_name">{name}</span> + ) : ( + <Link className="dataset_list_name" to={to}> + {name} + </Link> + )} + </Tooltip> + <Tag color="blue" size="small"> + 增量 + </Tag> + </> + ); + } else { + return tab === DatasetTabType.PARTICIPANT ? name : <Link to={to}>{name}</Link>; + } + }, + }, + tab === DatasetTabType.PARTICIPANT + ? { + title: '合作伙伴名称', + dataIndex: 'participant_id', + width: 180, + ...datasetParticipantFilters, + filteredValue: expression2Filter(urlState.filter!).participant_id, + render(id) { + return <WhichParticipant id={id} />; + }, + } + : { + title: '数据集状态', + dataIndex: 'state_frontend', + width: 180, + filters: [ + { + text: '待处理', + value: DatasetStateFront.PENDING, + }, + { + text: '处理中', + value: DatasetStateFront.PROCESSING, + }, + { + text: '可用', + value: DatasetStateFront.SUCCEEDED, + }, + { + text: '处理失败', + value: DatasetStateFront.FAILED, + }, + { + text: '删除中', + value: DatasetStateFront.DELETING, + }, + ], + filteredValue: urlState.state_frontend, + render: (_: any, record: Dataset) => { + return <ImportProgress dataset={record} />; + }, + }, + { + title: '数据格式', + dataIndex: tab === DatasetTabType.PARTICIPANT ? 'format' : 'dataset_format', + width: 150, + filters: [ + { text: DatasetDataTypeText.STRUCT, value: DatasetDataType.STRUCT }, + { text: DatasetDataTypeText.PICTURE, value: DatasetDataType.PICTURE }, + ], + // Return different values depending on whether the tab is PARTICIPANT + onFilter: (value: string, record: any) => { + if (tab === DatasetTabType.PARTICIPANT) { + return ( + record?.[tab === DatasetTabType.PARTICIPANT ? 'format' : 'dataset_format'] === value + ); + } + return true; + }, + filteredValue: expression2Filter(urlState.filter!)[ + tab === DatasetTabType.PARTICIPANT ? 'format' : 'dataset_format' + ], + render(val: DatasetDataType) { + switch (val) { + case DatasetDataType.STRUCT: + return DatasetDataTypeText.STRUCT; + case DatasetDataType.PICTURE: + return DatasetDataTypeText.PICTURE; + case DatasetDataType.NONE_STRUCTURED: + return DatasetDataTypeText.NONE_STRUCTURED; + } + }, + }, + { + title: ( + <Space> + <span>数据大小</span> + <Tooltip content="数据以系统格式存储的大小,较源文件会有一定变化"> + <IconInfoCircle style={{ color: 'var(--color-text-3)', fontSize: 14 }} /> + </Tooltip> + </Space> + ), + dataIndex: 'file_size', + width: 180, + render: (file_size: number, record: Dataset) => { + const isInternalProcessed = + record.dataset_kind === DatasetKindBackEndType.INTERNAL_PROCESSED; + const isErrorFileSize = file_size === -1; + if (isErrorFileSize) { + return '异常'; + } + return <span>{isInternalProcessed ? '-' : humanFileSize(getTotalDataSize(record))}</span>; + }, + }, + ]; + + if (tab === DatasetTabType.MY) { + cols.push({ + title: '数据集样本量', + dataIndex: 'num_example', + width: 180, + render: (num: number, record: Dataset) => { + const isInternalProcessed = + record.dataset_kind === DatasetKindBackEndType.INTERNAL_PROCESSED; + const isNoCopy = record.import_type === DATASET_COPY_CHECKER.NONE_COPY; + const isErrorFileSize = record.file_size === -1; + if (isErrorFileSize) { + return '异常'; + } + return renderStatistic(isInternalProcessed || isNoCopy ? '' : num); + }, + }); + + if (kindLabel === DatasetKindLabel.RAW) { + if (bcsSupportEnabled) { + cols.push({ + title: '数据价值', + dataIndex: 'total_value', + width: 180, + render: renderStatistic, + }); + } + cols.push( + { + title: '创建者', + dataIndex: 'creator_username', + key: 'creator_username', + width: 100, + render(val: string) { + return val || CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + title: '发布状态', + dataIndex: 'publish_frontend_state', + width: 150, + filters: getPublishStatusFilters(), + onFilter: (value: string, record: Dataset) => { + return value === record.publish_frontend_state; + }, + filterMultiple: false, + filteredValue: expression2Filter(urlState.filter!).publish_frontend_state + ? [expression2Filter(urlState.filter!).publish_frontend_state] + : [], + render: (publish_frontend_state: DatasetRawPublishStatus) => { + return ( + <StatusProgress options={RawPublishStatusOptions} status={publish_frontend_state} /> + ); + }, + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 180, + sortOrder: getSortOrder(urlState, 'created_at'), + sorter(a: Dataset, b: Dataset) { + return a.created_at - b.created_at; + }, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + ); + } else { + cols.push( + { + title: '创建者', + dataIndex: 'creator_username', + key: 'creator_username', + width: 100, + render(val: string) { + return val || CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + title: '授权状态', + dataIndex: 'auth_frontend_state', + width: 150, + render: (auth_frontend_state: DatasetProcessedAuthStatus, record: Dataset) => { + // 处于待授权状态时展示hover + if (auth_frontend_state === DatasetProcessedAuthStatus.AUTH_PENDING) { + const participants_info = Object.entries( + record.participants_info.participants_map, + ).map(([key, value]) => ({ + name: key === myPureDomainName ? '我方' : key, + auth_status: value['auth_status'], + })); + return ( + <StatusProgress + options={RawAuthStatusOptions} + status={auth_frontend_state || DatasetProcessedAuthStatus.AUTH_PENDING} + isTip={true} + toolTipContent={renderParticipantAuth(participants_info || [])} + /> + ); + } + return ( + <StatusProgress + options={RawAuthStatusOptions} + status={auth_frontend_state || DatasetProcessedAuthStatus.TICKET_PENDING} + /> + ); + }, + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 180, + sortOrder: getSortOrder(urlState, 'created_at'), + sorter(a: Dataset, b: Dataset) { + return a.created_at - b.created_at; + }, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + ); + } + } + + if (tab === DatasetTabType.PARTICIPANT) { + if (bcsSupportEnabled) { + cols.push({ + title: '使用单价', + dataIndex: 'value', + width: 180, + sortOrder: getSortOrder(urlState, 'value'), + sorter(a: ParticipantDataset, b: ParticipantDataset) { + return (a.value || 0) - (b.value || 0); + }, + render: renderStatistic, + }); + } + cols.push({ + title: '最近更新', + dataIndex: 'updated_at', + width: 200, + sortOrder: getSortOrder(urlState, 'updated_at'), + sorter(a: ParticipantDataset, b: ParticipantDataset) { + return a.updated_at - b.updated_at; + }, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }); + } + + if (!options.withoutActions && tab === DatasetTabType.MY) { + cols.push({ + title: '操作', + dataIndex: 'operation', + name: 'operation', + fixed: 'right', + width: 200, + render: (_: number, record: Dataset) => ( + <DatasetActions + onPerformAction={onPerformAction} + dataset={record} + type="text" + kindLabel={kindLabel} + /> + ), + } as any); + } + + return cols; +}; + +export const DATASET_LIST_QUERY_KEY = 'fetchDatasetList'; + +export const GlobalDatasetIdToErrorMessageMapContext = React.createContext<{ + [key: number]: string; +}>({}); + +type Props = { + dataset_kind: DatasetKind; +}; + +const DatasetListTable: FC<Props> = ({ dataset_kind }) => { + const { kind_label, tab } = useParams<{ + kind_label: DatasetKindLabel; + tab?: DatasetTabType; + }>(); + const isProcessedDataset = kind_label === DatasetKindLabel.PROCESSED; + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + const projectType = useGetCurrentProjectType(); + const participantList = useGetCurrentProjectParticipantList(); + const { hasTrusted } = useGetCurrentProjectAbilityConfig(); + const [currentExportId, setCurrentExportId] = useState<ID>(); + const [isShowExportModal, setIsShowExportModal] = useState(false); + const [isShowPublishModal, setIsShowPublishModal] = useState(false); + const [total, setTotal] = useState(0); + const [pageTotal, setPageTotal] = useState(0); + const [selectDataset, setSelectDataset] = useState<Dataset>(); + const [datasetIdToErrorMessageMap, setDatasetIdToErrorMessageMap] = useState<{ + [key: number]: string; + }>({}); + const [urlState, setUrlState] = useUrlState<any>({ + page: 1, + pageSize: 10, + filter: filterExpressionGenerator( + { + dataset_kind: + kind_label === DatasetKindLabel.RAW + ? [DatasetKindLabelCapitalMapper[kind_label]] + : [ + DatasetKindLabelCapitalMapper[kind_label], + DatasetKindBackEndType.INTERNAL_PROCESSED, + ], + project_id: projectId, + auth_status: isProcessedDataset + ? [DatasetProcessedMyAuthStatus.AUTHORIZED, DatasetProcessedMyAuthStatus.WITHDRAW] + : undefined, + }, + FILTER_OPERATOR_MAPPER_List, + ), + order_by: '', + }); + const { paginationProps } = useTablePaginationWithUrlState(); + const bcs_support_enabled = useGetAppFlagValue(FlagKey.BCS_SUPPORT_ENABLED); + const review_center_configuration = useGetAppFlagValue(FlagKey.REVIEW_CENTER_CONFIGURATION); + const isLightClient = projectType === ParticipantType.LIGHT_CLIENT; + const sysInfoQuery = useQuery(['fetchSysInfo'], () => fetchSysInfo(), { + retry: 2, + refetchOnWindowFocus: false, + enabled: Boolean(isProcessedDataset), + }); + + const myPureDomainName = useMemo<string>(() => { + return sysInfoQuery.data?.data?.pure_domain_name ?? ''; + }, [sysInfoQuery.data]); + const listQuery = useQuery<{ + data: Array<Dataset | ParticipantDataset>; + page_meta?: PageMeta; + }>( + [ + DATASET_LIST_QUERY_KEY, + tab === DatasetTabType.MY ? urlState : null, + projectId, + kind_label, + tab, + ], + () => { + if (!projectId!) { + Message.info('请选择工作区'); + return Promise.resolve({ data: [] }); + } + + if (!tab || tab === DatasetTabType.MY) { + return fetchDatasetList({ + page: urlState.page, + page_size: urlState.pageSize, + filter: urlState.filter, + state_frontend: urlState.state_frontend, + // when order_by is empty and set order_by = 'created_at desc' default + order_by: urlState.order_by || 'created_at desc', + }); + } + return fetchParticipantDatasetList(projectId!, { + page: urlState.page, + page_size: urlState.pageSize, + kind: DatasetKindLabel.RAW, + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + keepPreviousData: true, + refetchOnWindowFocus: false, + }, + ); + + const datasetParticipantFilters: TableFilterConfig = useMemo(() => { + let filters: { text: string; value: any }[] = []; + if (Array.isArray(participantList) && participantList.length) { + filters = participantList.map((item) => ({ + text: item.name, + value: '' + item.id, + })); + } + return { + filters, + onFilter: (value: string, record: ParticipantDataset) => { + return value === '' + record.participant_id; + }, + }; + }, [participantList]); + + const list = useMemo(() => { + if (!listQuery.data?.data) return []; + + let list = listQuery.data.data || []; + // Because participant_datasets api don't support search by name, we need to filter by web + if (tab === DatasetTabType.PARTICIPANT) { + const { name, format, participant_id } = expression2Filter(urlState.filter); + if (name) { + const regx = new RegExp(`^.*${transformRegexSpecChar(name)}.*$`); // support fuzzy matching + list = list.filter((item) => regx.test(item.name)); + } + if (format) { + list = list.filter((item: any) => format.includes(item.format)); + } + if (participant_id) { + list = list.filter((item: any) => participant_id.includes(String(item.participant_id))); + } + setTotal(list.length); + setPageTotal(Math.ceil(list.length / paginationProps.pageSize)); + } else { + const { page_meta, data = [] } = listQuery.data; + setTotal(page_meta?.total_items ?? data.length); + setPageTotal(page_meta?.total_pages ?? Math.ceil(data.length / paginationProps.pageSize)); + } + + return list; + }, [listQuery.data, urlState.filter, tab, paginationProps.pageSize]); + + const pagination = useMemo(() => { + return pageTotal <= 1 + ? false + : { + ...paginationProps, + total, + }; + }, [paginationProps, pageTotal, total]); + + const kindLabel = datasetKindLabelValueMap[dataset_kind]; + // 有可信分析能力不展示结果数据创建按钮 + const isHideCreateBtn = hasTrusted && kind_label === DatasetKindLabel.PROCESSED; + + useEffect(() => { + setUrlState({ + ...urlState, + filter: filterExpressionGenerator( + { + ...expression2Filter(urlState.filter), + project_id: projectId, + }, + FILTER_OPERATOR_MAPPER_List, + ), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + return ( + <> + <GridRow justify="space-between" align="center"> + <Space> + {!isHideCreateBtn && ( + <Button + className={'custom-operation-button'} + type="primary" + onClick={goCreateDataset} + icon={<IconPlus />} + > + 创建数据集 + </Button> + )} + {kind_label !== DatasetKindLabel.PROCESSED && ( + <Tabs + className="custom-tabs" + type="text" + defaultActiveTab={tab ?? DatasetTabType.MY} + onChange={onTabChange} + > + {Object.entries(DatasetTabType).map((item) => { + const [, value] = item; + const title = value === DatasetTabType.MY ? '我方数据集' : '合作伙伴数据集'; + return ( + <Tabs.TabPane + disabled={isLightClient && value === DatasetTabType.PARTICIPANT} + key={value} + title={title} + /> + ); + })} + </Tabs> + )} + </Space> + <Space> + <Input.Search + className={'custom-input'} + placeholder="输入数据集名称搜索" + defaultValue={expression2Filter(urlState.filter).name} + onSearch={onSearch} + onClear={() => onSearch('')} + allowClear + /> + </Space> + </GridRow> + <GlobalDatasetIdToErrorMessageMapContext.Provider value={datasetIdToErrorMessageMap}> + <Table + className={'custom-table custom-table-left-side-filter'} + loading={listQuery.isFetching} + data={list || []} + scroll={{ x: '100%' }} + columns={getDatasetTableColumns( + projectId, + tab, + kindLabel, + { + onSuccess: noop, + onDeleteClick, + onPublishClick, + onExportClick, + onAuthorize, + onCancelAuthorize, + }, + urlState, + datasetParticipantFilters, + myPureDomainName, + bcs_support_enabled as boolean, + review_center_configuration as string, + )} + rowKey="uuid" + pagination={pagination} + onChange={onTableChange} + /> + </GlobalDatasetIdToErrorMessageMapContext.Provider> + {!tab ? ( + <Redirect + to={generatePath('/datasets/:kind_label/:tab', { + kind_label, + tab: DatasetTabType.MY, + })} + /> + ) : null} + + <ExportModal + id={currentExportId} + visible={isShowExportModal} + onCancel={onExportModalClose} + onSuccess={onExportSuccess} + /> + + <DatasetPublishAndRevokeModal + onCancel={onPublishCancel} + onSuccess={onPublishSuccess} + dataset={selectDataset} + visible={isShowPublishModal} + /> + </> + ); + + function goCreateDataset() { + // 统一在路由区分新旧数据集入口 + history.push(`/datasets/${kindLabel}/create`); + } + + function onSearch(value: string) { + const filters = expression2Filter(urlState.filter); + filters.name = value; + setUrlState((prevState) => ({ + ...prevState, + page: 1, + filter: filterExpressionGenerator(filters, FILTER_OPERATOR_MAPPER_List), + })); + } + + function onTabChange(tab: string) { + history.push( + generatePath('/datasets/:kind_label/:tab', { + kind_label, + tab, + }), + ); + } + + function onDeleteClick(dataset: Dataset) { + Modal.delete({ + title: '确定要删除吗?', + content: '删除操作无法恢复,请谨慎操作', + onOk: async () => { + try { + const resp = await deleteDataset(dataset.id); + // If delete success, HTTP response status code is 204, resp is empty string + const isDeleteSuccess = !resp; + if (isDeleteSuccess) { + Message.success('删除成功'); + listQuery.refetch(); + setDatasetIdToErrorMessageMap((prevState) => { + const copyState = { ...prevState }; + delete copyState[dataset.id as number]; + return copyState; + }); + } else { + const errorMessage = resp?.message ?? '删除失败'; + Message.error(errorMessage!); + setDatasetIdToErrorMessageMap((prevState) => ({ + ...prevState, + [dataset.id]: errorMessage, + })); + } + } catch (error) { + Message.error(error.message); + } + }, + }); + } + + function onTableChange( + pagination: PaginationProps, + sorter: SorterResult, + filters: Partial<Record<keyof Dataset | keyof ParticipantDataset, string[]>>, + extra: { + currentData: Array<Dataset | ParticipantDataset>; + action: 'paginate' | 'sort' | 'filter'; + }, + ) { + switch (extra.action) { + case 'filter': + const filtersCopy = { + ...filters, + name: expression2Filter(urlState.filter).name, + project_id: projectId, + dataset_kind: + kind_label === DatasetKindLabel.RAW + ? [DatasetKindLabelCapitalMapper[kind_label]] + : [ + DatasetKindLabelCapitalMapper[kind_label], + DatasetKindBackEndType.INTERNAL_PROCESSED, + ], // 可信中心的结果数据集也进行展示 + publish_frontend_state: filters.publish_frontend_state?.[0] ?? undefined, + }; + setUrlState((prevState) => ({ + ...prevState, + page: 1, + filter: filterExpressionGenerator(filtersCopy, FILTER_OPERATOR_MAPPER_List), + state_frontend: filtersCopy.state_frontend ?? undefined, + })); + break; + case 'sort': + let orderValue = ''; + if (sorter.direction) { + orderValue = sorter.direction === 'ascend' ? 'asc' : 'desc'; + } + setUrlState((prevState) => ({ + ...prevState, + order_by: orderValue ? `${sorter.field} ${orderValue}` : '', + })); + break; + default: + break; + } + } + + function onPublishClick(dataset: Dataset) { + setSelectDataset(dataset); + setIsShowPublishModal(true); + } + function onPublishSuccess() { + setIsShowPublishModal(false); + listQuery.refetch(); + } + function onPublishCancel() { + setIsShowPublishModal(false); + } + async function onAuthorize(dataset: Dataset) { + try { + await authorizeDataset(dataset.id); + listQuery.refetch(); + } catch (err: any) { + Message.error(err.message); + } + } + + async function onCancelAuthorize(dataset: Dataset) { + try { + await cancelAuthorizeDataset(dataset.id); + listQuery.refetch(); + } catch (err: any) { + Message.error(err.message); + } + } + function onExportClick(dataset: Dataset) { + setCurrentExportId(dataset.id); + setIsShowExportModal(true); + } + function onExportSuccess(datasetId: ID, datasetJobId: ID) { + onExportModalClose(); + if (!datasetJobId && datasetJobId !== 0) { + Message.info('导出任务ID缺失,请手动跳转「任务管理」查看详情'); + } else { + history.push(`/datasets/${datasetId}/new/job_detail/${datasetJobId}`); + } + } + function onExportModalClose() { + setCurrentExportId(undefined); + setIsShowExportModal(false); + } +}; + +export default DatasetListTable; diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/ImportProgress/index.less b/web_console_v2/client/src/views/Datasets/DatasetList/ImportProgress/index.less new file mode 100644 index 000000000..29d0965d2 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/ImportProgress/index.less @@ -0,0 +1,7 @@ +.import-progress-wrapper{ + display: flex; + justify-content: left; + .dataset-empty-tag{ + margin-left: 4px; + } +} diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/ImportProgress/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetList/ImportProgress/index.tsx new file mode 100644 index 000000000..8cfd3879a --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/ImportProgress/index.tsx @@ -0,0 +1,48 @@ +import React, { FC, useContext } from 'react'; +import { BatchState, DataBatch, Dataset, DatasetKindBackEndType } from 'typings/dataset'; +import StateIndicator from 'components/StateIndicator'; +import { getImportStage, isFrontendSucceeded } from 'shared/dataset'; +import { GlobalDatasetIdToErrorMessageMapContext } from '../DatasetTable'; +import { Tag } from '@arco-design/web-react'; +import './index.less'; + +const ImportProgress: FC<{ dataset: Dataset; tag?: boolean }> = ({ dataset, tag = false }) => { + const { type, text, tip } = getImportStage(dataset); + const isEmptyDataset = (dataset?.file_size || 0) <= 0 && isFrontendSucceeded(dataset); + const isInternalProcessed = dataset.dataset_kind === DatasetKindBackEndType.INTERNAL_PROCESSED; + const globalDatasetIdToErrorMessageMap = useContext(GlobalDatasetIdToErrorMessageMapContext); + const errorMessage = globalDatasetIdToErrorMessageMap[dataset.id as number] ?? ''; + + return ( + <div data-name="dataset-import-progress" className={'import-progress-wrapper'}> + <StateIndicator type={type} text={text} tip={errorMessage || tip} tag={tag} /> + {isEmptyDataset && !isInternalProcessed && ( + <Tag className={'dataset-empty-tag'} color="purple" size="small"> + 空集 + </Tag> + )} + </div> + ); +}; + +export const DataBatchImportProgress: FC<{ batch: DataBatch }> = ({ batch }) => { + const { state } = batch; + + const indicatorPorps: React.ComponentProps<typeof StateIndicator> = ({ + [BatchState.IMPORTING]: { + type: 'processing', + text: '导入中', + }, + [BatchState.FAILED]: { type: 'error', text: '导入失败' }, + [BatchState.SUCCESS]: { type: 'success', text: '可用' }, + [BatchState.NEW]: { type: 'success', text: '可用' }, + } as const)[state]; + + return ( + <div data-name="data-batch-import-progress"> + <StateIndicator {...indicatorPorps} /> + </div> + ); +}; + +export default ImportProgress; diff --git a/web_console_v2/client/src/views/Datasets/DatasetList/ProcessedDatasetTodoPopover/index.tsx b/web_console_v2/client/src/views/Datasets/DatasetList/ProcessedDatasetTodoPopover/index.tsx new file mode 100644 index 000000000..180b315e1 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/DatasetList/ProcessedDatasetTodoPopover/index.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from 'react'; +import { Message } from '@arco-design/web-react'; +import { useQuery } from 'react-query'; +import { Dataset } from 'typings/dataset'; +import { fetchDatasetList } from 'services/dataset'; +import { useHistory } from 'react-router-dom'; +import { useGetCurrentProjectId } from 'hooks'; +import TodoPopover from 'components/TodoPopover'; +import { TIME_INTERVAL } from 'shared/constants'; +import { ApprovalProps } from 'components/TodoPopover'; +import { FILTER_OPERATOR_MAPPER, filterExpressionGenerator } from '../../shared'; +import { DatasetProcessedMyAuthStatus } from 'typings/dataset'; + +type ProcessedDatasetProps<T = any> = Omit<ApprovalProps<T>, 'list'> & {}; + +function ProcessedDatasetTodoPopover({ + dateField = 'created_at', + creatorField = 'creator', + contentField = 'name', + ...restProps +}: ProcessedDatasetProps) { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + + const { isError, data, error, isFetching } = useQuery<{ + data: Array<Dataset>; + }>( + ['fetchDatasetList', projectId], + () => { + if (!projectId!) { + Message.info('请选择工作区'); + return Promise.resolve({ data: [] }); + } + return fetchDatasetList({ + filter: filterExpressionGenerator( + { + dataset_kind: 'PROCESSED', + project_id: projectId, + auth_status: [DatasetProcessedMyAuthStatus.PENDING], + }, + FILTER_OPERATOR_MAPPER, + ), + order_by: 'created_at desc', + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + }, + ); + + if (isError && error) { + Message.error((error as Error).message); + } + + const todoList = useMemo(() => { + if (!data) { + return []; + } + + const list = data.data || []; + + return list; + }, [data]); + + return ( + <TodoPopover.ModelCenter + isLoading={isFetching} + list={todoList} + dateField={dateField} + contentField={contentField} + buttonText={`${todoList.length}条待处理结果数据集`} + title="待处理数据集授权申请" + contentVerb="发起了" + contentSuffix="数据集授权申请" + onClick={onClick} + {...restProps} + /> + ); + + function onClick(item: Dataset) { + history.push(`/datasets/processed/authorize/${item.id}`); + } +} + +export default ProcessedDatasetTodoPopover; diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobBasicInfo/index.module.less b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobBasicInfo/index.module.less new file mode 100644 index 000000000..100f52691 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobBasicInfo/index.module.less @@ -0,0 +1,3 @@ +.job_basic_info{ + margin: 0 20px; +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobBasicInfo/index.tsx b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobBasicInfo/index.tsx new file mode 100644 index 000000000..9a52acda9 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobBasicInfo/index.tsx @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; +import { formatTimestamp } from 'shared/date'; +import PropertyList from 'components/PropertyList'; +import WhichParticipant from 'components/WhichParticipant'; +import styled from './index.module.less'; + +type TJobBasicInfo = { + coordinatorId: ID; + createTime: DateTime; + creator_username: string; +}; + +export default function JobBasicInfo(prop: TJobBasicInfo) { + const { coordinatorId, createTime = 0, creator_username } = prop; + const basicInfo = useMemo(() => { + function TimeRender(prop: { time: DateTime }) { + const { time } = prop; + return <span>{time <= 0 ? '-' : formatTimestamp(time)}</span>; + } + return [ + { + label: '任务发起方', + value: coordinatorId === 0 ? '本方' : <WhichParticipant id={coordinatorId} />, + }, + { + label: '创建者', + value: creator_username, + }, + { + label: '创建时间', + value: <TimeRender time={createTime} />, + }, + ]; + }, [coordinatorId, createTime, creator_username]); + return ( + <div className={styled.job_basic_info}> + <PropertyList properties={basicInfo} cols={4} /> + </div> + ); +} diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobParamsPanel/index.module.less b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobParamsPanel/index.module.less new file mode 100644 index 000000000..64847e3a6 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobParamsPanel/index.module.less @@ -0,0 +1,36 @@ +.params_panel_header{ + display: flex; + flex-direction: row; + justify-content: flex-start; + margin: 20px 0; + .title{ + font-size: 14px; + line-height: 22px; + margin-right: 8px; + } + :global(.arco-radio-button) { + &:after { + background-color: unset; + } + } +} +.params_panel_container{ + display: flex; + flex-direction: row; + justify-content: flex-start; + .params_panel_item_wrapper{ + flex: 1; + margin-right: 20px; + &:last-child{ + margin-right: 0; + } + } +} +.params_panel_label{ + font-weight: 400; + font-size: 12px; + line-height: 18px; + color: #4e5969; + display: inline-block; + height: 18px; +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobParamsPanel/index.tsx b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobParamsPanel/index.tsx new file mode 100644 index 000000000..6fa427cd4 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobParamsPanel/index.tsx @@ -0,0 +1,84 @@ +import React, { FC, useMemo, useState } from 'react'; +import { Button, Dropdown, Menu, Tooltip } from '@arco-design/web-react'; +import { IconDown } from '@arco-design/web-react/icon'; +import { GlobalConfigs } from 'typings/dataset'; +import ClickToCopy from 'components/ClickToCopy'; +import { TAG_MAPPER } from '../../../shared'; +import { Tag } from 'typings/workflow'; +import PropertyList from 'components/PropertyList'; +import styled from './index.module.less'; + +type TProps = { + globalConfigs: GlobalConfigs; +}; +// 配置参数信息展示的 +const paramsTypes = [Tag.RESOURCE_ALLOCATION, Tag.INPUT_PARAM]; +const JobParamsPanel: FC<TProps> = (props: TProps) => { + const { globalConfigs = {} } = props; + const [selected, setSelected] = useState(''); + + const selectRole = useMemo(() => { + if (!selected && globalConfigs && Object.keys(globalConfigs).length) { + return Object.keys(globalConfigs)[0]; + } + return selected; + }, [globalConfigs, selected]); + + const panelInfos = useMemo(() => { + return paramsTypes.map((paramsType) => { + const paramInfo: any = { + title: TAG_MAPPER[paramsType], + properties: [], + }; + if (!globalConfigs || !selectRole) { + return []; + } + paramInfo.properties = globalConfigs[selectRole].variables + .filter((variable) => variable.tag === paramsType) + .map((item) => ({ + label: item.name, + value: ( + <Tooltip + position="left" + content={<ClickToCopy text={item.value}>{item.value}</ClickToCopy>} + > + <span>{item.value}</span> + </Tooltip> + ), + })); + return paramInfo; + }); + }, [globalConfigs, selectRole]); + + const dropList = () => { + return ( + <Menu onClickMenuItem={(key) => setSelected(key)}> + {Object.keys(globalConfigs).map((item) => { + return <Menu.Item key={item}>{item}</Menu.Item>; + })} + </Menu> + ); + }; + return ( + <div> + <div className={styled.params_panel_header}> + <h3 className={styled.title}>参数信息</h3> + <Dropdown droplist={dropList()} position="bl"> + <Button size={'mini'} type="text"> + {selectRole} <IconDown /> + </Button> + </Dropdown> + </div> + <div className={styled.params_panel_container}> + {panelInfos.map((item, index) => ( + <div className={styled.params_panel_item_wrapper} key={index}> + <span className={styled.params_panel_label}>{item.title}</span> + <PropertyList properties={item.properties} cols={2} /> + </div> + ))} + </div> + </div> + ); +}; + +export default JobParamsPanel; diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobStageItemContent/index.module.less b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobStageItemContent/index.module.less new file mode 100644 index 000000000..841d3e5ae --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobStageItemContent/index.module.less @@ -0,0 +1,13 @@ +@import '~styles/mixins.less'; +.job_stage_item_wrapper{ + padding: 0 20px; + .title{ + font-size: 14px; + line-height: 22px; + margin: 20px 0; + } +} + +.export_path_text{ + .MixinEllipsis(); +} diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobStageItemContent/index.tsx b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobStageItemContent/index.tsx new file mode 100644 index 000000000..30d02c7df --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/JobStageItemContent/index.tsx @@ -0,0 +1,224 @@ +import React, { useMemo, FC, ReactElement } from 'react'; +import { useQuery } from 'react-query'; +import { useGetCurrentProjectId } from 'hooks'; +import { fetchDatasetJobStageById, fetchDataBatchById } from 'services/dataset'; +import { useParams } from 'react-router'; +import { Message, Spin, Tooltip } from '@arco-design/web-react'; +import PropertyList from 'components/PropertyList'; +import WorkFlowPods from '../WorkFlowPods'; +import JobParamsPanel from '../JobParamsPanel'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import { DatasetJobState, DatasetKindLabel } from 'typings/dataset'; +import { getIntersectionRate } from 'shared/dataset'; +import { formatTimeCount, formatTimestamp } from 'shared/date'; +import { Link } from 'react-router-dom'; +import dayjs from 'dayjs'; +import CountTime from 'components/CountTime'; +import { DatasetDetailSubTabs } from 'views/Datasets/DatasetDetail'; +import ClickToCopy from 'components/ClickToCopy'; +import { CONSTANTS } from 'shared/constants'; +import styled from './index.module.less'; + +type Props = { + jobStageId: ID; + batchId: ID; + isProcessed: boolean; + isJoin: boolean; + isExport: boolean; + importDatasetId: ID | null; +}; +const JobStageItemContent: FC<Props> = function (props: Props) { + const { dataset_id, job_id } = useParams<{ dataset_id: string; job_id: string }>(); + const { jobStageId, batchId, isProcessed, isJoin, isExport, importDatasetId } = props; + const projectId = useGetCurrentProjectId(); + const { isFetching, data } = useQuery( + ['fetchDatasetJobStageById', projectId, job_id, jobStageId], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchDatasetJobStageById(projectId, job_id, jobStageId); + }, + { + retry: 2, + enabled: Boolean(job_id) && Boolean(jobStageId), + }, + ); + const { isFetching: isBatchFetch, data: batchData } = useQuery( + ['fetchDataBatchById', dataset_id, batchId], + () => { + return fetchDataBatchById(dataset_id, batchId); + }, + { + retry: 2, + enabled: Boolean(dataset_id) && Boolean(batchId), + }, + ); + const { + state, + started_at = 0, + finished_at = 0, + input_data_batch_num_example = 0, + output_data_batch_num_example = 0, + workflow_id, + global_configs, + } = data?.data || {}; + const { name, path } = batchData?.data || {}; + const isRunning = state + ? [DatasetJobState.PENDING, DatasetJobState.RUNNING].includes(state) + : false; + const basicInfo = useMemo(() => { + function TimeRender(prop: { time: DateTime }) { + const { time } = prop; + return <span>{time <= 0 ? '-' : formatTimestamp(time)}</span>; + } + function RunningTimeRender(prop: { start: DateTime; finish: DateTime; isRunning: boolean }) { + const { start, finish, isRunning } = prop; + if (isRunning) { + return start <= 0 ? ( + <span>待运行</span> + ) : ( + <CountTime time={dayjs().unix() - start} isStatic={false} /> + ); + } + return <span>{finish - start <= 0 ? '-' : formatTimeCount(finish - start)}</span>; + } + const basicInfoOptions: Array<{ + label: string; + value: ReactElement | string | number; + }> = [ + { + label: '任务状态', + value: renderDatasetJobState(state), + }, + { + label: '开始时间', + value: <TimeRender time={started_at} />, + }, + { + label: '结束时间', + value: <TimeRender time={finished_at} />, + }, + { + label: '运行时长', + value: <RunningTimeRender start={started_at} finish={finished_at} isRunning={isRunning} />, + }, + ]; + if (isExport) { + basicInfoOptions.push( + { + label: '导出批次', + value: ( + <Link + to={`/datasets/${ + isProcessed ? DatasetKindLabel.PROCESSED : DatasetKindLabel.RAW + }/detail/${importDatasetId}/${DatasetDetailSubTabs.Databatch}`} + > + {name} + </Link> + ), + }, + { + label: '导出路径', + value: ( + <ClickToCopy text={path || ''}> + <Tooltip content={path}> + <div className={styled.export_path_text}>{path || CONSTANTS.EMPTY_PLACEHOLDER}</div> + </Tooltip> + </ClickToCopy> + ), + }, + ); + } else { + basicInfoOptions.push( + { + label: '处理批次', + value: ( + <Link + to={`/datasets/${ + isProcessed ? DatasetKindLabel.PROCESSED : DatasetKindLabel.RAW + }/detail/${dataset_id}/${DatasetDetailSubTabs.Databatch}`} + > + {name} + </Link> + ), + }, + { + label: '输入样本量', + value: input_data_batch_num_example, + }, + { + label: '输出样本量', + value: output_data_batch_num_example, + }, + ); + } + if (isJoin) { + basicInfoOptions.push({ + label: '求交率', + value: getIntersectionRate({ + input: input_data_batch_num_example, + output: output_data_batch_num_example, + }), + }); + } + return basicInfoOptions; + }, [ + state, + started_at, + finished_at, + input_data_batch_num_example, + output_data_batch_num_example, + isRunning, + name, + path, + dataset_id, + isProcessed, + isJoin, + isExport, + importDatasetId, + ]); + return ( + <Spin loading={isFetching || isBatchFetch}> + <div className={styled.job_stage_item_wrapper}> + <h3 className={styled.title}>基本信息</h3> + <PropertyList properties={basicInfo} cols={4} /> + {global_configs && !isExport && ( + <JobParamsPanel globalConfigs={global_configs.global_configs} /> + )} + <WorkFlowPods workFlowId={workflow_id} /> + </div> + </Spin> + ); + + function renderDatasetJobState(state: DatasetJobState | undefined) { + let text: string; + let type: StateTypes; + switch (state) { + case DatasetJobState.SUCCEEDED: + text = '成功'; + type = 'success'; + break; + case DatasetJobState.PENDING: + case DatasetJobState.RUNNING: + text = '运行中'; + type = 'processing'; + break; + case DatasetJobState.FAILED: + text = '失败'; + type = 'error'; + break; + case DatasetJobState.STOPPED: + text = '已停止'; + type = 'error'; + break; + default: + text = '未知'; + type = 'default'; + } + return <StateIndicator text={text} type={type} />; + } +}; + +export default JobStageItemContent; diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/WorkFlowPods/index.module.less b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/WorkFlowPods/index.module.less new file mode 100644 index 000000000..d8dcc4007 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/WorkFlowPods/index.module.less @@ -0,0 +1,61 @@ +.workflow_pods_header{ + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + margin: 15px 0; + :global(.arco-radio-button) { + &:after { + background-color: unset; + } + } +} + +.job_detail_more_title{ + color: #4e5969; + font-size: 12px; + +} +.job_detail_more_content{ + color: #1d2129; + font-size: 12px; +} + +.job_detail_more_link{ + display: inline-block; + font-weight: 400; + font-size: 12px; + line-height: 20px; + margin-bottom: 12px; +} + +.job_detail_more_button{ + margin-left: auto; + font-size: 12px; +} + +.job_detail_state_indicator{ + padding-left: 16px; + position: relative; + .dot { + display: inline-block; + position: absolute; + top: 4px; + left: 2px; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: rgb(245, 63, 63); + } +} + +.job_detail_error_wrapper{ + max-height: 300px; + overflow: scroll; + .job_detail_error_title{ + color: #fff; + } + .job_detail_error_item{ + margin-bottom: 10px; + } +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/WorkFlowPods/index.tsx b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/WorkFlowPods/index.tsx new file mode 100644 index 000000000..241471832 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/WorkFlowPods/index.tsx @@ -0,0 +1,218 @@ +import React, { useMemo, useState } from 'react'; +import { LabelStrong } from 'styles/elements'; +import { Button, Message, Popover, Radio, Tooltip } from '@arco-design/web-react'; +import { useQuery } from 'react-query'; +import { useGetCurrentProjectId, useTablePaginationWithUrlState } from 'hooks'; +import { fetchJobById, getWorkflowDetailById } from 'services/workflow'; +import { TIME_INTERVAL } from 'shared/constants'; +import { get } from 'lodash-es'; +import { Pod, PodState } from 'typings/job'; +import { Table } from '@arco-design/web-react'; +import ClickToCopy from 'components/ClickToCopy'; +import StateIndicator from 'components/StateIndicator'; +import { getPodState, podStateFilters } from 'views/Workflows/shared'; +import { formatTimestamp } from 'shared/date'; +import { Link } from 'react-router-dom'; +import styled from './index.module.less'; +type TWorkFlowPods = { + workFlowId?: ID; +}; + +export default function WorkFlowPods(prop: TWorkFlowPods) { + const { workFlowId } = prop; + const { paginationProps, reset } = useTablePaginationWithUrlState({ + urlStateOption: { navigateMode: 'replace' }, + }); + const projectId = useGetCurrentProjectId(); + const [selectJobId, setSelectJobId] = useState<ID>(); + const workFlowDetail = useQuery( + ['fetch_workflow_detail', projectId, workFlowId], + () => { + if (!workFlowId) { + return Promise.resolve({ data: {} }); + } + if (!projectId) { + Message.info('请选择工作区'); + return Promise.resolve({ data: {} }); + } + return getWorkflowDetailById(workFlowId!, projectId); + }, + { + cacheTime: 1, + refetchInterval: TIME_INTERVAL.CONNECTION_CHECK, + onSuccess: (data) => { + const jobIds = get(data, 'data.job_ids') || []; + if (jobIds.length) { + setSelectJobId((pre) => { + return jobIds.indexOf(selectJobId) > -1 ? pre : jobIds[0]; + }); + } + }, + }, + ); + + const jobDetail = useQuery( + ['fetchJobById', selectJobId], + () => fetchJobById(Number(selectJobId)), + { + enabled: Boolean(selectJobId), + }, + ); + + const jobList = useMemo(() => { + if (!workFlowDetail.data) { + return []; + } + const jobIds = get(workFlowDetail.data, 'data.job_ids') || []; + const jobNames = get(workFlowDetail.data, 'data.config.job_definitions') || []; + const jobs = get(workFlowDetail.data, 'data.jobs') || []; + return jobIds.map((item: ID, index: number) => { + const { error_message, state } = jobs[index]; + return { + label: jobNames[index].name, + value: item, + hasError: + error_message && + (error_message.app || JSON.stringify(error_message.pods) !== '{}') && + state !== 'COMPLETED', + errorMessage: error_message, + }; + }); + }, [workFlowDetail.data]); + + const jobData = useMemo(() => { + return get(jobDetail, 'data.data.pods') || ([] as Pod[]); + }, [jobDetail]); + + const handleOnChangeJob = (val: ID) => { + setSelectJobId(() => val); + reset(); + }; + + const columns = [ + { + title: 'Pod', + dataIndex: 'name', + key: 'name', + width: 400, + render: (val: string) => { + return <ClickToCopy text={val}>{val}</ClickToCopy>; + }, + }, + { + title: '运行状态', + dataIndex: 'state', + key: 'state', + ...podStateFilters, + width: 200, + render: (_: PodState, record: Pod) => { + return <StateIndicator {...getPodState(record)} />; + }, + }, + { + title: '创建时间', + dataIndex: 'creation_timestamp', + key: 'creation_timestamp', + width: 150, + sorter(a: Pod, b: Pod) { + return a.creation_timestamp - b.creation_timestamp; + }, + render: (val: number) => { + return formatTimestamp(val); + }, + }, + { + title: '操作', + dataIndex: 'actions', + key: 'actions', + width: 150, + render: (_: any, record: Pod) => { + return ( + <Link target={'_blank'} to={`/logs/pod/${selectJobId}/${record.name}`}> + 日志 + </Link> + ); + }, + }, + ]; + + return ( + <> + <div className={styled.workflow_pods_header}> + <LabelStrong fontSize={14} isBlock={true}> + 实例信息 + </LabelStrong> + <Radio.Group onChange={handleOnChangeJob} size="small" type="button" value={selectJobId}> + {jobList.map((item: any) => { + return ( + <Radio key={item.value} value={item.value}> + {item.hasError ? ( + <Tooltip content={renderErrorMessage(item.errorMessage)}> + <span className={styled.job_detail_state_indicator}> + <span className={styled.dot} /> + {item.label} + </span> + </Tooltip> + ) : ( + item.label + )} + </Radio> + ); + })} + </Radio.Group> + <Popover + trigger="hover" + position="br" + content={ + <span> + <div className={styled.job_detail_more_title}>工作流</div> + <Link + className={styled.job_detail_more_link} + to={`/workflow-center/workflows/${workFlowId}`} + > + 点击查看工作流 + </Link> + <div className={styled.job_detail_more_title}>工作流 ID</div> + <div className={styled.job_detail_more_content}>{workFlowId}</div> + </span> + } + > + <Button className={styled.job_detail_more_button} type="text"> + 更多信息 + </Button> + </Popover> + </div> + {jobList.length > 0 ? ( + <Table + rowKey={'name'} + className={'custom-table custom-table-left-side-filter'} + loading={jobDetail.isFetching} + data={jobData} + columns={columns} + pagination={{ + ...paginationProps, + }} + onChange={(pagination, sorter, filters, extra) => { + if (extra.action === 'filter') { + reset(); + } + }} + /> + ) : null} + </> + ); + function renderErrorMessage(errorMessage: any) { + const { app, pods } = errorMessage; + return ( + <div className={styled.job_detail_error_wrapper}> + <h3 className={styled.job_detail_error_title}>Main Error: {app}</h3> + {Object.entries(pods).map(([pod, error], index) => ( + <div className={styled.job_detail_error_item} key={index}> + <div>Pod: {pod}</div> + <div>Error: {error}</div> + </div> + ))} + </div> + ); + } +} diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/index.module.less b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/index.module.less new file mode 100644 index 000000000..df0c7fca4 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/index.module.less @@ -0,0 +1,111 @@ +.dataset_job_stage_wrapper{ + flex: 1; + display: flex; + justify-content: flex-start; +} + +.job_stage_list_wrapper{ + height: 100%; + border-right: 1px solid #e5e8ef; + position: relative; + .job_stage_list{ + overflow: scroll; + height: calc(100% - 40px); + .job_stage_list_header{ + color: #1D2129; + font-weight: 500; + font-size: 14px; + height: 60px; + line-height: 60px; + padding-left: 20px; + } + .job_stage_list_item{ + height: 56px; + border-top: 1px solid #E5E8EF; + padding-top: 8px; + padding-left: 20px; + position: relative; + cursor: pointer; + & .job_stage_list_item_title{ + font-size: 12px; + line-height: 20px; + height: 20px; + } + &.active { + background-color: #F2F3F8; + & .job_stage_list_item_title{ + font-size: 12px; + line-height: 20px; + height: 20px; + color: #1664FF; + } + } + } + } + .job_stage_list_count{ + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 40px; + padding: 10px 0; + z-index: 10; + text-align: center; + background: #fff; + font-weight: 400; + font-size: 12px; + line-height: 20px; + color: #86909C; + } + .collapse { + transition: 0.1s background-color; + position: absolute; + top: 285px; + left: 200px; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + padding: 2px 0 1px; + border-radius: 50%; + cursor: pointer; + background: #FFFFFF; + border: 0.857143px solid #E5E8EF; + box-shadow: 0px 0px 7px #F2F3F5; + } + .is_reverse { + transition: 0.1s background-color cubic-bezier(0.4, 0, 0.2, 1); + position: absolute; + left: 5px; + top: 285px; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + transform: rotate(180deg); + padding: 1px 0 2px; + border-radius: 50%; + cursor: pointer; + background: #FFFFFF; + border: 0.857143px solid #E5E8EF; + box-shadow: 0px 0px 7px #F2F3F5; + } +} + +.job_stage_content{ + height: 100%; + max-height: 600px; + flex: 1; + :global(.arco-spin) { + height: 100%; + display: block; + } + :global(.arco-spin-children){ + height: 100%; + overflow: scroll; + } +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/index.tsx b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/index.tsx new file mode 100644 index 000000000..63e343974 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobStageDetail/index.tsx @@ -0,0 +1,152 @@ +import React, { + useState, + useMemo, + ForwardRefRenderFunction, + forwardRef, + useImperativeHandle, +} from 'react'; +import { useQuery } from 'react-query'; +import { Left } from 'components/IconPark'; +import { DatasetJobStage, DatasetJobState } from 'typings/dataset'; +import { useGetCurrentProjectId } from 'hooks'; +import { fetchDatasetJobStageList } from 'services/dataset'; +import { useParams, useLocation } from 'react-router'; +import { Message } from '@arco-design/web-react'; +import StateIndicator, { StateTypes } from 'components/StateIndicator'; +import JobStageItemContent from './JobStageItemContent'; +import qs from 'qs'; +import styled from './index.module.less'; + +type ExposedRef = { + refetch: () => void; +}; + +type Props = { + isProcessed: boolean; + isJoin: boolean; // 表示是否是求交任务 + isExport: boolean; // 表示是否是导出任务 + importDatasetId: ID | null; +}; +const JobStageDetail: ForwardRefRenderFunction<ExposedRef, Props> = function ( + props: Props, + parentRef, +) { + const { job_id } = useParams<{ job_id: string }>(); + const projectId = useGetCurrentProjectId(); + const [collapsed, setCollapsed] = useState(true); + const [total, setTotal] = useState(0); + const [activeJobStage, setActiveJobStage] = useState<DatasetJobStage>(); + const location = useLocation(); + const query = location.search || ''; + const queryObject = qs.parse(query.slice(1)) || {}; + const listQuery = useQuery( + ['fetchDatasetJobStageList', projectId, job_id], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchDatasetJobStageList(projectId!, job_id); + }, + { + retry: 2, + onSuccess: (res) => { + if (!res) return; + const { page_meta, data } = res || {}; + setTotal((pre) => page_meta?.total_items || pre); + if (queryObject.stageId) { + const activeJobStage = data.find((item) => `${item.id}` === queryObject.stageId); + setActiveJobStage(activeJobStage || data[0]); + } else { + setActiveJobStage(data[0]); + } + }, + }, + ); + + const list = useMemo(() => { + return listQuery.data?.data || []; + }, [listQuery.data]); + + useImperativeHandle(parentRef, () => { + return { + refetch: listQuery.refetch, + }; + }); + + return ( + <div className={styled.dataset_job_stage_wrapper}> + <div + className={styled.job_stage_list_wrapper} + style={{ + width: collapsed ? '212px' : '0px', + }} + > + {collapsed && ( + <div className={styled.job_stage_list}> + <div className={styled.job_stage_list_header}>任务</div> + {list.map((item, index) => ( + <div + key={index} + className={`${styled.job_stage_list_item} ${ + item.id === activeJobStage?.id ? styled.active : '' + }`} + onClick={() => { + handleChangeJobStage(item); + }} + > + <div className={styled.job_stage_list_item_title}>{item.name}</div> + {renderDatasetJobState(item)} + </div> + ))} + <div className={styled.job_stage_list_count}>{total}个记录</div> + </div> + )} + <div + onClick={() => setCollapsed(!collapsed)} + className={collapsed ? styled.collapse : styled.is_reverse} + > + <Left /> + </div> + </div> + <div className={styled.job_stage_content}> + {activeJobStage && ( + <JobStageItemContent + {...props} + jobStageId={activeJobStage.id} + batchId={activeJobStage.output_data_batch_id} + /> + )} + </div> + </div> + ); + function renderDatasetJobState(stage: DatasetJobStage) { + let text: string; + let type: StateTypes; + switch (stage.state) { + case DatasetJobState.SUCCEEDED: + text = '成功'; + type = 'success'; + break; + case DatasetJobState.PENDING: + case DatasetJobState.RUNNING: + text = '运行中'; + type = 'processing'; + break; + case DatasetJobState.FAILED: + text = '失败'; + type = 'error'; + break; + case DatasetJobState.STOPPED: + text = '已停止'; + type = 'error'; + break; + } + return <StateIndicator text={text} type={type} />; + } + function handleChangeJobStage(stage: DatasetJobStage) { + setActiveJobStage(stage); + } +}; + +export default forwardRef(JobStageDetail); diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobTitle/index.module.less b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobTitle/index.module.less new file mode 100644 index 000000000..7c2b648ab --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobTitle/index.module.less @@ -0,0 +1,14 @@ +.job_title_icon{ + height: 44px; + width: 44px; + border-radius: 4px; + background: #686a72; + display: flex; + justify-content: center; + align-items: center; + font-size: 16px; + color: #ffffff; +} +.job_title_name{ + margin: 0 12px 0 12px; +} diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobTitle/index.tsx b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobTitle/index.tsx new file mode 100644 index 000000000..2eec0ac92 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/JobTitle/index.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { DatasetJob, DatasetJobListItem } from 'typings/dataset'; +import StateIndicator from 'components/StateIndicator'; +import GridRow from 'components/_base/GridRow'; +import { getDatasetJobState, getDatasetJobType } from 'shared/dataset'; +import TaskActions from '../../TaskList/TaskActions'; +import { Space } from '@arco-design/web-react'; +import styled from './index.module.less'; + +type TJobTitleProp = { + data: DatasetJob; + onStop?: () => void; + onDelete?: () => void; + id: ID; +}; + +export default function JobTitle(prop: TJobTitleProp) { + const { data = {} as DatasetJobListItem, onStop, onDelete, id } = prop; + const jobData: DatasetJobListItem = { + name: '', + uuid: '', + project_id: data.project_id, + kind: data.kind, + state: data.state, + result_dataset_id: '', + result_dataset_name: '', + id, + created_at: 0, + coordinator_id: 0, + has_stages: false, + }; + return ( + <GridRow + justify={'space-between'} + style={{ + margin: `20px 20px 0 20px`, + }} + > + <Space> + <span className={styled.job_title_icon}>{getDatasetJobType(jobData.kind)}</span> + <span className={styled.job_title_name}>{data.name}</span> + <StateIndicator {...getDatasetJobState(jobData)} /> + </Space> + <TaskActions data={jobData} onDelete={onDelete} onStop={onStop} /> + </GridRow> + ); +} diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/index.module.less b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/index.module.less new file mode 100644 index 000000000..37b12926a --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/index.module.less @@ -0,0 +1,32 @@ +.dataset_job_detail_container{ + display: flex; + flex-direction: row; + justify-content: space-between; + :global(.arco-spin) { + flex-grow: 1; + margin-right: 12px; + } +} + +.data_detail_tab_pane{ + display: grid; +} + +.data_detail_tab{ + margin-bottom: 0 !important; +} + +.data_job_task_detail{ + padding: 20px 20px; + flex: 1; + :global(.arco-spin-children){ + height: 100%; + > div{ + height: 100%; + } + } + :global(.react-flow-container){ + height: 100%; + margin: 0px; + } +} \ No newline at end of file diff --git a/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/index.tsx b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/index.tsx new file mode 100644 index 000000000..b9da09790 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/NewDatasetJobDetail/index.tsx @@ -0,0 +1,216 @@ +import React, { FC, useMemo, useState, useRef } from 'react'; +import { Tabs, Message } from '@arco-design/web-react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import TaskDetail, { NodeType } from '../TaskDetail'; +import { useParams } from 'react-router-dom'; +import JobTitle from './JobTitle'; +import { useQuery } from 'react-query'; +import { fetchDatasetJobDetail, fetchDatasetDetail, fetchDatasetList } from 'services/dataset'; +import { useGetCurrentProjectId } from 'hooks'; +import { DatasetJob, DatasetKindLabel, DatasetKindLabelCapitalMapper } from 'typings/dataset'; +import JobBasicInfo from './JobBasicInfo'; +import JobStageDetail from './JobStageDetail'; +import { DatasetDetailSubTabs } from '../DatasetDetail'; +import { useHistory, Route, Redirect } from 'react-router'; +import BackButton from 'components/BackButton'; +import { + isDataJoin, + isDataExport, + FILTER_OPERATOR_MAPPER, + filterExpressionGenerator, +} from '../shared'; +import { useGetCurrentDomainName } from 'hooks'; +import styled from './index.module.less'; + +const { TabPane } = Tabs; + +type TProps = {}; + +export enum JobDetailSubTabs { + TaskProcess = 'process', + TaskList = 'list', +} + +const DatasetJobDetail: FC<TProps> = function (props: TProps) { + const { job_id, subtab, dataset_id } = useParams<{ + job_id: string; + subtab: string; + dataset_id: string; + }>(); + const currentDomainName = useGetCurrentDomainName(); + const projectId = useGetCurrentProjectId(); + const history = useHistory(); + const jobStageDetailRef = useRef<any>(); + const [activeTab, setActiveTab] = useState(subtab || JobDetailSubTabs.TaskProcess); + const [jobBasicInfo, setJobBasicInfo] = useState( + {} as { + coordinatorId: ID; + createTime: DateTime; + creator_username: string; + }, + ); + // ======= Dataset query ============ + const query = useQuery(['fetchDatasetDetail', dataset_id], () => fetchDatasetDetail(dataset_id), { + refetchOnWindowFocus: false, + enabled: Boolean(dataset_id), + onError(e: any) { + Message.error(e.message); + }, + }); + // 表示任务的是是否是原始数据集, 用于后续跳转详情页 + const isProcessed = Boolean( + DatasetKindLabelCapitalMapper[DatasetKindLabel.PROCESSED] === query.data?.data.dataset_kind, + ); + const jobDetailQuery = useQuery( + ['fetch_dataset_jobDetail', projectId, job_id], + () => fetchDatasetJobDetail(projectId!, job_id!), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: Boolean(projectId && job_id), + onSuccess: (data) => { + const { coordinator_id, created_at, creator_username } = data.data || {}; + setJobBasicInfo({ + coordinatorId: coordinator_id, + createTime: created_at, + creator_username, + }); + }, + }, + ); + + const jobDetail = useMemo(() => { + if (!jobDetailQuery.data) { + return {}; + } + return jobDetailQuery.data.data; + }, [jobDetailQuery.data]); + + const isJoin = isDataJoin(jobDetailQuery.data?.data.kind); + const isExport = isDataExport(jobDetailQuery.data?.data.kind); + // 导出任务的导入数据集uuid, 服务端担心直接加导入数据集的id会有问题? + const inportDatasetUuid = useMemo(() => { + const rawDatasetObject = jobDetailQuery.data?.data?.global_configs?.global_configs ?? {}; + let inportDatasetUuid: string = ''; + Object.keys(rawDatasetObject).forEach((key, index) => { + const rawDatasetInfo = rawDatasetObject[key]; + if (currentDomainName.indexOf(key) > -1) { + inportDatasetUuid = rawDatasetInfo.dataset_uuid; + } + }); + return inportDatasetUuid; + }, [jobDetailQuery, currentDomainName]); + + const datasetListQuery = useQuery( + ['fetchDatasetList', inportDatasetUuid], + () => + fetchDatasetList({ + filter: filterExpressionGenerator( + { + uuid: inportDatasetUuid, + }, + FILTER_OPERATOR_MAPPER, + ), + }), + { + enabled: Boolean(inportDatasetUuid) && Boolean(isExport), + refetchOnWindowFocus: false, + retry: 2, + }, + ); + + const importDatasetId = useMemo(() => { + if (!datasetListQuery.data?.data) return null; + return datasetListQuery.data?.data?.[0]?.id; + }, [datasetListQuery]); + + const backToList = () => { + history.goBack(); + }; + /** IF no subtab be set, defaults to preview */ + if (!subtab) { + return ( + <Redirect + to={`/datasets/${dataset_id}/new/job_detail/${job_id}/${JobDetailSubTabs.TaskProcess}`} + /> + ); + } + + return ( + <SharedPageLayout + title={<BackButton onClick={backToList}>任务详情</BackButton>} + cardPadding={0} + > + <JobTitle id={job_id} data={jobDetail as DatasetJob} onStop={onStop} onDelete={backToList} /> + <JobBasicInfo {...jobBasicInfo} /> + <Tabs activeTab={activeTab} onChange={onSubtabChange} className={styled.data_detail_tab}> + <TabPane + className={styled.data_detail_tab_pane} + title="任务流程" + key={JobDetailSubTabs.TaskProcess} + /> + <TabPane + className={styled.data_detail_tab_pane} + title="任务列表" + key={JobDetailSubTabs.TaskList} + /> + </Tabs> + <Route + path={`/datasets/:dataset_id/new/job_detail/:job_id/${JobDetailSubTabs.TaskProcess}`} + exact + render={(props) => { + return ( + <TaskDetail + className={styled.data_job_task_detail} + middleJump={false} + datasetJobId={job_id} + isShowTitle={false} + isShowRatio={!jobDetailQuery.data?.data?.has_stages} + onNodeClick={(node, datasetMapper = {}) => { + if (node.type === NodeType.DATASET_PROCESSED) { + const datasetInfo = datasetMapper[node?.data?.dataset_uuid ?? '']; + datasetInfo && + datasetInfo.id && + history.push( + `/datasets/processed/detail/${datasetInfo.id}/${DatasetDetailSubTabs.PreviewData}`, + ); + } + }} + // TODO: pass error message when state_frontend = DatasetStateFront.FAILED, + errorMessage="" + // TODO: confirm the dataset is processed or not by the state of job + isProcessedDataset={true} + /> + ); + }} + /> + <Route + path={`/datasets/:dataset_id/new/job_detail/:job_id/${JobDetailSubTabs.TaskList}`} + exact + render={(props) => { + return ( + <JobStageDetail + ref={jobStageDetailRef} + isProcessed={isProcessed} + isJoin={isJoin} + isExport={isExport} + importDatasetId={importDatasetId} + /> + ); + }} + /> + </SharedPageLayout> + ); + + function onSubtabChange(val: string) { + setActiveTab(val as JobDetailSubTabs); + history.replace(`/datasets/${dataset_id}/new/job_detail/${job_id}/${val}`); + } + + function onStop() { + jobDetailQuery.refetch(); + jobStageDetailRef.current.refetch(); + } +}; + +export default DatasetJobDetail; diff --git a/web_console_v2/client/src/views/Datasets/TaskDetail/DatasetNode/index.module.less b/web_console_v2/client/src/views/Datasets/TaskDetail/DatasetNode/index.module.less new file mode 100644 index 000000000..18b89c64e --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskDetail/DatasetNode/index.module.less @@ -0,0 +1,28 @@ +.container{ + width: 260px; + min-height: 56px; + padding: 8px 12px; + background: #fff; + border-radius: 4px; + border: 1px solid #dde2e6; +} + +.active{ + border: 1px dashed var(--primaryColor); +} + +.can_click{ + cursor: pointer; +} + +.can_click_label{ + color: var(--primaryColor) !important; +} + +.handle_dot{ + width: 12px; + height: 12px; + border: 1px solid var(--backgroundColorGray); + border-radius: 50%; + background: #fff; +} diff --git a/web_console_v2/client/src/views/Datasets/TaskDetail/DatasetNode/index.tsx b/web_console_v2/client/src/views/Datasets/TaskDetail/DatasetNode/index.tsx new file mode 100644 index 000000000..aa63b6ab2 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskDetail/DatasetNode/index.tsx @@ -0,0 +1,92 @@ +import React, { FC, useMemo, useState } from 'react'; +import { Handle, Position, NodeComponentProps } from 'react-flow-renderer'; +import WhichDataset from 'components/WhichDataset'; +import { Label, LabelStrong } from 'styles/elements'; +import { NodeType } from '..'; +import WhichParticipantDataset from 'components/WhichParticipantDataset'; +import { Dataset } from 'typings/dataset'; +import styled from './index.module.less'; + +export type Props = NodeComponentProps<{ + title: string; + dataset_name: string; + dataset_uuid?: string; + onAPISuccess?: (uuid: string, data?: any) => void; + isActive?: boolean; +}>; + +const DatasetNode: FC<Props> = ({ data, targetPosition, sourcePosition, type }) => { + const [myDataset, setMyDataset] = useState<null | Dataset>(); + const nameJsx = useMemo(() => { + let jsx: React.ReactNode = ''; + switch (type) { + case NodeType.DATASET_MY: + case NodeType.DATASET_PROCESSED: + case NodeType.UPLOAD: + case NodeType.DOWNLOAD: + jsx = data?.dataset_uuid ? ( + <WhichDataset.UUID + displayKey={type === NodeType.DOWNLOAD ? 'path' : 'name'} + uuid={data.dataset_uuid} + onAPISuccess={(apiData) => { + data?.onAPISuccess?.(data.dataset_uuid!, apiData); + setMyDataset(apiData); + }} + /> + ) : ( + '' + ); + break; + case NodeType.DATASET_PARTICIPANT: + jsx = data?.dataset_uuid ? ( + <WhichParticipantDataset + uuid={data.dataset_uuid} + onAPISuccess={(apiData) => { + data?.onAPISuccess?.(data.dataset_uuid!, apiData); + }} + emptyText="对方已撤销发布" + /> + ) : ( + '' + ); + break; + case NodeType.LIGHT_CLIENT: + jsx = '本地上传'; + break; + default: + break; + } + + return jsx; + }, [data, type]); + + const isCanClick = + (type === NodeType.DATASET_MY && Boolean(myDataset)) || type === NodeType.DATASET_PROCESSED; + return ( + <div + className={`${styled.container} ${isCanClick && styled.can_click} ${ + data?.isActive && styled.active + }`} + > + <Label isBlock>{data.title}</Label> + <LabelStrong + isBlock + fontSize={14} + className={isCanClick ? styled.can_click_label : ''} + style={{ + wordBreak: 'break-all', + }} + > + {nameJsx} + </LabelStrong> + {targetPosition && ( + <Handle className={styled.handle_dot} type="target" position={Position.Left} /> + )} + {sourcePosition && ( + <Handle className={styled.handle_dot} type="source" position={Position.Right} /> + )} + </div> + ); +}; + +export default DatasetNode; diff --git a/web_console_v2/client/src/views/Datasets/TaskDetail/ImportNode/index.module.less b/web_console_v2/client/src/views/Datasets/TaskDetail/ImportNode/index.module.less new file mode 100644 index 000000000..1e6d55464 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskDetail/ImportNode/index.module.less @@ -0,0 +1,20 @@ +.container{ + width: 260px; + min-height: 56px; + padding: 8px 12px; + background: #fff; + border-radius: 4px; + border: 1px solid #dde2e6; +} + +.active{ + border: 1px dashed var(--primaryColor); +} + +.handle_dot{ + width: 12px; + height: 12px; + border: 1px solid var(--backgroundColorGray); + border-radius: 50%; + background: #fff; +} diff --git a/web_console_v2/client/src/views/Datasets/TaskDetail/ImportNode/index.tsx b/web_console_v2/client/src/views/Datasets/TaskDetail/ImportNode/index.tsx new file mode 100644 index 000000000..9b23a1d40 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskDetail/ImportNode/index.tsx @@ -0,0 +1,97 @@ +import React, { FC, useMemo } from 'react'; +import { Handle, Position, NodeComponentProps } from 'react-flow-renderer'; +import { useQuery } from 'react-query'; +import { fetchDatasetList, fetchDataSourceDetail } from 'services/dataset'; +import { Label, LabelStrong } from 'styles/elements'; +import { Spin } from '@arco-design/web-react'; +import { FILTER_OPERATOR_MAPPER, filterExpressionGenerator } from '../../shared'; +import ClickToCopy from 'components/ClickToCopy'; +import styled from './index.module.less'; + +export type Props = NodeComponentProps<{ + title: string; + dataset_name: string; + dataset_uuid?: string; + onAPISuccess?: (uuid: string, data?: any) => void; + isActive?: boolean; +}>; + +export const ImportNode: FC<Props> = ({ data, targetPosition, sourcePosition, type }) => { + const datasetListQuery = useQuery( + ['fetchDatasetList', data.dataset_uuid], + () => + fetchDatasetList({ + filter: filterExpressionGenerator( + { + uuid: data.dataset_uuid, + }, + FILTER_OPERATOR_MAPPER, + ), + }), + { + enabled: Boolean(data.dataset_uuid), + refetchOnWindowFocus: false, + retry: 2, + onSuccess(res) { + data.dataset_uuid && data?.onAPISuccess?.(data.dataset_uuid, res.data?.[0] ?? undefined); + }, + }, + ); + + const dataSourceDetailQuery = useQuery( + ['data_source_detail_query', datasetListQuery], + () => + fetchDataSourceDetail({ + id: datasetListQuery.data?.data?.[0].id!, + }), + { + enabled: Boolean( + datasetListQuery.data?.data?.[0]?.id || datasetListQuery.data?.data?.[0]?.id === 0, + ), + refetchOnWindowFocus: false, + retry: 2, + }, + ); + + const [title, content] = useMemo(() => { + const localTitle = '本地上传'; + const dataSourceTitle = '数据源上传'; + const noTitle = '导入任务'; + const noContent = '数据集信息未找到'; + if (!dataSourceDetailQuery?.data?.data) { + return [noTitle, noContent]; + } + const { name, url, is_user_upload } = dataSourceDetailQuery.data.data; + const title = is_user_upload ? localTitle : `${dataSourceTitle}-${name}`; + const content = is_user_upload ? <ClickToCopy text={url}>{url}</ClickToCopy> : url; + return [title, content]; + }, [dataSourceDetailQuery]); + + return ( + <div className={`${styled.container} ${data?.isActive && styled.active}`}> + <Label + style={{ + wordBreak: 'break-all', + }} + isBlock + > + {dataSourceDetailQuery.isFetching ? <Spin /> : title} + </Label> + <LabelStrong + isBlock + fontSize={14} + style={{ + wordBreak: 'break-all', + }} + > + {dataSourceDetailQuery.isFetching ? <Spin /> : content} + </LabelStrong> + {targetPosition && ( + <Handle type="target" className={styled.handle_dot} position={Position.Left} /> + )} + {sourcePosition && ( + <Handle type="source" className={styled.handle_dot} position={Position.Right} /> + )} + </div> + ); +}; diff --git a/web_console_v2/client/src/views/Datasets/TaskDetail/TagNode/index.module.less b/web_console_v2/client/src/views/Datasets/TaskDetail/TagNode/index.module.less new file mode 100644 index 000000000..36de850e9 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskDetail/TagNode/index.module.less @@ -0,0 +1,19 @@ +.container{ + min-width: 72px; + height: 24px; + padding: 0 12px; + text-align: center; + background: #ffffff; + border: 1px solid var(--backgroundColorGray); + border-radius: 40px; + cursor: pointer; +} + +.handle_dot{ + visibility: hidden; +} + +.label{ + font-weight: 400; + font-size: 12px; +} diff --git a/web_console_v2/client/src/views/Datasets/TaskDetail/TagNode/index.tsx b/web_console_v2/client/src/views/Datasets/TaskDetail/TagNode/index.tsx new file mode 100644 index 000000000..d9463c771 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskDetail/TagNode/index.tsx @@ -0,0 +1,34 @@ +import React, { FC } from 'react'; +import { Handle, Position, NodeComponentProps } from 'react-flow-renderer'; +import { DataJobBackEndType } from 'typings/dataset'; +import styled from './index.module.less'; + +export type Props = NodeComponentProps<{ + title: string; + dataset_job_uuid: string; + workflow_uuid: string; + kind: DataJobBackEndType; + job_id: ID; +}>; + +const TagNode: FC<Props> = ({ data, targetPosition, sourcePosition }) => { + return ( + <div className={styled.container}> + <span + className={styled.label} + style={{ color: Boolean(data.job_id) ? 'var(--primaryColor)' : 'var(--textColor)' }} + title={data.title} + > + {data.title} + </span> + {targetPosition && ( + <Handle className={styled.handle_dot} type="target" position={Position.Left} /> + )} + {sourcePosition && ( + <Handle className={styled.handle_dot} type="source" position={Position.Right} /> + )} + </div> + ); +}; + +export default TagNode; diff --git a/web_console_v2/client/src/views/Datasets/TaskDetail/index.less b/web_console_v2/client/src/views/Datasets/TaskDetail/index.less new file mode 100644 index 000000000..a2b62b221 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskDetail/index.less @@ -0,0 +1,53 @@ +.react-flow-container{ + position: relative; + height: 313px; + padding: 16px 20px; + margin: 12px 0 20px; + background-color: var(--backgroundColor); + border-radius: 4px; +} + +.statistic-group-container{ + position: absolute; + left: 20px; + top: 12px; + display: flex; + flex-direction: column; +} + +.statistic-item{ + margin-top: 10px; + .arco-statistic-title { + font-size: 12px; + margin-bottom: 0; + } + .arco-statistic-value { + font-size: 20px; + font-family: 'Byte Number'; + } +} + +.statistic-rate-item{ + .arco-statistic-extra { + font-size: 12px; + margin-top: 0px; + } + .arco-statistic-value { + .arco-statistic-value-int { + font-size: 20px; + } + .arco-statistic-value-decimal { + font-size: 20px; + } + } +} + +.statistic-progress{ + .arco-progress-circle-text { + font-size: 14px; + } +} + +.task-detail-tag{ + margin: 0 12px 0 12px; +} diff --git a/web_console_v2/client/src/views/Datasets/TaskDetail/index.tsx b/web_console_v2/client/src/views/Datasets/TaskDetail/index.tsx new file mode 100644 index 000000000..798d8f676 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskDetail/index.tsx @@ -0,0 +1,537 @@ +import React, { FC, useCallback, useMemo, useRef, useState } from 'react'; +import { useHistory } from 'react-router'; +import { Alert, Message, Spin, Statistic, Tag, Progress } from '@arco-design/web-react'; +import ReactFlow, { + Controls, + Edge, + FlowElement, + isNode, + Node, + OnLoadParams, + Position, +} from 'react-flow-renderer'; +import { LabelStrong } from 'styles/elements'; +import DatasetNode from './DatasetNode'; +import TagNode from './TagNode'; + +import { fetchDatasetJobDetail } from 'services/dataset'; + +import { useQuery } from 'react-query'; +import { + useGetCurrentDomainName, + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, +} from 'hooks'; +import { DataJobBackEndTypeToLabelMap, isDataImport, isDataJoin, isDataAnalyzer } from '../shared'; +import { + DataJobBackEndType, + Dataset, + DatasetKindBackEndType, + DatasetKindLabel, + ParticipantDataset, +} from 'typings/dataset'; + +import { DatasetDetailSubTabs } from '../DatasetDetail'; +import { ParticipantType } from 'typings/participant'; +import { ImportNode } from './ImportNode'; +import { JobDetailSubTabs } from 'views/Datasets/NewDatasetJobDetail'; +import './index.less'; + +export enum NodeType { + DATASET_MY = 'dataset_my', + DATASET_PARTICIPANT = 'dataset_participant', + DATASET_PROCESSED = 'dataset_processed', + Tag = 'tag', + UPLOAD = 'upload', + DOWNLOAD = 'download', + LIGHT_CLIENT = 'light_client', +} + +export type DatasetNodeData = { + title: string; + dataset_name: string; + dataset_uuid: string; + dataset_job_uuid?: string; + workflow_uuid?: string; + isActive?: boolean; + job_id?: ID; +}; + +type Props = { + datasetId?: ID; + datasetJobId?: ID; + onNodeClick?: ( + element: FlowElement<DatasetNodeData>, + datasetMapper?: { [key: string]: Dataset }, + ) => void; + errorMessage?: string; + isProcessedDataset?: boolean; + middleJump: Boolean; + className?: string; + isOldData?: boolean; // 是否是老数据 + isShowTitle?: boolean; + isShowRatio?: boolean; // 是否展示求交率等信息 +}; + +const nodeTypes = { + [NodeType.DATASET_MY]: DatasetNode, + [NodeType.DATASET_PARTICIPANT]: DatasetNode, + [NodeType.DATASET_PROCESSED]: DatasetNode, + [NodeType.Tag]: TagNode, + [NodeType.UPLOAD]: ImportNode, + [NodeType.DOWNLOAD]: DatasetNode, + [NodeType.LIGHT_CLIENT]: DatasetNode, +}; + +export const BASE_X = 0; +export const BASE_Y = 100; +export const WIDTH_DATASET_NODE = 260; +export const WIDTH_TAG_NODE = 120; +export const WIDTH_GAP = 120; +export const HEIGHT_DATASET_NODE = 56; +export const HEIGHT_TAG_NODE = 24; + +const TaskDetail: FC<Props> = ({ + datasetId, + datasetJobId, + onNodeClick, + errorMessage, + isProcessedDataset = false, + middleJump, + className, + isOldData = false, + isShowTitle = true, + isShowRatio = true, +}) => { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + const currentDomainName = useGetCurrentDomainName(); + const participantList = useGetCurrentProjectParticipantList(); + + const [intersectionRate, setIntersectionRate] = useState(0); + const [intersectionNumber, setIntersectionNumber] = useState(0); + const [myAmountOfData, setMyAmountOfData] = useState(0); + const myDatasetUuidToInfoMap = useRef<{ + [uuid: string]: Dataset; + }>({}); + const myParticipantDatasetUuidToDatasetInfoMap = useRef<{ + [uuid: string]: ParticipantDataset; + }>({}); + const myResultDatasetUuidToInfoMap = useRef<{ + [uuid: string]: Dataset; + }>({}); + + const getLeftNodeName = (jobKind: DataJobBackEndType) => { + switch (jobKind) { + case DataJobBackEndType.EXPORT: + return '结果数据集'; + case DataJobBackEndType.IMPORT_SOURCE: + return '本地上传'; + default: + return '我方数据集'; + } + }; + + const getRightNodeName = (jobKind: DataJobBackEndType) => { + switch (jobKind) { + case DataJobBackEndType.EXPORT: + return '导出地址'; + case DataJobBackEndType.IMPORT_SOURCE: + return '原始数据集'; + default: + return '结果数据集'; + } + }; + + const datasetJobDetailQuery = useQuery( + ['fetchDatasetJobDetail', projectId, datasetJobId], + () => fetchDatasetJobDetail(projectId!, datasetJobId!), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: Boolean(projectId && datasetJobId), + onSuccess(res) { + const { input_data_batch_num_example, output_data_batch_num_example, kind } = res.data; + setIntersectionRate((prev) => { + if (input_data_batch_num_example === 0) { + return 0; + } + return parseFloat( + ((output_data_batch_num_example / input_data_batch_num_example) * 100).toFixed(2), + ); + }); + + setIntersectionNumber((prev) => { + return output_data_batch_num_example; + }); + + setMyAmountOfData((prev) => { + const myAmountOfData = isDataImport(kind) + ? output_data_batch_num_example + : input_data_batch_num_example; + return myAmountOfData || 0; + }); + }, + }, + ); + + const leftNodeType = useMemo(() => { + const getLeftNodeType = (jobKind: DataJobBackEndType) => { + switch (jobKind) { + case DataJobBackEndType.EXPORT: + return NodeType.DATASET_PROCESSED; + case DataJobBackEndType.IMPORT_SOURCE: + return NodeType.UPLOAD; + default: + return NodeType.DATASET_MY; + } + }; + // set empty and default value to <import> + if (!datasetJobDetailQuery?.data?.data) { + return NodeType.UPLOAD; + } + return getLeftNodeType(datasetJobDetailQuery.data.data?.kind); + }, [datasetJobDetailQuery.data]); + + const rightNodeType = useMemo(() => { + const getRightNodeType = (jobKind: DataJobBackEndType) => { + switch (jobKind) { + case DataJobBackEndType.EXPORT: + return NodeType.DOWNLOAD; + case DataJobBackEndType.IMPORT_SOURCE: + return NodeType.DATASET_MY; + default: + return NodeType.DATASET_PROCESSED; + } + }; + // set empty and default value to <export> + if (!datasetJobDetailQuery?.data?.data) { + return NodeType.DOWNLOAD; + } + return getRightNodeType(datasetJobDetailQuery.data.data?.kind); + }, [datasetJobDetailQuery.data]); + + const onMyDatasetAPISuccess = useCallback((uuid: ID, dataset: Dataset) => { + myDatasetUuidToInfoMap.current[uuid] = dataset; + }, []); + const onParticipantAPISuccess = useCallback( + (uuid: ID, participantDataset: ParticipantDataset) => { + myParticipantDatasetUuidToDatasetInfoMap.current[uuid] = participantDataset; + }, + [], + ); + const onResultDatasetAPISuccess = useCallback((uuid: ID, dataset: Dataset) => { + myResultDatasetUuidToInfoMap.current[uuid] = dataset; + }, []); + + const elementList = useMemo(() => { + const isLightClient = (curParticipant: string) => { + if (Array.isArray(participantList)) { + const filterParticipant = participantList.filter((item) => + item?.domain_name?.includes(curParticipant), + ); + if (filterParticipant.length) { + return filterParticipant[0].type === ParticipantType.LIGHT_CLIENT; + } + } + return false; + }; + + if (!datasetJobDetailQuery.data || !currentDomainName) { + return []; + } + + const datasetJobDetail = datasetJobDetailQuery.data.data; + const rawDatasetObject = datasetJobDetail?.global_configs?.global_configs ?? {}; + + let myDatasetElementList: Node[] = []; + let participantDatasetElementList: Node[] = []; + const kindElementList: Node[] = []; + const processedDatasetElementList: Node[] = []; + const edgeElementList: Edge[] = []; + + // col 1: raw dataset + Object.keys(rawDatasetObject).forEach((key, index) => { + const rawDatasetInfo = rawDatasetObject[key]; + + if (currentDomainName.indexOf(key) > -1) { + myDatasetElementList.push({ + id: `c1-${rawDatasetInfo.dataset_uuid}`, + sourcePosition: Position.Right, + type: leftNodeType, + data: { + title: getLeftNodeName(datasetJobDetail.kind), + dataset_uuid: rawDatasetInfo.dataset_uuid, + onAPISuccess: onMyDatasetAPISuccess, + isActive: !isProcessedDataset, + }, + position: { x: BASE_X, y: BASE_Y }, + }); + } else { + // check whether the participant is light client + const isCurLightClient = isLightClient(key); + participantDatasetElementList.push({ + id: `c1-${isCurLightClient ? key : rawDatasetInfo.dataset_uuid}`, + sourcePosition: Position.Right, + type: isCurLightClient ? NodeType.LIGHT_CLIENT : NodeType.DATASET_PARTICIPANT, + data: { + title: isCurLightClient ? ( + <> + 合作伙伴数据集{' '} + <Tag className="task-detail-tag" color="arcoblue"> + 轻量 + </Tag> + </> + ) : ( + `合作伙伴数据集 - ${key}` + ), + dataset_uuid: isCurLightClient ? key : rawDatasetInfo.dataset_uuid, + onAPISuccess: isCurLightClient ? null : onParticipantAPISuccess, + }, + position: { x: BASE_X, y: BASE_Y + HEIGHT_DATASET_NODE * 2 }, + }); + } + }); + + myDatasetElementList = myDatasetElementList.map((item, index) => ({ + ...item, + position: { x: BASE_X, y: BASE_Y + HEIGHT_DATASET_NODE * 2 * index }, + })); + + const PARTICIPANT_BASE_Y = + myDatasetElementList.length > 0 + ? myDatasetElementList[myDatasetElementList.length - 1].position.y + 2 * HEIGHT_DATASET_NODE + : BASE_Y; + + participantDatasetElementList = participantDatasetElementList.map((item, index) => ({ + ...item, + position: { + x: BASE_X, + y: PARTICIPANT_BASE_Y + HEIGHT_DATASET_NODE * 2 * index, + }, + })); + + const rawDatasetList = [...myDatasetElementList, ...participantDatasetElementList].map( + (item, index) => ({ + ...item, + id: `c1-${index}`, + }), + ); + + // col 2: kind + kindElementList.push({ + id: 'c2-1', + targetPosition: Position.Left, + sourcePosition: Position.Right, + type: NodeType.Tag, + data: { + title: DataJobBackEndTypeToLabelMap[datasetJobDetail.kind] || 'Unknown', + kind: datasetJobDetail.kind, + dataset_job_uuid: datasetJobDetail.uuid, + workflow_uuid: datasetJobDetail.workflow_id, + job_id: middleJump ? datasetJobId : null, // 中间不可跳转的话,数据不传job_id, 兼容置灰 + }, + position: { + x: WIDTH_DATASET_NODE + WIDTH_GAP, + y: + BASE_Y + + Math.floor((2 * rawDatasetList.length - 1) / 2) * HEIGHT_DATASET_NODE + + (HEIGHT_DATASET_NODE - HEIGHT_TAG_NODE) / 2, + }, + }); + + // col 3: processed dataset + processedDatasetElementList.push({ + id: 'c3-1', + targetPosition: Position.Left, + type: rightNodeType, + data: { + title: getRightNodeName(datasetJobDetail.kind), + dataset_name: datasetJobDetail.result_dataset_name, + dataset_uuid: datasetJobDetail.result_dataset_uuid, + isActive: isProcessedDataset, + onAPISuccess: onResultDatasetAPISuccess, + }, + position: { + x: WIDTH_DATASET_NODE + WIDTH_GAP * 2 + WIDTH_TAG_NODE, + y: BASE_Y + Math.floor((2 * rawDatasetList.length - 1) / 2) * HEIGHT_DATASET_NODE - 1.5, + }, + }); + + // edge + if (kindElementList.length > 0) { + rawDatasetList.forEach((item) => { + edgeElementList.push({ + id: `e|${item.id}_c2-1`, + source: `${item.id}`, + target: 'c2-1', + type: 'smoothstep', + animated: true, + }); + }); + } + + if (kindElementList.length > 0 && processedDatasetElementList.length > 0) { + edgeElementList.push({ + id: `e|c2-1_c3-1`, + source: 'c2-1', + target: 'c3-1', + type: 'smoothstep', + animated: true, + }); + } + + return [ + ...rawDatasetList, + ...kindElementList, + ...processedDatasetElementList, + ...edgeElementList, + ]; + }, [ + participantList, + datasetJobId, + onResultDatasetAPISuccess, + datasetJobDetailQuery.data, + currentDomainName, + isProcessedDataset, + onMyDatasetAPISuccess, + onParticipantAPISuccess, + leftNodeType, + rightNodeType, + middleJump, + ]); + + const isShowErrorMessage = errorMessage; + + return ( + <Spin loading={datasetJobDetailQuery.isFetching} className={className}> + <div> + {isShowTitle && ( + <LabelStrong fontSize={14} isBlock={true}> + 任务流程 + </LabelStrong> + )} + + <div className="react-flow-container"> + {isShowRatio && ( + <div className="statistic-group-container"> + {isDataJoin(datasetJobDetailQuery.data?.data?.kind) && ( + <> + <Progress + className="statistic-progress" + percent={intersectionRate ? intersectionRate : 0} + size="large" + type="circle" + status="normal" + trailColor="var(--color-primary-light-1)" + formatText={(percent: number) => { + return ( + <Statistic + className="statistic-rate-item" + extra="求交率" + value={intersectionRate} + suffix="%" + groupSeparator + /> + ); + }} + /> + + <Statistic + className="statistic-item" + title="交集数" + value={intersectionNumber || 0} + groupSeparator + /> + </> + )} + + {!isDataAnalyzer(datasetJobDetailQuery.data?.data?.kind) && ( + <Statistic + className="statistic-item" + title="我方数据量" + value={myAmountOfData || 0} + groupSeparator + /> + )} + </div> + )} + + {elementList.length > 0 && ( + <ReactFlow + elements={elementList} + onLoad={onReactFlowLoad} + onElementClick={(_, element: FlowElement) => onElementsClick(element)} + nodesDraggable={false} + nodesConnectable={false} + zoomOnScroll={false} + zoomOnPinch={false} + zoomOnDoubleClick={false} + minZoom={1} + maxZoom={1} + defaultZoom={1} + nodeTypes={nodeTypes} + > + <Controls showZoom={false} showInteractive={false} /> + </ReactFlow> + )} + </div> + {isShowErrorMessage && ( + <> + <LabelStrong fontSize={14} isBlock={true} style={{ marginBottom: 12 }}> + 错误信息 + </LabelStrong> + <Alert type="error" showIcon={false} content={errorMessage} /> + </> + )} + </div> + </Spin> + ); + + function onReactFlowLoad(reactFlowInstance: OnLoadParams) { + // Fits the view port so that all nodes are visible + reactFlowInstance!.fitView(); + } + async function onElementsClick(element: FlowElement<DatasetNodeData>) { + const allNodeMapper = { + ...myDatasetUuidToInfoMap.current, + ...myResultDatasetUuidToInfoMap.current, + }; + if (element && isNode(element)) { + onNodeClick?.(element, allNodeMapper); + switch (element?.type) { + case NodeType.DATASET_MY: + const datasetInfo = allNodeMapper[element?.data?.dataset_uuid ?? '']; + if (datasetInfo?.id) { + history.push( + `/datasets/${ + datasetInfo?.dataset_kind === DatasetKindBackEndType.PROCESSED + ? DatasetKindLabel.PROCESSED + : DatasetKindLabel.RAW + }/detail/${datasetInfo.id}/${DatasetDetailSubTabs.DatasetJobDetail}`, + ); + } + break; + case NodeType.Tag: + if (middleJump && element?.data?.job_id) { + try { + if (isOldData) { + history.push(`/datasets/job_detail/${element?.data?.job_id}`); + } else { + history.push( + `/datasets/${datasetId}/new/job_detail/${element?.data?.job_id}/${JobDetailSubTabs.TaskProcess}`, + ); + } + } catch (error) { + Message.error(error.message); + } + } + break; + default: + break; + } + } + } +}; + +export default TaskDetail; diff --git a/web_console_v2/client/src/views/Datasets/TaskList/TaskActions/index.tsx b/web_console_v2/client/src/views/Datasets/TaskList/TaskActions/index.tsx new file mode 100644 index 000000000..6bf83eacb --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskList/TaskActions/index.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { DatasetJobListItem } from 'typings/dataset'; +import { isJobRunning } from 'shared/dataset'; +import Modal from 'components/Modal'; +import { Button, Message, Space } from '@arco-design/web-react'; +import MoreActions from 'components/MoreActions'; +import { deleteDatasetJob, stopDatasetJob } from 'services/dataset'; +import { ButtonProps } from '@arco-design/web-react/es/Button/interface'; + +interface IProp { + data: DatasetJobListItem; + onDelete?: () => void; + onStop?: () => void; + buttonProps?: ButtonProps; +} + +export default function TaskActions(prop: IProp) { + const { data, onDelete, onStop, buttonProps } = prop; + + const handleOnJobStop = (projectId: ID, data: DatasetJobListItem) => { + stopDatasetJob(projectId, data.id!).then( + () => { + onStop && onStop(); + }, + (err) => Message.error(`停止失败: ${err?.message}`), + ); + }; + + const handleOnJobDelete = (projectId: ID, data: DatasetJobListItem) => { + deleteDatasetJob(projectId, data.id!).then( + () => { + onDelete && onDelete(); + }, + (err) => Message.error(`删除失败: ${err?.message}`), + ); + }; + return ( + <Space> + <Button + disabled={!isJobRunning(data)} + onClick={() => { + Modal.stop({ + title: `确认要停止「${data.result_dataset_name}」?`, + content: '停止后,该任务不能再重新运行,请谨慎操作', + onOk() { + if (!data.project_id) { + Message.info('请选择工作区'); + return; + } + handleOnJobStop(data.project_id, data); + }, + }); + }} + {...buttonProps} + > + 停止运行 + </Button> + <MoreActions + actionList={[ + { + disabled: isJobRunning(data), + label: '删除', + danger: true, + onClick() { + Modal.delete({ + title: `确认要删除「${data.result_dataset_name}」?`, + content: '删除后,该任务及信息将无法恢复,请谨慎操作', + onOk() { + if (!data.project_id) { + Message.info('请选择工作区'); + return; + } + handleOnJobDelete(data.project_id, data); + }, + }); + }, + }, + ]} + /> + </Space> + ); +} diff --git a/web_console_v2/client/src/views/Datasets/TaskList/index.less b/web_console_v2/client/src/views/Datasets/TaskList/index.less new file mode 100644 index 000000000..c1d8d9801 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskList/index.less @@ -0,0 +1,14 @@ +.indicator-with-tip{ + display: flex; + flex-direction: row; + align-items: center; +} +.task-running-count-wrapper{ + display: inline-block; + background-color: white; + border-radius: 4px; + padding: 0 8px 0 8px; + height: 20px; + line-height: 20px; + margin-left: 8px; +} diff --git a/web_console_v2/client/src/views/Datasets/TaskList/index.tsx b/web_console_v2/client/src/views/Datasets/TaskList/index.tsx new file mode 100644 index 000000000..e68202d8e --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/TaskList/index.tsx @@ -0,0 +1,334 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantId, + useGetCurrentProjectParticipantList, + useTablePaginationWithUrlState, + useUrlState, +} from 'hooks'; +import { fetchDatasetJobList } from 'services/dataset'; +import { TABLE_COL_WIDTH, TIME_INTERVAL } from 'shared/constants'; +import { formatTimestamp } from 'shared/date'; +import { getDatasetJobState } from 'shared/dataset'; +import { + datasetJobStateFilters, + datasetJobTypeFilters, + FILTER_DATA_JOB_OPERATOR_MAPPER, + filterExpressionGenerator, + getJobKindByFilter, + getJobStateByFilter, + getSortOrder, +} from '../shared'; + +import { Button, Input, Message, Table, TableColumnProps } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import SharedPageLayout from 'components/SharedPageLayout'; +import StateIndicator from 'components/StateIndicator'; +import DatasetJobsType from 'components/DatasetJobsType'; +import WhichParticipant from 'components/WhichParticipant'; +import { Link } from 'react-router-dom'; + +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { DataJobBackEndType, DatasetJobListItem, DatasetJobState } from 'typings/dataset'; +import TaskActions from './TaskActions'; +import { expression2Filter } from 'shared/filter'; +import { useToggle } from 'react-use'; +import './index.less'; + +type TProps = {}; +type TableFilterConfig = Pick<TableColumnProps, 'filters' | 'onFilter'>; +const { Search } = Input; + +const List: FC<TProps> = function (props: TProps) { + const { paginationProps } = useTablePaginationWithUrlState(); + const projectId = useGetCurrentProjectId(); + const participantId = useGetCurrentProjectParticipantId(); + const participantList = useGetCurrentProjectParticipantList(); + const [total, setTotal] = useState(0); + const [isViewRunning, setIsViewRunning] = useToggle(false); + const [pageTotal, setPageTotal] = useState(0); + + // Temporarily get the number of running tasks by calling the list-api again; + const runningCountQuery = useQuery( + ['fetchRunningCount', projectId], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchDatasetJobList(projectId!, { + page: 1, + page_size: 1, + filter: filterExpressionGenerator( + { + state: [DatasetJobState.RUNNING, DatasetJobState.PENDING], + }, + FILTER_DATA_JOB_OPERATOR_MAPPER, + ), + }); + }, + { + refetchInterval: TIME_INTERVAL.LIST, + }, + ); + + // get participantFilter from participantList + const datasetJobCoordinatorFilters: TableFilterConfig = useMemo(() => { + let filters: { text: string; value: any }[] = []; + if (Array.isArray(participantList) && participantList.length) { + filters = participantList.map((item) => ({ + text: item.name, + value: item.id, + })); + filters.push({ + text: '本方', + value: 0, + }); + } + return { + filters, + onFilter: (value: number, record: DatasetJobListItem) => { + return value === record.coordinator_id; + }, + }; + }, [participantList]); + + // store filter status into urlState + const [urlState, setUrlState] = useUrlState({ + filter: '', + order_by: '', + page: 1, + pageSize: 10, + }); + + // generator listQuery + const listQuery = useQuery( + ['fetchDatasetJobList', projectId, urlState], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + const filter = expression2Filter(urlState.filter); + filter.state = getJobStateByFilter(filter.state); + filter.kind = getJobKindByFilter(filter.kind); + return fetchDatasetJobList(projectId!, { + page: urlState.page, + page_size: urlState.pageSize, + filter: filterExpressionGenerator(filter, FILTER_DATA_JOB_OPERATOR_MAPPER), + order_by: urlState.order_by || 'created_at desc', + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + refetchOnWindowFocus: false, + onSuccess: (res) => { + const { page_meta } = res || {}; + setTotal((pre) => page_meta?.total_items || pre); + setPageTotal(page_meta?.total_pages ?? 0); + }, + }, + ); + + // generator listData from listQuery and watch listQuery + const list = useMemo(() => { + return listQuery.data?.data || []; + }, [listQuery.data]); + + const runningCount = useMemo(() => { + if (!runningCountQuery.data) { + return 0; + } else { + return runningCountQuery.data?.page_meta?.total_items || 0; + } + }, [runningCountQuery.data]); + + const columns = useMemo<ColumnProps<DatasetJobListItem>[]>(() => { + return [ + { + title: '任务名称', + dataIndex: 'name', + key: 'name', + width: TABLE_COL_WIDTH.NAME, + ellipsis: true, + render: (name: string, record) => { + if (record.has_stages) { + // 新数据跳转新任务详情 + return ( + <Link to={`/datasets/${record.result_dataset_id}/new/job_detail/${record.id}`}> + {name} + </Link> + ); + } + return <Link to={`/datasets/job_detail/${record.id}`}>{name}</Link>; + }, + }, + { + title: '任务类型', + dataIndex: 'kind', + key: 'kind', + width: TABLE_COL_WIDTH.NORMAL, + ...datasetJobTypeFilters, + filteredValue: expression2Filter(urlState.filter).kind, + render: (type) => { + return <DatasetJobsType type={type as DataJobBackEndType} />; + }, + }, + { + title: '任务状态', + dataIndex: 'state', + key: 'state', + width: TABLE_COL_WIDTH.NORMAL, + ...datasetJobStateFilters, + filteredValue: expression2Filter(urlState.filter).state, + render: (_: any, record: DatasetJobListItem) => { + return ( + <div className="indicator-with-tip"> + <StateIndicator {...getDatasetJobState(record)} /> + </div> + ); + }, + }, + { + title: '任务发起方', + dataIndex: 'coordinator_id', + key: 'coordinator_id', + width: TABLE_COL_WIDTH.COORDINATOR, + ...datasetJobCoordinatorFilters, + filteredValue: expression2Filter(urlState.filter).coordinator_id, + render: (value: any) => { + return value === 0 ? '本方' : <WhichParticipant id={value} />; + }, + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: TABLE_COL_WIDTH.TIME, + sorter(a: DatasetJobListItem, b: DatasetJobListItem) { + return a.created_at - b.created_at; + }, + defaultSortOrder: getSortOrder(urlState, 'created_at'), + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + { + title: '操作', + dataIndex: 'state', + key: 'operation', + fixed: 'right', + width: TABLE_COL_WIDTH.NORMAL, + render: (state: DatasetJobState, record) => ( + <TaskActions + buttonProps={{ + type: 'text', + className: 'custom-text-button', + }} + data={record} + onDelete={listQuery.refetch} + onStop={handleRefetch} + /> + ), + }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlState, projectId, participantId]); + + // filter running jobs + const handleOnClick = () => { + const filter = expression2Filter(urlState.filter); + filter.state = isViewRunning ? undefined : [DatasetJobState.RUNNING]; + setUrlState((prevState) => ({ + ...prevState, + page: 1, + filter: filterExpressionGenerator(filter, FILTER_DATA_JOB_OPERATOR_MAPPER), + })); + setIsViewRunning((pre: boolean) => !pre); + }; + + // search by keyword + const onSearch = (value: string) => { + const filter = expression2Filter(urlState.filter); + filter.name = value; + setUrlState((prevState) => ({ + ...prevState, + page: 1, + filter: filterExpressionGenerator(filter, FILTER_DATA_JOB_OPERATOR_MAPPER), + })); + }; + + const pagination = useMemo(() => { + return pageTotal <= 1 + ? false + : { + ...paginationProps, + total, + }; + }, [paginationProps, pageTotal, total]); + + return ( + <SharedPageLayout title="任务管理"> + <GridRow justify="space-between" align="center"> + <Button className={'custom-operation-button'} onClick={handleOnClick}> + 查看运行中任务 + <span className="task-running-count-wrapper">{runningCount}</span> + </Button> + <Search + className={'custom-input'} + allowClear + placeholder="输入任务名称" + defaultValue={expression2Filter(urlState.filter).name} + onSearch={onSearch} + onClear={() => onSearch('')} + /> + </GridRow> + <Table + className={'custom-table custom-table-left-side-filter'} + rowKey="id" + loading={listQuery.isFetching} + data={list} + scroll={{ x: '100%' }} + columns={columns} + pagination={pagination} + onChange={( + pagination, + sorter, + filters: Partial<Record<keyof DatasetJobListItem, any[]>>, + extra, + ) => { + switch (extra.action) { + case 'sort': + let orderValue = ''; + if (sorter.direction) { + orderValue = sorter.direction === 'ascend' ? 'asc' : 'desc'; + } + setUrlState((prevState) => ({ + ...prevState, + order_by: orderValue ? `${sorter.field} ${orderValue}` : '', + })); + break; + case 'filter': + const filterCopy = { + ...filters, + name: expression2Filter(urlState.filter).name, + }; + setUrlState((prevState) => ({ + ...prevState, + filter: filterExpressionGenerator(filterCopy, FILTER_DATA_JOB_OPERATOR_MAPPER), + page: 1, + })); + break; + default: + } + }} + /> + </SharedPageLayout> + ); + function handleRefetch() { + listQuery.refetch(); + runningCountQuery.refetch(); + } +}; + +export default List; diff --git a/web_console_v2/client/src/views/Datasets/routes.tsx b/web_console_v2/client/src/views/Datasets/routes.tsx new file mode 100644 index 000000000..e8c2a5e69 --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/routes.tsx @@ -0,0 +1,12 @@ +const INDEX_PATH = '/datasets'; + +const routes: Record<string, string> = { + DatasetCreate: `${INDEX_PATH}/:action(create|edit)/source`, +}; + +export default routes; + +export enum DatasetCreateAction { + Create = 'create', + Edit = 'edit', +} diff --git a/web_console_v2/client/src/views/Datasets/shared.ts b/web_console_v2/client/src/views/Datasets/shared.ts new file mode 100644 index 000000000..a047d85ef --- /dev/null +++ b/web_console_v2/client/src/views/Datasets/shared.ts @@ -0,0 +1,507 @@ +/* istanbul ignore file */ +import { + DataJobBackEndType, + DatasetJobListItem, + DatasetJobState, + DatasetJobType, + DatasetKind, + DatasetKindBackEndType, + DatasetKindLabel, + DatasetStateFront, + DataBatchV2, + DatasetJobStage, + DatasetRawPublishStatus, + Dataset, + DatasetProcessedAuthStatus, + DatasetProcessedMyAuthStatus, + DatasetJob, +} from 'typings/dataset'; +import { TableColumnProps } from '@arco-design/web-react'; +import { FilterOp } from 'typings/filter'; +import { expression2Filter, operationMap } from 'shared/filter'; +import { Tag } from 'typings/workflow'; + +type TableFilterConfig = Pick<TableColumnProps, 'filters' | 'onFilter'>; + +export const datasetPageTitles = { + [DatasetKindLabel.RAW]: '原始数据集', + [DatasetKindLabel.PROCESSED]: '结果数据集', + undefined: '未知数据集', +}; + +export const datasetKindLabelValueMap = { + [DatasetKindLabel.RAW]: DatasetKind.RAW, + [DatasetKindLabel.PROCESSED]: DatasetKind.PROCESSED, + [DatasetKind.RAW]: DatasetKindLabel.RAW, + [DatasetKind.PROCESSED]: DatasetKindLabel.PROCESSED, +}; + +export const DataJobBackEndTypeToLabelMap = { + [DataJobBackEndType.RSA_PSI_DATA_JOIN]: 'RSA-PSI 求交', + [DataJobBackEndType.LIGHT_CLIENT_RSA_PSI_DATA_JOIN]: 'LIGHT_CLIENT_RSA_PSI数据求交', + [DataJobBackEndType.LIGHT_CLIENT_OT_PSI_DATA_JOIN]: 'LIGHT_CLIENT_OT_PSI数据求交', + [DataJobBackEndType.OT_PSI_DATA_JOIN]: 'OT-PSI数据求交', + [DataJobBackEndType.DATA_JOIN]: '数据求交', + [DataJobBackEndType.DATA_ALIGNMENT]: '数据对齐', + [DataJobBackEndType.IMPORT_SOURCE]: '数据导入', + [DataJobBackEndType.EXPORT]: '导出', + [DataJobBackEndType.HASH_DATA_JOIN]: '哈希求交', + [DataJobBackEndType.ANALYZER]: '数据探查', +}; + +export function isDataJoin(kind?: DataJobBackEndType) { + if (!kind) return false; + return [ + DataJobBackEndType.RSA_PSI_DATA_JOIN, + DataJobBackEndType.LIGHT_CLIENT_RSA_PSI_DATA_JOIN, + DataJobBackEndType.OT_PSI_DATA_JOIN, + DataJobBackEndType.DATA_JOIN, + DataJobBackEndType.HASH_DATA_JOIN, + ].includes(kind); +} +export function isDataImport(kind?: DataJobBackEndType) { + if (!kind) return false; + return [DataJobBackEndType.IMPORT_SOURCE].includes(kind); +} +export function isDataExport(kind?: DataJobBackEndType) { + if (!kind) return false; + return [DataJobBackEndType.EXPORT].includes(kind); +} +export function isDataAlignment(kind?: DataJobBackEndType) { + if (!kind) return false; + return [DataJobBackEndType.DATA_ALIGNMENT].includes(kind); +} +export function isDataAnalyzer(kind?: DataJobBackEndType) { + if (!kind) return false; + return [DataJobBackEndType.ANALYZER].includes(kind); +} + +export function isDataLightClient(kind?: DataJobBackEndType) { + if (!kind) return false; + return [DataJobBackEndType.LIGHT_CLIENT_RSA_PSI_DATA_JOIN].includes(kind); +} + +export function isDataOtPsiJoin(kind?: DataJobBackEndType) { + if (!kind) return false; + return [DataJobBackEndType.OT_PSI_DATA_JOIN].includes(kind); +} + +export function isDataHashJoin(kind?: DataJobBackEndType) { + if (!kind) return false; + return [DataJobBackEndType.HASH_DATA_JOIN].includes(kind); +} + +export const datasetJobTypeOptions = [ + { + label: '求交', + value: DatasetJobType.JOIN, + }, + { + label: '对齐', + value: DatasetJobType.ALIGNMENT, + }, + { + label: '导入', + value: DatasetJobType.IMPORT, + }, + { + label: '导出', + value: DatasetJobType.EXPORT, + }, + { + label: '数据探查', + value: DatasetJobType.ANALYZER, + }, +]; + +export const datasetJobStateOptions = [ + { + label: '待运行', + value: DatasetJobState.PENDING, + }, + { + label: '运行中', + value: DatasetJobState.RUNNING, + }, + { + label: '成功', + value: DatasetJobState.SUCCEEDED, + }, + { + label: '失败', + value: DatasetJobState.FAILED, + }, + { + label: '已停止', + value: DatasetJobState.STOPPED, + }, +]; + +export const datasetJobTypeFilters: TableFilterConfig = { + filters: datasetJobTypeOptions.map((item) => ({ + text: item.label, + value: item.value, + })), + onFilter: (value: string, record: DatasetJobListItem) => { + switch (value) { + case DatasetJobType.JOIN: + return isDataJoin(record.kind); + case DatasetJobType.ALIGNMENT: + return [DataJobBackEndType.DATA_ALIGNMENT].includes(record.kind); + case DatasetJobType.IMPORT: + return [DataJobBackEndType.IMPORT_SOURCE].includes(record.kind); + case DatasetJobType.EXPORT: + return [DataJobBackEndType.EXPORT].includes(record.kind); + case DatasetJobType.ANALYZER: + return [DataJobBackEndType.ANALYZER].includes(record.kind); + default: + return false; + } + }, +}; + +export const datasetJobStateFilters: TableFilterConfig = { + filters: datasetJobStateOptions + .filter((opt) => opt.value !== DatasetJobState.PENDING) + .map((item) => ({ + text: item.label, + value: item.value, + })), + onFilter: (value: string, record: DatasetJobListItem) => { + if (value === DatasetJobState.RUNNING) { + return [DatasetJobState.PENDING, DatasetJobState.RUNNING].includes(record.state); + } + return value === record.state; + }, +}; + +export const FILTER_DATA_BATCH_OPERATOR_MAPPER = { + state: FilterOp.IN, +}; + +export const dataBatchStateFilters: TableFilterConfig = { + filters: [ + { + text: '待处理', + value: DatasetStateFront.PENDING, + }, + { + text: '处理中', + value: DatasetStateFront.PROCESSING, + }, + { + text: '可用', + value: DatasetStateFront.SUCCEEDED, + }, + { + text: '处理失败', + value: DatasetStateFront.FAILED, + }, + // { + // text: '删除中', + // value: DatasetStateFront.DELETING, + // }, + ], + onFilter: (value: string, record: DataBatchV2) => { + return value === record.state; + }, +}; + +export enum CREDITS_LIMITS { + MIN = 100, + MAX = 10000, +} + +export const NO_CATEGORY = '未分类'; + +export const TAG_MAPPER = { + [Tag.RESOURCE_ALLOCATION]: '资源配置', + [Tag.INPUT_PARAM]: '输入参数', + [Tag.INPUT_PATH]: '输入路径', + [Tag.OUTPUT_PATH]: '输出路径', + [Tag.OPERATING_PARAM]: '运行参数', + [Tag.SYSTEM_PARAM]: '系统变量', +}; + +export enum DatasetJobTypeFront { + RAW = 'RAW', + PROCESSED = 'PROCESSED', + IMPORT = 'IMPORT', + EXPORTED = 'EXPORTED', +} + +export const DATA_JOB_TYPE_MAPPER = { + [DatasetJobTypeFront.IMPORT]: [DatasetKindBackEndType.SOURCE], + [DatasetJobTypeFront.RAW]: [DatasetKindBackEndType.RAW], + [DatasetJobTypeFront.EXPORTED]: [DatasetKindBackEndType.EXPORTED], + [DatasetJobTypeFront.PROCESSED]: [DatasetKindBackEndType.PROCESSED], +}; + +/** + * check the originType form back-end is belonged to target type or not + * @param originType + * @param targetType + */ +export function dataJobTypeCheck( + originType: DatasetKindBackEndType, + targetType: DatasetJobTypeFront, +): boolean { + if (!originType || !DATA_JOB_TYPE_MAPPER[targetType]) { + return false; + } + return DATA_JOB_TYPE_MAPPER[targetType].includes(originType); +} + +/** + * generate an expression from filter object + * @param filter + * @param filterOpMapper + */ +export function filterExpressionGenerator( + filter: { [filed: string]: any }, + filterOpMapper: { [filed: string]: FilterOp }, +) { + const filterPairStringArray = []; + const keys = Object.keys(filter); + if (!keys.length) { + return ''; + } + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const val = filter[key]; + if (typeof val !== 'boolean' && !val && val !== 0) { + continue; + } + const finalVal = JSON.stringify(val); + const op = filterOpMapper[key]; + op && filterPairStringArray.push(`(${key}${operationMap(op)}${finalVal})`); + } + switch (filterPairStringArray.length) { + case 0: + return ''; + case 1: + return filterPairStringArray[0]; + default: + return `(and${filterPairStringArray.join('')})`; + } +} + +/** + * get sortValue form urlState + * @param urlState + * @param key + */ +export function getSortOrder(urlState: any, key: string) { + const order = urlState.order_by?.split(' ') || []; + let res: 'ascend' | 'descend' | undefined = undefined; + const [keyword, value] = order; + if (keyword === key) { + switch (value) { + case 'asc': + res = 'ascend'; + break; + case 'desc': + res = 'descend'; + break; + default: + break; + } + } + return res; +} + +export function getPublishState(filter?: string) { + if (!filter) { + return undefined; + } + return expression2Filter(filter).publish_frontend_state; +} + +export const FILTER_OPERATOR_MAPPER = { + dataset_format: FilterOp.IN, + publish_frontend_state: FilterOp.EQUAL, + auth_status: FilterOp.IN, + name: FilterOp.CONTAIN, + project_id: FilterOp.EQUAL, + dataset_kind: FilterOp.EQUAL, + format: FilterOp.IN, + participant_id: FilterOp.IN, + uuid: FilterOp.EQUAL, + dataset_type: FilterOp.EQUAL, +}; + +export const FILTER_DATA_JOB_OPERATOR_MAPPER = { + coordinator_id: FilterOp.IN, + kind: FilterOp.IN, + state: FilterOp.IN, + name: FilterOp.CONTAIN, +}; + +export const JOB_FRONT_TYPE_TO_BACK_TYPE_MAPPER = { + [DatasetJobType.JOIN]: [ + DataJobBackEndType.RSA_PSI_DATA_JOIN, + DataJobBackEndType.LIGHT_CLIENT_RSA_PSI_DATA_JOIN, + DataJobBackEndType.OT_PSI_DATA_JOIN, + DataJobBackEndType.DATA_JOIN, + DataJobBackEndType.HASH_DATA_JOIN, + ], + [DatasetJobType.IMPORT]: [DataJobBackEndType.IMPORT_SOURCE], + [DatasetJobType.EXPORT]: [DataJobBackEndType.EXPORT], + [DatasetJobType.ALIGNMENT]: [DataJobBackEndType.DATA_ALIGNMENT], + [DatasetJobType.ANALYZER]: [DataJobBackEndType.ANALYZER], +}; + +export function getJobKindByFilter(kindList?: DatasetJobType[]) { + if (!kindList || !kindList.length) { + return; + } + return kindList.reduce((pre, cur) => { + return pre.concat(JOB_FRONT_TYPE_TO_BACK_TYPE_MAPPER[cur]); + }, [] as DataJobBackEndType[]); +} + +export function getJobStateByFilter(stateList?: DatasetJobState[]) { + if (!stateList || !stateList.length) { + return; + } + const runningFlag = stateList.includes(DatasetJobState.RUNNING); + const pendingFlag = stateList.includes(DatasetJobState.PENDING); + const spliceIndex = pendingFlag + ? stateList.findIndex((item) => item === DatasetJobState.PENDING) + : stateList.length; + pendingFlag && !runningFlag && stateList.splice(spliceIndex, 1); + runningFlag && !pendingFlag && stateList.push(DatasetJobState.PENDING); + return stateList; +} + +export const VARIABLE_TIPS_MAPPER: { [prop: string]: string } = { + num_partitions: '数据分片的数量,各方需保持一致', + part_num: '数据分片的数量,各方需保持一致', + replicas: '求交worker数量,各方需保持一致', + part_key: '用来当作求交列的列名', +}; + +export const SYNCHRONIZATION_VARIABLE = { + NUM_PARTITIONS: 'num_partitions', + PART_NUM: 'part_num', + REPLICAS: 'replicas', +}; + +export function isDatasetJobStagePending(datasetJobStage?: DatasetJobStage) { + if (!datasetJobStage) return false; + return [DatasetJobState.PENDING, DatasetJobState.RUNNING].includes(datasetJobStage?.state); +} + +export function isDatasetJobStageFailed(datasetJobStage?: DatasetJobStage) { + if (!datasetJobStage) return false; + return [DatasetJobState.FAILED, DatasetJobState.STOPPED].includes(datasetJobStage?.state); +} + +export function isDatasetJobStageSuccess(datasetJobStage?: DatasetJobStage) { + if (!datasetJobStage) return false; + return [DatasetJobState.SUCCEEDED].includes(datasetJobStage?.state); +} + +export function isDatasetTicket(data: Dataset) { + return [DatasetRawPublishStatus.TICKET_PENDING, DatasetRawPublishStatus.TICKET_DECLINED].includes( + data.publish_frontend_state, + ); +} + +export function isDatasetPublished(data: Dataset) { + return data.publish_frontend_state === DatasetRawPublishStatus.PUBLISHED; +} + +export function isFrontendAuthorized(data: Dataset) { + return data.local_auth_status === DatasetProcessedMyAuthStatus.AUTHORIZED; +} + +export const RawPublishStatusOptions = [ + { + status: DatasetRawPublishStatus.UNPUBLISHED, + text: '未发布', + color: '#165DFF', + percent: 25, + }, + { + status: DatasetRawPublishStatus.TICKET_PENDING, + text: '待审批', + color: '#165DFF', + percent: 70, + }, + { + status: DatasetRawPublishStatus.TICKET_PENDING, + text: '审批拒绝', + color: '#FA9600', + percent: 70, + }, + { + status: DatasetRawPublishStatus.PUBLISHED, + text: '已发布', + color: '#165DFF', + percent: 100, + }, +]; + +export const RawAuthStatusOptions = [ + { + status: DatasetProcessedAuthStatus.TICKET_PENDING, + text: '待审批', + color: '#165DFF', + percent: 25, + }, + { + status: DatasetProcessedAuthStatus.TICKET_PENDING, + text: '审批拒绝', + color: '#FA9600', + percent: 50, + }, + { + status: DatasetProcessedAuthStatus.AUTH_PENDING, + text: '待授权', + color: '#165DFF', + percent: 100, + }, + + { + status: DatasetProcessedAuthStatus.AUTH_APPROVED, + text: '授权通过', + color: '#00B42A', + percent: 100, + }, +]; + +export function isSingleParams(kind?: DataJobBackEndType) { + if (!kind) { + return false; + } + return [ + DataJobBackEndType.EXPORT, + DataJobBackEndType.IMPORT_SOURCE, + DataJobBackEndType.ANALYZER, + DataJobBackEndType.LIGHT_CLIENT_OT_PSI_DATA_JOIN, + DataJobBackEndType.LIGHT_CLIENT_RSA_PSI_DATA_JOIN, + ].includes(kind); +} + +export enum CronType { + DAY = 'DAY', + HOUR = 'HOUR', +} + +export const cronTypeOptions = [ + { + value: CronType.DAY, + label: '每天', + warnTip: '天级导入只会读取文件夹格式为 YYYYMMDD (如20220101) 下的数据。', + }, + { + value: CronType.HOUR, + label: '每小时', + warnTip: '小时级导入只会读取文件夹格式为 YYYYMMDD-HH(如20220101-12) 下的数据。', + }, +]; + +export function isHoursCronJoin(datasetJob?: DatasetJob) { + return datasetJob?.time_range?.hours === 1; +} diff --git a/web_console_v2/client/src/views/Login/index.module.less b/web_console_v2/client/src/views/Login/index.module.less new file mode 100644 index 000000000..437391387 --- /dev/null +++ b/web_console_v2/client/src/views/Login/index.module.less @@ -0,0 +1,133 @@ +@import '~styles/mixins.less'; + +.login_layout{ + display: grid; + grid-template-areas: 'left right'; + grid-template-columns: 520px 1fr; + min-width: 500px; + height: 100vh; + min-height: 500px; + background-color: #fff; + + @media screen and (max-width: 1000px) { + grid-template-columns: 0 1fr; + } +} + +.login_left_block{ + position: relative; + height: 100%; + grid-area: left; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + background-position: 20px 20px, center; + background-repeat: no-repeat; + background-color: var(--primaryColor); + background-size: 140px auto, 80% auto; + + > * { + transform: translateY(-9vh); + } +} +.login_right_block{ + .MixinFlexAlignCenter(); + position: relative; + height: 100%; + display: flex; + background-color: white; + grid-area: right; + flex-direction: column; + @media screen and (max-width: 1000px) { + background: url('~assets/images/logo-black.png') top 24px left 32px no-repeat; + background-size: 160px; + } +} + +.login_bioland_right_block{ + display: flex; + .MixinFlexAlignCenter(); + position: relative; + height: 100%; + background-color: white; + grid-area: right; + flex-direction: column; + @media screen and (max-width: 1000px) { + background: url('~assets/icons/logo-bioland-colorful.svg') top 24px left 32px no-repeat; + background-size: 160px; + } +} + +.login_form{ + width: 360px !important; + :global{ + .arco-form-item{ + margin-bottom: 32px; + } + .arco-input-inner-wrapper{ + height: 48px; + } + .arco-col{ + flex: 1; + } + .checkboxItem { + margin-bottom: 0; + } + .arco-checkbox-text{ + color: #7a8499; + } + } + .form_title { + margin-bottom: 24px; + font-size: 27px; + line-height: 36px; + } + .login_button{ + width: 100%; + height: 48px; + } +} + +.no_account { + margin-top: 16px; + color: var(--textColorSecondary); + font-size: 12px; + white-space: nowrap; +} + +.login_way_layout{ + display: flex; + justify-content: flex-start; + flex-wrap: wrap; +} + +.login_way_item{ + text-align: center; + cursor: pointer; + flex: 0 0 33%; + word-break: break-all; + margin-bottom: 16px; + img { + display: inline-block; + width: 46px; + height: 46px; + } + div { + font-size: 14px; + color: var(--textColorStrong); + } +} + +.other_login_way_text{ + display: inline-block; + font-size: 14px; + color: var(--primaryColor); + cursor: pointer; + width: 100%; + text-align: left; + position: relative; + top: -20px; +} + + diff --git a/web_console_v2/client/src/views/LogsViewer/ModelServingInstanceLogs/index.tsx b/web_console_v2/client/src/views/LogsViewer/ModelServingInstanceLogs/index.tsx new file mode 100644 index 000000000..48996a01e --- /dev/null +++ b/web_console_v2/client/src/views/LogsViewer/ModelServingInstanceLogs/index.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { useParams } from 'react-router-dom'; + +import { fetchModelServingInstanceLog_new } from 'services/modelServing'; + +import { useGetCurrentProjectId } from 'hooks'; + +import PrintLogs from 'components/PrintLogs'; + +const ModelServingInstanceLogs: FC = () => { + const projectId = useGetCurrentProjectId(); + const params = useParams<{ + modelServingId: string; + instanceName: string; + }>(); + + return ( + <PrintLogs + logsFetcher={getLogs} + refetchInterval={4000} + queryKey={['getJob', params.modelServingId, params.instanceName]} + /> + ); + + async function getLogs(tailLines = 5000) { + if (!params.modelServingId) { + return { data: ['Model serving ID invalid!'] }; + } + if (!params.instanceName) { + return { data: ['Instance name invalid!'] }; + } + if (!projectId) { + return { data: ['请选择工作区!'] }; + } + return fetchModelServingInstanceLog_new( + projectId!, + params.modelServingId, + params.instanceName, + { + tail_lines: tailLines, + }, + ).catch((error) => { + return { + data: [error.message], + }; + }); + } +}; + +export default ModelServingInstanceLogs; diff --git a/web_console_v2/client/src/views/LogsViewer/index.module.less b/web_console_v2/client/src/views/LogsViewer/index.module.less new file mode 100644 index 000000000..4c8c7aac3 --- /dev/null +++ b/web_console_v2/client/src/views/LogsViewer/index.module.less @@ -0,0 +1,5 @@ +.container{ + padding-left: 10px; + height: 100vh; + background-color: #292238; +} diff --git a/web_console_v2/client/src/views/ModelCenter/InstanceInfo.tsx b/web_console_v2/client/src/views/ModelCenter/InstanceInfo.tsx new file mode 100644 index 000000000..80bf85eee --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/InstanceInfo.tsx @@ -0,0 +1,107 @@ +import React, { CSSProperties } from 'react'; +import { Table } from '@arco-design/web-react'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { useQuery } from 'react-query'; +import { fetchJobById } from 'services/workflow'; +import { useRecoilValue } from 'recoil'; +import { projectState } from 'stores/project'; +import StateIndicator from 'components/StateIndicator'; +import { formatTimestamp } from 'shared/date'; +import { Pod, PodState } from 'typings/job'; +import { getPodState } from 'views/Workflows/shared'; +import { Link } from 'react-router-dom'; + +type ColumnOptions = { + id: ID; + jobId: ID; +}; + +const getColumns = (options: ColumnOptions): ColumnProps[] => { + return [ + { + dataIndex: 'name', + title: '实例 ID', + width: 400, + }, + { + dataIndex: 'state', + title: '运行状态', + filters: [ + PodState.SUCCEEDED, + PodState.RUNNING, + PodState.FAILED, + PodState.PENDING, + PodState.FAILED_AND_FREED, + PodState.SUCCEEDED_AND_FREED, + PodState.UNKNOWN, + ].map((state) => { + const { text } = getPodState({ state } as Pod); + return { + text, + value: state, + }; + }), + onFilter: (state, record: Pod) => { + return record?.state === state; + }, + render(state, record: Pod) { + return <StateIndicator {...getPodState(record)} />; + }, + }, + { + dataIndex: 'creation_timestamp', + title: '创建时间', + width: 200, + render(value) { + return formatTimestamp(value); + }, + }, + { + key: 'operate', + title: '操作', + render(_, record: Pod) { + return ( + <Link target={'_blank'} to={`/logs/pod/${options.jobId}/${record.name}`}> + 查看日志 + </Link> + ); + }, + }, + ]; +}; + +const PAGE_SIZE = 10; + +const InstanceInfo: React.FC<{ id: ID; jobId: ID; style?: CSSProperties }> = ({ + id, + jobId, + style, +}) => { + const selectedProject = useRecoilValue(projectState); + const projectId = selectedProject.current?.id; + const { data } = useQuery( + ['workflow', '/jobs/:job_id'], + () => fetchJobById(jobId as number).then((res) => res.data.pods), + { + enabled: Boolean(projectId), + }, + ); + + return ( + <> + <Table + rowKey="name" + data={data} + style={{ marginTop: 20, ...style }} + className="custom-table custom-table-left-side-filter" + columns={getColumns({ + id, + jobId, + })} + pagination={!data || data.length <= PAGE_SIZE ? false : { pageSize: PAGE_SIZE }} + /> + </> + ); +}; + +export default InstanceInfo; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.module.less b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.module.less new file mode 100644 index 000000000..b177dbf8b --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.module.less @@ -0,0 +1,36 @@ +.page_section_card { + position: relative; + :global(.arco-card-body) { + min-height: calc( + 100vh - var(--pageHeaderHeight) - var(--headerHeight) - var(--contentOuterPadding) * 2 + ); + background-color: inherit; + } +} +.title_text_large { + .title_text(16px, 0px, 0px); +} +.title_text_small { + .title_text(14px, 40px, 10px); +} +.title_text(@fontSize: 14px,@marginTop: 0px,@marginBottom: 0px) { + margin-top: @marginTop; + margin-bottom: @marginBottom; + padding-right: 16px; + text-align: right; + font-weight: 600; + font-size: @fontSize; + color: var(--color-text-1); +} +.small_text { + font-size: 12px; + color: var(--color-text-2); +} +.form_container { + width: 600px; + margin: 0 auto; + font-size: 12px; +} +.submit_btn_container { + width: 140px; +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.tsx new file mode 100644 index 000000000..de072d09f --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.tsx @@ -0,0 +1,866 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery, useMutation, useQueries } from 'react-query'; +import { generatePath, useHistory, useParams } from 'react-router'; +import { + Button, + Form, + FormItemProps, + Input, + Space, + Select, + Message, + Card, + Spin, + Typography, + Checkbox, + Tooltip, + Tag, +} from '@arco-design/web-react'; +import { IconInfoCircle, IconQuestionCircle } from '@arco-design/web-react/icon'; +import { validNamePattern, MAX_COMMENT_LENGTH } from 'shared/validator'; +import ResourceConfig from 'components/ResourceConfig'; +import DoubleSelect from 'components/DoubleSelect'; + +import { + fetchModelJob_new, + fetchModelDetail_new, + fetchPeerModelJobDetail_new, + updateModelJob, + createModelJob, +} from 'services/modelCenter'; + +import routes, { ModelEvaluationCreateParams, ModelEvaluationListParams } from '../../routes'; +import { ModelJobType, ResourceTemplateType } from 'typings/modelCenter'; +import { ModelJob } from 'typings/modelCenter'; +import { + ALGORITHM_TYPE_LABEL_MAPPER, + Avatar, + getConfigInitialValues, + getConfigInitialValuesByDefinition, + hydrateModalGlobalConfig, +} from '../../shared'; +import { cloneDeep, omit } from 'lodash-es'; +import { Dataset, DatasetKindLabel } from 'typings/dataset'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import { useRecoilQuery } from 'hooks/recoil'; +import { + nnHorizontalEvalTemplateDetailQuery, + nnTemplateDetailQuery, + treeTemplateDetailQuery, +} from 'stores/modelCenter'; +import { EnumAlgorithmProjectType, Algorithm } from 'typings/algorithm'; +import { WorkflowTemplate } from 'typings/workflow'; +import { stringifyComplexDictField } from 'shared/formSchema'; +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantName, + useGetCurrentProjectParticipantId, + useGetCurrentProjectParticipantList, + useGetCurrentPureDomainName, +} from 'hooks'; +import DatasesetSelect from 'components/NewDatasetSelect'; +import { OptionInfo } from '@arco-design/web-react/es/Select/interface'; +import { fetchDatasetDetail } from 'services/dataset'; + +import styles from './index.module.less'; +import { fetchAlgorithmByUuid } from 'services/algorithm'; +import WhichAlgorithm from 'components/WhichAlgorithm'; + +const formLayout = { + labelCol: { + span: 4, + }, + wrapperCol: { + span: 20, + }, +}; + +enum Fields { + name = 'name', + comment = 'comment', + modelId = 'model_id', + algorithmType = 'algorithm_type', + datasetId = 'dataset_id', + config = 'config', +} + +enum ModelJobFields { + GROUP_KEY = 'model_group_id', + ITEM_KEY = 'dataset_id', +} + +const algorithmTypeOptions = [ + { + label: ALGORITHM_TYPE_LABEL_MAPPER[EnumAlgorithmProjectType.TREE_VERTICAL], + value: 'TREE_VERTICAL', + }, + { + label: ALGORITHM_TYPE_LABEL_MAPPER[EnumAlgorithmProjectType.NN_VERTICAL], + value: 'NN_VERTICAL', + }, + { + label: ALGORITHM_TYPE_LABEL_MAPPER[EnumAlgorithmProjectType.NN_HORIZONTAL], + value: 'NN_HORIZONTAL', + }, +]; + +const MODEL_JOB_TYPE_FIELD = 'model_job_type'; +const ALGORITHM_ID_FIELD = 'algorithm_id'; +const MODEL_JOB_ID_FIELD = 'eval_model_job_id'; +const disabledFieldWhenEdit = [ + Fields.algorithmType, + Fields.modelId, + MODEL_JOB_TYPE_FIELD, + MODEL_JOB_ID_FIELD, + Fields.name, +]; + +const formConfig: Record<Fields, Partial<FormItemProps>> = { + [Fields.name]: { + label: '名称', + field: Fields.name, + rules: [ + { + match: validNamePattern, + message: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + { required: true, message: '请输入名称' }, + ], + }, + [Fields.comment]: { + label: '描述', + field: Fields.comment, + rules: [{ maxLength: MAX_COMMENT_LENGTH, message: '最多为 200 个字符' }], + }, + [Fields.config]: { + label: '资源模板', + field: Fields.config, + rules: [{ required: true }], + }, + [Fields.algorithmType]: { + label: '类型', + field: Fields.algorithmType, + rules: [{ required: true }], + initialValue: algorithmTypeOptions[0].value, + }, + + [Fields.modelId]: { + label: '模型', + field: Fields.modelId, + rules: [ + { + required: true, + message: '请选择模型', + validator: (val, cb) => { + const hasValue = Boolean( + val?.[ModelJobFields.GROUP_KEY] && val?.[ModelJobFields.ITEM_KEY], + ); + cb(!hasValue ? '请选择模型' : undefined); + }, + }, + ], + }, + [Fields.datasetId]: { + label: '数据集', + field: Fields.datasetId, + rules: [{ required: true, message: '请选择数据集' }], + }, +}; + +const resourceConfigList = [ + 'master_replicas', + 'master_cpu', + 'master_mem', + 'ps_replicas', + 'ps_cpu', + 'ps_mem', + 'worker_replicas', + 'worker_cpu', + 'worker_mem', +]; + +type Props = { + job?: ModelJob; + jobType: ModelJobType; + createReq: (data: any) => Promise<any>; + patchReq: (jobId: ID, data: any) => Promise<any>; +}; + +const CreateForm: React.FC<Props> = ({ job, createReq, patchReq, jobType }) => { + const history = useHistory(); + const params = useParams<ModelEvaluationCreateParams & ModelEvaluationListParams>(); + const [form] = Form.useForm(); + const projectId = useGetCurrentProjectId(); + const myPureDomainName = useGetCurrentPureDomainName(); + const participantName = useGetCurrentProjectParticipantName(); + const participantList = useGetCurrentProjectParticipantList(); + const participantId = useGetCurrentProjectParticipantId(); + const isEdit = (params.action === 'edit' && params.id != null) || params.role === 'receiver'; + const isReceiver = params.role === 'receiver'; + const [selectedModelJob, setSelectedModelJob] = useState<Record<ModelJobFields, ID>>({ + [ModelJobFields.GROUP_KEY]: 0, + [ModelJobFields.ITEM_KEY]: params.id, + }); + const [modelJobIsOldVersion, setModelJobIsOldVersion] = useState<boolean>(false); + + const selectedDatasetRef = useRef<Dataset>({} as Dataset); + + const { data: nnTreeTemplateDetail } = useRecoilQuery(treeTemplateDetailQuery); + const { data: nnHorizontalEvalTemplateDetail } = useRecoilQuery( + nnHorizontalEvalTemplateDetailQuery, + ); + const { data: nnVerticalTemplateDetailQuery } = useRecoilQuery(nnTemplateDetailQuery); + + const relativeModel = useQuery( + ['model-evaluation-relative-model', job?.model_id], + () => fetchModelDetail_new(projectId!, job?.model_id!).then((res) => res.data), + { + enabled: Boolean(projectId && job?.model_id), + onSuccess(res) { + if (!res.group_id) { + return; + } + setSelectedModelJob({ + ...selectedModelJob, + [ModelJobFields.GROUP_KEY]: res.group_id, + }); + const modelData = { + [ModelJobFields.GROUP_KEY]: res.group_id!, + [ModelJobFields.ITEM_KEY]: res.model_job_id!, + }; + form.setFieldValue(Fields.modelId, { ...modelData }); + }, + }, + ); + + const peerModelJobData = useQuery( + ['model-evaluation-peer-model-detail'], + () => + fetchPeerModelJobDetail_new(projectId!, params.id, participantId!).then((res) => res.data), + { + enabled: Boolean(projectId && params.id && isEdit), + }, + ); + + const selectedModelJobDetail = useQuery( + ['model-evaluation-new-model-detail', selectedModelJob?.[ModelJobFields.ITEM_KEY]], + async () => { + const modelId = selectedModelJob?.[ModelJobFields.ITEM_KEY]; + if (!projectId || !modelId) { + return; + } + const jobDetail = await fetchModelJob_new(projectId, modelId); + return jobDetail.data; + }, + ); + + const createMutation = useMutation((value: any) => createReq(value), { + onError(err: any) { + Message.error(err.code === 409 ? '名称已存在' : err.message || err); + }, + onSuccess() { + Message.success('创建成功'); + history.replace( + generatePath(routes.ModelEvaluationList, { + module: params.module, + }), + ); + }, + }); + + const patchMutation = useMutation((value: any) => patchReq(params.id, value), { + onError(err: any) { + Message.error(err.message || err); + }, + onSuccess() { + Message.success('已授权模型评估,任务开始运行'); + history.replace( + generatePath(routes.ModelEvaluationList, { + module: params.module, + }), + ); + }, + }); + const algorithmDetailQueries = useQueries( + [...participantList.map((participant) => participant.pure_domain_name), myPureDomainName].map( + (pureDomain) => { + return { + queryKey: [ + 'fetch-algorithm-detail', + projectId, + selectedModelJobDetail?.data?.global_config?.global_config?.[pureDomain] + ?.algorithm_uuid!, + pureDomain, + ], + queryFn: async () => { + const res = await fetchAlgorithmByUuid( + projectId!, + selectedModelJobDetail?.data?.global_config?.global_config?.[pureDomain] + ?.algorithm_uuid!, + ); + return { [pureDomain]: res.data }; + }, + + retry: 2, + enabled: Boolean( + projectId && + selectedModelJobDetail?.data?.global_config?.global_config?.[pureDomain] + ?.algorithm_uuid, + ), + refetchOnWindowFocus: false, + }; + }, + ), + ); + const algorithmDetail = useMemo(() => { + let algorithmMap: Record<string, Algorithm> = {}; + algorithmDetailQueries.forEach((item) => { + const algorithmValue = item.data as { [key: string]: Algorithm }; + algorithmMap = { + ...algorithmMap, + ...algorithmValue, + }; + }); + return algorithmMap; + }, [algorithmDetailQueries]); + + useEffect(() => { + const data = job; + if (!data) { + return; + } + job?.dataset_id && + fetchDatasetDetail(job.dataset_id).then( + (detail) => { + selectedDatasetRef.current = detail.data; + }, + () => { + selectedDatasetRef.current.id = job?.dataset_id as ID; + }, + ); + + form.setFieldsValue({ + [Fields.name]: data.name, + [Fields.comment]: data.comment, + [Fields.datasetId]: data.dataset_id, + [Fields.algorithmType]: data.algorithm_type, + }); + }, [job, form]); + + useEffect(() => { + if (!relativeModel.data) { + return; + } + + const selectedModelJob = { + [ModelJobFields.GROUP_KEY]: relativeModel.data?.group_id!, + [ModelJobFields.ITEM_KEY]: relativeModel.data?.model_job_id!, + }; + setSelectedModelJob(selectedModelJob); + form.setFieldValue(Fields.modelId, { ...selectedModelJob }); + }, [relativeModel.data, form]); + + useEffect(() => { + if (!peerModelJobData.data || !modelJobIsOldVersion) { + return; + } + const resource_config = getConfigInitialValues( + peerModelJobData.data?.config!, + resourceConfigList, + ); + form.setFieldValue(Fields.config, { ...form.getFieldsValue().config, ...resource_config }); + }, [peerModelJobData, form, modelJobIsOldVersion]); + + useEffect(() => { + if (!job?.global_config?.global_config) { + return; + } + const globalConfig = job.global_config.global_config; + const myResourceConfig = getConfigInitialValuesByDefinition( + globalConfig?.[myPureDomainName]?.variables, + resourceConfigList, + ); + const participantResourceConfig: Record<string, any> = {}; + + participantList.forEach((participant) => { + participantResourceConfig[participant.pure_domain_name] = getConfigInitialValuesByDefinition( + globalConfig?.[participant.pure_domain_name]?.variables, + resourceConfigList, + ); + }); + form.setFieldValue(Fields.config, { ...form.getFieldsValue().config, ...myResourceConfig }); + form.setFieldValue('resource_config', { + ...form.getFieldsValue().resource_config, + ...participantResourceConfig, + }); + }, [form, job?.global_config?.global_config, myPureDomainName, participantList]); + + useEffect(() => { + if (isEdit) { + setModelJobIsOldVersion(!job?.global_config?.global_config); + return; + } + if (!selectedModelJob.dataset_id || selectedModelJobDetail.isFetching) { + setModelJobIsOldVersion(false); + return; + } + setModelJobIsOldVersion(!selectedModelJobDetail.data?.global_config?.global_config); + }, [ + isEdit, + job?.global_config?.global_config, + selectedModelJob.dataset_id, + selectedModelJobDetail.data?.global_config?.global_config, + selectedModelJobDetail.isFetching, + ]); + return ( + <> + {params.role === 'receiver' ? ( + <Card style={{ marginBottom: 20 }} bordered={false}> + <Space> + <Avatar /> + <div> + {!job ? ( + <Spin /> + ) : ( + <p className={styles.title_text_large}> + {participantName} + {params.module === 'model-evaluation' + ? `向您发起「${job.name}」的模型评估授权申请` + : `向您发起「${job.name}」的离线预测授权申请`} + </p> + )} + <small className={styles.small_text}> + <IconInfoCircle style={{ color: 'var(--color-text-3)', fontSize: 14 }} />{' '} + {`合作方均同意授权时,${ + params.module === 'model-evaluation' ? '评估' : '预测' + }任务将自动运行`} + </small> + </div> + </Space> + </Card> + ) : null} + <Card className={styles.page_section_card} bordered={false}> + <Form + className={`${styles.form_container} form-content`} + form={form} + {...formLayout} + onSubmit={modelJobIsOldVersion ? submitWrapper : submitWrapper_new} + disabled={createMutation.isLoading || patchMutation.isLoading} + onValuesChange={(changedValue: any) => { + if (changedValue[Fields.modelId] != null) { + setSelectedModelJob(changedValue[Fields.modelId]); + } + }} + > + <section className="form-section"> + <h3>基本信息</h3> + <Form.Item {...formConfig[Fields.name]}> + {isReceiver ? ( + <Typography.Text bold={true}> {job?.name} </Typography.Text> + ) : ( + <Input placeholder={'请输入名称'} /> + )} + </Form.Item> + <Form.Item {...formConfig[Fields.comment]}> + <Input.TextArea placeholder={'最多为 200 个字符'} /> + </Form.Item> + </section> + <section className="form-section"> + <h3>{params.module === 'model-evaluation' ? '评估配置' : '预测配置'}</h3> + <Form.Item {...formConfig[Fields.algorithmType]}> + {isReceiver ? ( + <Typography.Text bold={true}> + { + ALGORITHM_TYPE_LABEL_MAPPER[ + job?.algorithm_type || EnumAlgorithmProjectType.TREE_VERTICAL + ] + } + </Typography.Text> + ) : ( + <Select + options={algorithmTypeOptions} + showSearch={true} + onChange={() => { + form.setFieldValue(Fields.modelId, { + [ModelJobFields.GROUP_KEY]: undefined, + [ModelJobFields.ITEM_KEY]: undefined, + }); + }} + filterOption={(inputValue, option) => + option.props.children.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0 + } + /> + )} + </Form.Item> + <Form.Item + {...formConfig[Fields.modelId]} + shouldUpdate={shouldModelJobDoubleSelectUpdate} + > + {isReceiver ? ( + <Typography.Text bold={true}>{selectedModelJobDetail?.data?.name} </Typography.Text> + ) : ( + (values: any) => ( + <DoubleSelect.ModelJobGroupSelect + type={values[Fields.algorithmType]} + leftField={ModelJobFields.GROUP_KEY} + rightField={ModelJobFields.ITEM_KEY} + onLeftOptionsEmpty={() => { + form.setFields({ + [Fields.modelId]: { + error: { + message: '目标模型不存在,请联系合作伙伴重新选择', + }, + }, + }); + }} + isClearRightValueAfterLeftSelectChange={true} + /> + ) + )} + </Form.Item> + {selectedModelJob.dataset_id && + selectedModelJobDetail?.data?.algorithm_type && + selectedModelJobDetail.data.algorithm_type !== + EnumAlgorithmProjectType.TREE_VERTICAL && + !modelJobIsOldVersion && ( + <Form className={'form-content'} labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}> + <Form.Item label={'我方算法'} style={{ marginBottom: 0 }}> + <Typography.Text bold={true}> + <WhichAlgorithm + id={algorithmDetail?.[myPureDomainName]?.id} + uuid={algorithmDetail?.[myPureDomainName]?.uuid} + participantId={algorithmDetail?.[myPureDomainName]?.participant_id as ID} + /> + </Typography.Text> + </Form.Item> + {participantList.map((participant) => { + return ( + <Form.Item + key={participant.pure_domain_name} + label={`「${participant.name}」算法`} + > + <Typography.Text bold={true}> + <WhichAlgorithm + id={algorithmDetail?.[participant.pure_domain_name]?.id} + uuid={algorithmDetail?.[participant.pure_domain_name]?.uuid} + participantId={ + algorithmDetail?.[participant.pure_domain_name]?.participant_id as ID + } + /> + </Typography.Text> + </Form.Item> + ); + })} + </Form> + )} + <Form.Item {...formConfig[Fields.datasetId]}> + {isReceiver && !modelJobIsOldVersion ? ( + <Space> + <Typography.Text bold={true}> + {selectedDatasetRef.current.name || ''} + </Typography.Text> + <Tag color="arcoblue">结果</Tag> + </Space> + ) : ( + <DatasesetSelect + lazyLoad={{ + page_size: 10, + enable: true, + }} + kind={DatasetKindLabel.PROCESSED} + onChange={async (_, option) => { + const dataset = (option as OptionInfo)?.extra; + selectedDatasetRef.current = dataset; + }} + /> + )} + </Form.Item> + </section> + <section className="form-section"> + <h3>{'资源配置'}</h3> + <Form.Item + {...formConfig[Fields.config]} + shouldUpdate={(pre, cur) => pre[Fields.algorithmType] !== cur[Fields.algorithmType]} + disabled={isReceiver && !modelJobIsOldVersion} + > + {(val: any) => ( + <ResourceConfig + key={myPureDomainName} + algorithmType={val[Fields.algorithmType]} + defaultResourceType={ResourceTemplateType.CUSTOM} + isIgnoreFirstRender={false} + localDisabledList={['master.replicas']} + collapsedOpen={false} + /> + )} + </Form.Item> + </section> + {!modelJobIsOldVersion && + participantList.map((participant) => { + return ( + <section className="form-section" key={participant.pure_domain_name}> + <h3>{`「${participant.name}」资源配置`}</h3> + <Form.Item + field={`resource_config.${participant.pure_domain_name}`} + label="资源配置" + rules={[{ required: true }]} + shouldUpdate={(pre, cur) => + pre[Fields.algorithmType] !== cur[Fields.algorithmType] + } + disabled={isReceiver && !modelJobIsOldVersion} + > + {(val: any) => ( + <ResourceConfig + algorithmType={val[Fields.algorithmType]} + defaultResourceType={ResourceTemplateType.CUSTOM} + isIgnoreFirstRender={false} + localDisabledList={['master.replicas']} + collapsedOpen={false} + /> + )} + </Form.Item> + </section> + ); + })} + <Space size="large"> + <Button + className={styles.submit_btn_container} + onClick={() => form.submit()} + size="large" + type="primary" + loading={createMutation.isLoading || patchMutation.isLoading} + > + {params.role === 'receiver' + ? '确认授权' + : form.getFieldValue(Fields.algorithmType) !== + EnumAlgorithmProjectType.NN_HORIZONTAL + ? '提交并发送' + : '提交'} + </Button> + <ButtonWithModalConfirm + disabled={ + createMutation.isLoading || + patchMutation.isLoading || + !selectedModelJobDetail.isFetched + } + isShowConfirmModal={true} + size="large" + onClick={() => { + history.push( + generatePath(routes.ModelEvaluationList, { + module: params.module, + }), + ); + }} + title={params.action === 'edit' ? `确认要退出编辑「${job?.name}」?` : '确认要退出?'} + content={'退出后,当前所填写的信息将被清空。'} + > + 取消 + </ButtonWithModalConfirm> + {!modelJobIsOldVersion && + form.getFieldValue(Fields.algorithmType) !== + EnumAlgorithmProjectType.NN_HORIZONTAL && ( + <Form.Item + field="metric_is_public" + triggerPropName="checked" + style={{ marginBottom: 0 }} + > + <Checkbox style={{ width: 200, fontSize: 12 }}> + 共享模型评估结果 + <Tooltip content="共享后,合作伙伴能够查看本方评估结果"> + <IconQuestionCircle /> + </Tooltip> + </Checkbox> + </Form.Item> + )} + </Space> + </Form> + </Card> + </> + ); + + async function submitWrapper(value: any) { + const algorithmType = selectedModelJobDetail.data?.algorithm_type || job?.algorithm_type; + const selectedModelJob = selectedModelJobDetail.data; + const selectedDataset = selectedDatasetRef.current; + + if (!algorithmType || !selectedDataset || !selectedModelJob) { + return; + } + + let template: WorkflowTemplate | null = null; + + switch (algorithmType) { + case EnumAlgorithmProjectType.NN_HORIZONTAL: + template = nnHorizontalEvalTemplateDetail; + break; + case EnumAlgorithmProjectType.NN_VERTICAL: + template = nnVerticalTemplateDetailQuery; + break; + case EnumAlgorithmProjectType.TREE_VERTICAL: + template = nnTreeTemplateDetail; + break; + } + + const payload = { + ...value, + [Fields.algorithmType]: algorithmType, + [MODEL_JOB_TYPE_FIELD]: jobType, + [ALGORITHM_ID_FIELD]: selectedModelJob?.algorithm_id, + [MODEL_JOB_ID_FIELD]: selectedModelJob?.id, // handle DoubleSelect value + config: createPayloadWithWorkflowTemplate( + value, + selectedModelJob, + selectedDataset, + cloneDeep(template), + ), + }; + + if (isEdit) { + patchMutation.mutate(omit(payload, disabledFieldWhenEdit)); + return; + } + createMutation.mutate(omit(payload, Fields.modelId)); + } + + async function submitWrapper_new(value: any) { + const algorithmType = selectedModelJobDetail.data?.algorithm_type || job?.algorithm_type; + const selectedModelJob = selectedModelJobDetail.data; + const selectedDataset = selectedDatasetRef.current; + + if (!algorithmType || !selectedDataset || !selectedModelJob) { + return; + } + + const globalConfig: Record<string, any> = {}; + const coordinatorGlobalConfig = selectedModelJob.global_config?.global_config[myPureDomainName]; + const baseConfig = getConfigInitialValuesByDefinition( + coordinatorGlobalConfig?.variables!, + coordinatorGlobalConfig?.variables.map((item) => item.name), + ); + globalConfig[myPureDomainName] = { + algorithm_uuid: coordinatorGlobalConfig?.algorithm_uuid, + algorithm_parameter: coordinatorGlobalConfig?.algorithm_parameter, + variables: hydrateModalGlobalConfig(coordinatorGlobalConfig?.variables!, { + ...baseConfig, + ...value.config, + }), + }; + participantList.forEach((participant) => { + const pureDomainName = participant.pure_domain_name; + const participantGlobalConfig = selectedModelJob.global_config?.global_config[pureDomainName]; + const participantBaseConfig = getConfigInitialValuesByDefinition( + participantGlobalConfig?.variables!, + participantGlobalConfig?.variables.map((item) => item.name), + ); + globalConfig[pureDomainName] = { + algorithm_uuid: participantGlobalConfig?.algorithm_uuid, + algorithm_parameter: participantGlobalConfig?.algorithm_parameter, + variables: hydrateModalGlobalConfig(participantGlobalConfig?.variables!, { + ...participantBaseConfig, + ...value.resource_config[pureDomainName], + }), + }; + }); + const payload = { + name: value.name, + comment: value.comment, + dataset_id: value.dataset_id, + [Fields.algorithmType]: algorithmType, + [MODEL_JOB_TYPE_FIELD]: jobType, + [MODEL_JOB_ID_FIELD]: selectedModelJob?.id, + model_id: selectedModelJob?.output_models[0].id, + global_config: { + dataset_uuid: selectedDatasetRef.current?.uuid, + model_uuid: selectedModelJob?.output_models[0].uuid, + global_config: globalConfig, + }, + }; + + if (isEdit) { + patchMutation.mutate( + omit( + { ...payload, metric_is_public: value.metric_is_public }, + disabledFieldWhenEdit.concat(['global_config', 'dataset_id']), + ), + ); + return; + } + try { + if (!projectId) { + Message.info('请选择工作区!'); + return; + } + const res = await createModelJob(projectId!, payload); + value.metric_is_public && updateModelJob(projectId!, res.data.id, { metric_is_public: true }); + Message.success('创建成功'); + history.replace( + generatePath(routes.ModelEvaluationList, { + module: params.module, + }), + ); + } catch (err: any) { + Message.error(err.message); + } + } +}; + +function shouldModelJobDoubleSelectUpdate(prev: any, cur: any) { + return ( + prev[Fields.algorithmType] !== cur[Fields.algorithmType] || + prev[Fields.modelId]?.[ModelJobFields.GROUP_KEY] !== + cur[Fields.modelId]?.[ModelJobFields.GROUP_KEY] || + prev[Fields.modelId]?.[ModelJobFields.ITEM_KEY] !== + cur[Fields.modelId]?.[ModelJobFields.ITEM_KEY] + ); +} + +function createPayloadWithWorkflowTemplate( + formValue: any, + relativeJob: ModelJob, + dataset: Dataset, + template: WorkflowTemplate | null, +) { + if (!template) { + return {}; + } + + const { variables: tplVariables } = template.config.job_definitions[0]; + const { variables: jobVariables } = relativeJob.config.job_definitions[0]; + const varInForm = formValue.config; + + for (let i = 0; i < tplVariables.length; i++) { + const variable = tplVariables[i]; + const { name } = variable; + + if (varInForm.hasOwnProperty(variable.name)) { + variable.value = varInForm[variable.name]; + } else { + if (name === 'data_source' && dataset.data_source) { + variable.value = dataset.data_source; + } else if (name === 'data_path' && dataset.path) { + variable.value = dataset.path; + } else { + const jobVariable = jobVariables.find((v) => v.name === name); + switch (name) { + case 'mode': + variable.value = 'eval'; + break; + case 'load_model_name': + variable.value = relativeJob.job_name; + break; + default: + if (jobVariable) { + tplVariables[i] = { ...jobVariable }; + } + break; + } + } + } + } + + const processedTypedValueConfig = stringifyComplexDictField(template); + return processedTypedValueConfig.config; +} + +export default CreateForm; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ListTable/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ListTable/index.tsx new file mode 100644 index 000000000..137654b9a --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ListTable/index.tsx @@ -0,0 +1,198 @@ +import React, { useMemo } from 'react'; +import { PaginationProps, Table, TableProps, Space } from '@arco-design/web-react'; +import { generatePath, Link } from 'react-router-dom'; +import StateIndicator from 'components/StateIndicator'; +import { ModelJobState } from 'typings/modelCenter'; +import { ModelJob } from 'typings/modelCenter'; +import { + ColumnsGetterOptions, + algorithmTypeFilters, + roleFilters, + statusFilters, + getModelJobStatus, +} from '../../shared'; +import { formatTimestamp } from 'shared/date'; +import MoreActions from 'components/MoreActions'; +import routeMaps from '../../routes'; +import { CONSTANTS } from 'shared/constants'; +import AlgorithmType from 'components/AlgorithmType'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; + +const staticPaginationProps: Partial<PaginationProps> = { + pageSize: 10, + defaultCurrent: 0, + showTotal: true, + sizeCanChange: false, +}; + +export const getTableColumns = (options: ColumnsGetterOptions) => { + const cols = [ + { + title: options.nameFieldText, + dataIndex: 'name', + key: 'name', + ellipsis: true, + render: (_: any, record: ModelJob) => { + const name = record.name ? record.name : CONSTANTS.EMPTY_PLACEHOLDER; + return ( + <Link + to={generatePath(routeMaps.ModelEvaluationDetail, { + id: record.id, + module: options.module, + })} + > + {name} + </Link> + ); + }, + }, + { + title: '类型', + dataIndex: 'algorithm_type', + key: 'algorithm_type', + width: 200, + filteredValue: options.filterDropdownValues?.algorithm_type, + filters: algorithmTypeFilters.filters, + render(value: ModelJob['algorithm_type']) { + return <AlgorithmType type={value as EnumAlgorithmProjectType} />; + }, + }, + { + title: '运行状态', + dataIndex: 'status', + key: 'status', + name: 'status', + width: 200, + filteredValue: options.filterDropdownValues?.status, + filters: statusFilters.filters, + render: (name: any, record: any) => { + return ( + <StateIndicator + {...getModelJobStatus(record.status, { + ...options, + isHideAllActionList: true, + onLogClick: () => { + options.onLogClick && options.onLogClick(record); + }, + onRestartClick: () => { + options.onRestartClick && options.onRestartClick(record); + }, + })} + /> + ); + }, + }, + { + title: '创建者', + dataIndex: 'creator_username', + key: 'creator_username', + name: 'creator', + width: 200, + render(val: string) { + return val ?? CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + title: '发起方', + dataIndex: 'role', + name: 'role', + key: 'role', + width: 200, + filters: roleFilters.filters, + filterMultiple: false, + filteredValue: options.filterDropdownValues?.role, + render(role: string) { + return role === 'COORDINATOR' ? '本方' : '合作伙伴'; + }, + }, + { + title: '创建时间', + dataIndex: 'created_at', + name: 'created_at', + key: 'created_at', + width: 200, + sorter(a: ModelJob, b: ModelJob) { + return a.created_at - b.created_at; + }, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + ]; + if (!options.withoutActions) { + cols.push({ + title: '操作', + dataIndex: 'state', + key: 'operation', + name: 'operation', + width: 200, + fixed: 'right', + render: (state: ModelJobState, record: ModelJob) => { + const disabledTerminateOperate = state !== ModelJobState.RUNNING; + return ( + <Space> + <button + className="custom-text-button" + disabled={disabledTerminateOperate} + onClick={() => { + if (disabledTerminateOperate) return; + options.onStopClick && options.onStopClick(record); + }} + > + 终止 + </button> + <MoreActions + actionList={[ + { + label: '删除', + onClick: () => { + options.onDeleteClick && options.onDeleteClick(record); + }, + danger: true, + }, + ]} + /> + </Space> + ); + }, + } as any); + } + + return cols; +}; + +interface EvaluationTableProps extends Omit<TableProps, 'columns'>, ColumnsGetterOptions {} +const EvaluationTable: React.FC<EvaluationTableProps> = (props) => { + const { + module, + pagination = false, + onDeleteClick, + onStopClick, + nameFieldText, + filterDropdownValues = {}, + } = props; + const paginationProps = useMemo(() => { + return { + ...staticPaginationProps, + ...(typeof pagination === 'object' ? pagination : {}), + }; + }, [pagination]); + return ( + <Table + rowKey="uuid" + className="custom-table-left-side-filter" + scroll={{ + x: 1500, + }} + columns={getTableColumns({ + module, + onDeleteClick, + onStopClick, + nameFieldText, + filterDropdownValues, + })} + pagination={paginationProps} + {...props} + /> + ); +}; + +export default EvaluationTable; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationCreate/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationCreate/index.tsx new file mode 100644 index 000000000..bb027b321 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationCreate/index.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { generatePath, useHistory, useParams } from 'react-router'; +import { ModelJobCreateFormData, ModelJobPatchFormData } from 'typings/modelCenter'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BackButton from 'components/BackButton'; +import * as service from 'services/modelCenter'; +import CreateForm from '../CreateForm'; +import { useRecoilValue } from 'recoil'; +import { projectState } from 'stores/project'; +import routeMap, { + ModelEvaluationListParams, + ModelEvaluationCreateParams, + ModelEvaluationModuleType, +} from '../../routes'; +import { useQuery } from 'react-query'; + +const Create: React.FC = () => { + const history = useHistory(); + const params = useParams<ModelEvaluationCreateParams & ModelEvaluationListParams>(); + const selectedProject = useRecoilValue(projectState); + const project_id = selectedProject.current?.id; + const { data: job } = useQuery( + [params.id, params.action], + () => service.fetchModelJob_new(project_id!, params.id).then((res) => res.data), + { + enabled: params.action === 'edit' && Boolean(project_id), + }, + ); + + return ( + <SharedPageLayout + contentWrapByCard={false} + title={ + <BackButton + isShowConfirmModal={true} + modalClassName="custom-modal" + title={params.action === 'edit' ? `确认要退出编辑「${job?.name}」?` : '确认要退出?'} + content={'退出后,当前所填写的信息将被清空。'} + onClick={() => { + history.push( + generatePath(routeMap.ModelEvaluationList, { + module: params.module, + }), + ); + }} + > + {params.module === ModelEvaluationModuleType.Evaluation ? '模型评估' : '离线预测'} + </BackButton> + } + centerTitle={params.module === ModelEvaluationModuleType.Evaluation ? '创建评估' : '创建预测'} + > + <CreateForm + job={job} + jobType={ + params.module === ModelEvaluationModuleType.Evaluation ? 'EVALUATION' : 'PREDICTION' + } + createReq={createReqWrapper} + patchReq={patchReqWrapper} + /> + </SharedPageLayout> + ); + + function createReqWrapper(config: ModelJobCreateFormData) { + if (!project_id) { + return Promise.reject('请选择工作区'); + } + return service.createModelJob_new(project_id, config); + } + + function patchReqWrapper(jobId: ID, config: ModelJobPatchFormData) { + return service.updateModelJob_new(project_id!, jobId, config); + } +}; + +export default Create; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationDetail/index.less b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationDetail/index.less new file mode 100644 index 000000000..d1979c549 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationDetail/index.less @@ -0,0 +1,35 @@ +.padding-container-card{ + padding: 20px; + padding-bottom: 0px; + .eval-name{ + margin-bottom: 0px; + font-size: 16px; + height: 24px; + font-weight: 600; + } + .eval-comment{ + font-size: 12px; + line-height: 1.2; + color: var(--textColorSecondary); + } + .eval-section-title{ + display: block; + margin-bottom: 5px; + } +} +.pop-title{ + color: #4e5969; +} +.pop-content{ + color: #1d2129; +} +.styled-link{ + display: inline-block; + font-weight: 400; + font-size: 12px; + line-height: 20px; + margin-bottom: 12px; +} +.padding-top-card{ + padding-top: 0px; +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationDetail/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationDetail/index.tsx new file mode 100644 index 000000000..cf4f3e97a --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationDetail/index.tsx @@ -0,0 +1,308 @@ +import React, { useState } from 'react'; +import { generatePath, Redirect, useHistory, useParams } from 'react-router'; +import { Link } from 'react-router-dom'; +import { Grid, Tabs, Space, Typography, Message, Popover, Button } from '@arco-design/web-react'; +import { IconShareInternal } from '@arco-design/web-react/icon'; +import { useQuery } from 'react-query'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BackButton from 'components/BackButton'; +import GridRow from 'components/_base/GridRow'; +import MoreActions from 'components/MoreActions'; +import PropertyList from 'components/PropertyList'; +import StateIndicator from 'components/StateIndicator'; +import AlgorithmType from 'components/AlgorithmType'; +import CountTime from 'components/CountTime'; +import { getFullModelJobDownloadHref } from 'services/modelCenter'; +import { fetchModelDetail_new, fetchModelJob_new } from 'services/modelCenter'; +import { useGetCurrentProjectId } from 'hooks'; + +import { Avatar, deleteEvaluationJob, getModelJobStatus } from '../../shared'; +import routes, { ModelEvaluationDetailParams, ModelEvaluationDetailTab } from '../../routes'; +import ReportResult from '../../ReportResult'; +import InstanceInfo from '../../InstanceInfo'; +import WhichRole from '../WhichRole'; +import { formatTimestamp } from 'shared/date'; +import ResourceConfigTable from 'views/ModelCenter/ResourceConfigTable'; +import ModelJobDetailDrawer from 'views/ModelCenter/ModelJobDetailDrawer'; +import { TIME_INTERVAL, CONSTANTS } from 'shared/constants'; +import { ModelJobState, ModelJobStatus } from 'typings/modelCenter'; +import request from 'libs/request'; +import { isNNAlgorithm } from 'views/ModelCenter/shared'; + +import './index.less'; + +const ModelEvaluation: React.FC = () => { + const params = useParams<ModelEvaluationDetailParams>(); + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + const [metricIsPublic, setMetricIsPublic] = useState(false); + const detailQuery = useQuery( + ['model-valuation-detail-page-query', params.id, projectId], + () => fetchModelJob_new(projectId!, params.id).then((res) => res.data), + { + enabled: Boolean(projectId), + refetchInterval: TIME_INTERVAL.LIST, + onSuccess: (res) => { + const { metric_is_public } = res; + setMetricIsPublic(!!metric_is_public); + }, + }, + ); + + const { data: detail } = detailQuery; + const { data: relativeModelData } = useQuery( + ['model_valuation-detail-relative-model', detail?.model_id, projectId], + () => fetchModelDetail_new(projectId!, detail?.model_id!).then((res) => res.data), + { + enabled: Boolean(detail?.model_id && projectId), + }, + ); + + const isModelEvaluation = params.module === 'model-evaluation'; + const isJobRunning = detail?.state === ModelJobState.RUNNING; + + const propertyList = [ + { + label: '运行状态', + value: detail ? ( + <StateIndicator {...getModelJobStatus(detail.status, { isHideAllActionList: true })} /> + ) : null, + }, + { + label: '发起方', + value: <WhichRole job={detail} />, + }, + { + label: '创建者', + value: detail?.creator_username || '-', + }, + { + label: '模型', + value: relativeModelData?.model_job_id ? ( + <ModelJobDetailDrawer.Button + id={relativeModelData?.model_job_id} + text={relativeModelData?.name} + title={ + <Space> + {relativeModelData?.name} + <StateIndicator + tag={true} + {...getModelJobStatus(detail?.status as ModelJobStatus, { + isHideAllActionList: true, + })} + /> + </Space> + } + /> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + }, + { + label: '数据集', + value: + detail?.dataset_name ?? detail?.intersection_dataset_name ? ( + <Link to={`/datasets/processed/detail/${detail?.dataset_id}/dataset_job_detail`}> + {detail?.dataset_name ?? detail?.intersection_dataset_name} + </Link> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + }, + { + label: '资源配置', + value: detail && ( + <ResourceConfigTable.Button + job={detail} + popoverProps={{ position: 'bl', style: { maxWidth: 500, width: 500 } }} + /> + ), + }, + { + label: '创建时间', + value: detail?.created_at ? formatTimestamp(detail?.created_at) : CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + label: '开始时间', + value: detail?.started_at && formatTimestamp(detail.started_at), + }, + { + label: '结束时间', + value: + detail?.state === ModelJobState.COMPLETED && detail?.stopped_at + ? formatTimestamp(detail.stopped_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + label: '运行时长', + value: + detail?.started_at || detail?.stopped_at ? ( + <CountTime + time={ + isJobRunning + ? Math.floor(Date.now() / 1000) - (detail?.started_at ?? 0) + : (detail?.stopped_at ?? 0) - (detail?.started_at ?? 0) + } + isStatic={!isJobRunning} + /> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + }, + ]; + + const refreshModelJobDetail = () => { + detailQuery.refetch(); + }; + + return ( + <SharedPageLayout + cardPadding={0} + title={ + <BackButton onClick={() => goToListPage()}> + {isModelEvaluation ? '模型评估' : '离线预测'} + </BackButton> + } + > + <div className="padding-container-card"> + <Grid.Row align="center" justify="space-between"> + <GridRow gap="12" style={{ maxWidth: '85%' }}> + <Avatar data-name={detail?.name} /> + <div> + <Space> + <h3 className="eval-name">{detail?.name}</h3> + {detail && <AlgorithmType type={detail.algorithm_type} />} + </Space> + <small className="eval-comment">{detail?.comment}</small> + </div> + </GridRow> + + <GridRow> + <MoreActions + actionList={[ + { + label: '删除', + disabled: !projectId || !detail, + danger: true, + onClick: async () => { + if (projectId && detail) { + try { + const res = await deleteEvaluationJob( + projectId, + detail, + params.module, + ).then(); + if (res) { + goToListPage(true); + } + } catch (e) {} + } + }, + }, + ]} + /> + </GridRow> + </Grid.Row> + <div> + <PropertyList cols={5} colProportions={[1, 1, 1, 1, 1]} properties={propertyList} /> + </div> + </div> + {!params.tab && <Redirect to={getTabPath(ModelEvaluationDetailTab.Result)} />} + {params.module === 'model-evaluation' && ( + <> + <Tabs + defaultActiveTab={params.tab} + onChange={(tab) => history.push(getTabPath(tab))} + style={{ marginBottom: 0 }} + > + <Tabs.TabPane title={'评估结果'} key={ModelEvaluationDetailTab.Result} /> + <Tabs.TabPane title={'实例信息'} key={ModelEvaluationDetailTab.Info} /> + </Tabs> + <div className="padding-container-card padding-top-card"> + {params.tab === ModelEvaluationDetailTab.Result && ( + <ReportResult + onSwitch={refreshModelJobDetail} + metricIsPublic={metricIsPublic} + id={params.id} + isTraining={false} + isNNAlgorithm={detail ? isNNAlgorithm(detail?.algorithm_type) : false} + algorithmType={detail?.algorithm_type} + /> + )} + {params.tab === ModelEvaluationDetailTab.Info && detail?.job_id && ( + <> + <InstanceInfo id={params.id} jobId={detail?.job_id} /> + <Popover + trigger="hover" + position="br" + content={ + <> + <div className="pop-title">工作流</div> + <Link + className="styled-link" + to={`/workflow-center/workflows/${detail?.workflow_id}`} + > + 点击查看工作流 + </Link> + <div className="pop-title">工作流 ID</div> + <div className="pop-content">{detail?.workflow_id}</div> + </> + } + > + <Button size="mini" type="text"> + 更多信息 + </Button> + </Popover> + </> + )} + </div> + </> + )} + {params.module === 'offline-prediction' && detail?.job_id && ( + <div className="padding-container-card padding-top-card"> + <div> + <Typography.Text bold={true} className="custom-typography eval-section-title"> + 预测结果 + </Typography.Text> + <button className="custom-text-button" onClick={downloadDataset}> + <IconShareInternal /> + 结果数据集 + </button> + </div> + <div style={{ marginTop: 20 }}> + <Typography.Text bold={true} className="custom-typography eval-section-title"> + 实例信息 + </Typography.Text> + <InstanceInfo id={params.id} jobId={detail?.job_id} style={{ marginTop: 0 }} /> + </div> + </div> + )} + </SharedPageLayout> + ); + + function getTabPath(tab: string) { + return generatePath(routes.ModelEvaluationDetail, { + ...params, + tab: tab as ModelEvaluationDetailTab, + }); + } + + async function downloadDataset() { + try { + const tip = await request.download(getFullModelJobDownloadHref(projectId!, detail?.id!)); + tip && Message.info(tip); + } catch (error) { + Message.error(error.message); + } + } + + function goToListPage(isReplace = false) { + history[isReplace ? 'replace' : 'push']( + generatePath(routes.ModelEvaluationList, { + module: params.module, + }), + ); + } +}; + +export default ModelEvaluation; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationList/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationList/index.tsx new file mode 100644 index 000000000..d99f5f4fa --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationList/index.tsx @@ -0,0 +1,194 @@ +import React, { FC, useMemo } from 'react'; +import { debounce } from 'lodash-es'; +import { useQuery } from 'react-query'; +import { generatePath, useHistory, useParams } from 'react-router'; +import { Button, Input, Message } from '@arco-design/web-react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import GridRow from 'components/_base/GridRow'; +import TodoPopover from 'components/TodoPopover'; +import { useGetCurrentProjectId, useTablePaginationWithUrlState, useUrlState } from 'hooks'; +import * as service from 'services/modelCenter'; +import { ModelJobState } from 'typings/modelCenter'; +import { ModelJob, ModelJobQueryParams_new as ModelJobQueryParams } from 'typings/modelCenter'; +import { TIME_INTERVAL } from 'shared/constants'; + +import routesMap, { ModelEvaluationListParams } from '../../routes'; +import EvaluationTable from '../ListTable'; +import { + dangerConfirmWrapper, + deleteEvaluationJob, + FILTER_MODEL_JOB_OPERATOR_MAPPER, +} from '../../shared'; +import { IconPlus } from '@arco-design/web-react/icon'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { expression2Filter } from 'shared/filter'; + +type TProps = {}; +const { Search } = Input; +const List: FC<TProps> = function () { + const history = useHistory(); + const params = useParams<ModelEvaluationListParams>(); + const [urlState, setUrlState] = useUrlState<ModelJobQueryParams>({}); + const { urlState: pageInfo, paginationProps } = useTablePaginationWithUrlState(); + const projectId = useGetCurrentProjectId(); + const isModelEvaluation = params.module === 'model-evaluation'; + const listQuery = useQuery( + [urlState.keyword, pageInfo.page, pageInfo.pageSize, projectId, params.module, urlState.filter], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return service.fetchModelJobList_new(projectId, { + page: pageInfo.page, + page_size: pageInfo.pageSize, + types: params.module === 'offline-prediction' ? 'PREDICTION' : 'EVALUATION', + states: [ + ModelJobState.COMPLETED, + ModelJobState.FAILED, + ModelJobState.INVALID, + ModelJobState.PARTICIPANT_CONFIGURING, + ModelJobState.PREPARE_RUN, + ModelJobState.PREPARE_STOP, + ModelJobState.READY_TO_RUN, + ModelJobState.RUNNING, + ModelJobState.STOPPED, + ModelJobState.WARMUP_UNDERHOOD, + ], + filter: urlState.filter || undefined, + }); + }, + { + enabled: Boolean(projectId), + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + keepPreviousData: true, + refetchOnWindowFocus: false, + onError(err: any) { + Message.error(err.message || err); + }, + }, + ); + const { isFetching, data } = listQuery; + const pagination = useMemo(() => { + return (data?.page_meta?.total_items || 0) <= paginationProps.pageSize + ? false + : { ...paginationProps, total: data?.page_meta?.total_items }; + }, [data?.page_meta?.total_items, paginationProps]); + return ( + <SharedPageLayout + title={isModelEvaluation ? '模型评估' : '离线预测'} + rightTitle={<TodoPopover.EvaluationModelNew module={params.module} />} + key={params.module} + > + <GridRow justify="space-between" align="center"> + <Button + type="primary" + className={'custom-operation-button'} + onClick={goToCreatePage} + icon={<IconPlus />} + > + {params.module === 'model-evaluation' ? '创建评估' : '创建预测'} + </Button> + + <Search + className={'custom-input'} + allowClear + placeholder={isModelEvaluation ? '输入评估任务名称' : '输入预测任务名称'} + defaultValue={urlState.keyword} + onChange={debounce((keyword) => { + const filter = expression2Filter(urlState.filter); + setUrlState((preState) => ({ + ...preState, + page: 1, + keyword, + filter: filterExpressionGenerator( + { ...filter, name: keyword }, + FILTER_MODEL_JOB_OPERATOR_MAPPER, + ), + })); + }, 300)} + /> + </GridRow> + <EvaluationTable + className="custom-table custom-table-left-side-filter" + data={data?.data} + loading={isFetching} + module={params.module} + pagination={pagination} + filterDropdownValues={{ + algorithm_type: expression2Filter(urlState.filter).algorithm_type, + status: expression2Filter(urlState.filter).status, + role: expression2Filter(urlState.filter).role, + }} + nameFieldText={!isModelEvaluation ? '预测任务名称' : '评估任务名称'} + onChange={(_, sorter, filters, extra) => { + if (extra.action === 'filter') { + setUrlState((preState) => ({ + ...preState, + filter: filterExpressionGenerator( + { + algorithm_type: filters.algorithm_type, + status: filters.status, + role: filters.role, + name: urlState.keyword, + }, + FILTER_MODEL_JOB_OPERATOR_MAPPER, + ), + page: 1, + })); + } + }} + onStopClick={(job: ModelJob) => { + return stopJob(job); + }} + onDeleteClick={(job: ModelJob) => { + return deleteJob(job); + }} + /> + </SharedPageLayout> + ); + + function goToCreatePage() { + history.push( + generatePath(routesMap.ModelEvaluationCreate, { + module: params.module, + role: 'sender', + action: 'create', + }), + ); + } + + async function stopJob(job: ModelJob) { + if (!projectId) { + throw new Error('请选择工作区'); + } + + dangerConfirmWrapper( + `确认要终止「${job.name}」?`, + '终止后,该评估任务将无法重新运行,请谨慎操作', + '终止', + async () => { + try { + await service.stopJob_new(projectId!, job.id); + Message.success('停止成功'); + listQuery.refetch(); + } catch (e) { + Message.error(e.message); + } + }, + ); + } + + async function deleteJob(job: ModelJob) { + if (!projectId) { + throw new Error('请选择工作区'); + } + try { + await deleteEvaluationJob(projectId, job, params.module); + listQuery.refetch(); + } catch (error: any) {} + } +}; + +export default List; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/WhichRole/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/WhichRole/index.tsx new file mode 100644 index 000000000..98a8552a1 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/WhichRole/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { ModelJob } from 'typings/modelCenter'; +import { useGetCurrentProjectParticipantName } from 'hooks'; + +const WhichRole: React.FC<{ job?: ModelJob }> = ({ job }) => { + const participantName = useGetCurrentProjectParticipantName(); + if (!job) { + return null; + } + return <span>{job.role === 'PARTICIPANT' ? participantName : '本方'}</span>; +}; + +export default WhichRole; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/index.tsx new file mode 100644 index 000000000..7958ddd59 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/index.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Route, useRouteMatch, useParams, generatePath, Redirect } from 'react-router'; +import List from './ModelEvaluationList'; +import Detail from './ModelEvaluationDetail'; +import routesMap, { ModelEvaluationListParams } from '../routes'; + +const ModelEvaluation: React.FC = () => { + const matched = useRouteMatch(); + const params = useParams<ModelEvaluationListParams>(); + + return ( + <> + <Route exact path={routesMap.ModelEvaluationList} component={List} /> + <Route exact path={routesMap.ModelEvaluationDetail} component={Detail} /> + {matched.path === routesMap.ModelEvaluation && matched.isExact ? ( + <Redirect + to={generatePath(routesMap.ModelEvaluationList, { + module: params.module, + })} + /> + ) : null} + </> + ); +}; + +export default ModelEvaluation; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelJobDetailDrawer.tsx b/web_console_v2/client/src/views/ModelCenter/ModelJobDetailDrawer.tsx new file mode 100644 index 000000000..1b0f9d488 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelJobDetailDrawer.tsx @@ -0,0 +1,607 @@ +import React, { useState, useMemo } from 'react'; +import dayjs from 'dayjs'; +import { useQuery } from 'react-query'; +import { Link } from 'react-router-dom'; + +import { fetchModelJobDetail_new } from 'services/modelCenter'; +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useGetCurrentPureDomainName, +} from 'hooks'; + +import { formatJSONValue } from 'shared/helpers'; +import { formatTimestamp } from 'shared/date'; +import { + ALGORITHM_TYPE_LABEL_MAPPER, + isNNAlgorithm, + isTreeAlgorithm, + TRAIN_ROLE, +} from 'views/ModelCenter/shared'; +import { CONSTANTS } from 'shared/constants'; + +import { Drawer, Popover, Table, Button, Space, Tabs, Tag } from '@arco-design/web-react'; +import { LabelStrong } from 'styles/elements'; +import PropertyList from 'components/PropertyList'; +import BackButton from 'components/BackButton'; +import CodeEditor from 'components/CodeEditor'; +import CountTime from 'components/CountTime'; +import StateIndicator from 'components/StateIndicator'; +import ReportResult from './ReportResult'; +import InstanceInfo from './InstanceInfo'; + +import { DrawerProps } from '@arco-design/web-react/es/Drawer'; +import { WorkflowState } from 'typings/workflow'; +import AlgorithmDrawer from 'components/AlgorithmDrawer'; +import { fetchPodLogs, fetchJobById } from 'services/workflow'; +import { Pod, JobState } from 'typings/job'; +import WhichAlgorithm from 'components/WhichAlgorithm'; +import { ModelJob, ModelJobStatus } from 'typings/modelCenter'; +import ResourceConfigTable from './ResourceConfigTable'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { getDefaultVariableValue } from 'shared/modelCenter'; +import { getModelJobStatus, LABEL_MAPPER } from './shared'; + +import './index.less'; +import { fetchAlgorithmByUuid } from 'services/algorithm'; +import { fetchDataBatchById } from 'services/dataset'; + +const { TabPane } = Tabs; +interface Props extends DrawerProps { + /** Model job id */ + id: ID; + pod?: Pod; + showCodeEditorBackBtn?: boolean; + datasetBatchType?: 'day' | 'hour'; +} +type ButtonProps = Omit<Props, 'visible'> & { + text?: string; + btnDisabled?: boolean; +}; + +const getPropertyList = (algorithmType?: EnumAlgorithmProjectType) => { + if (!algorithmType) { + return []; + } + + return [ + { + text: '联邦类型', + key: 'algorithm_type', + render(_: any, job?: ModelJob) { + return ALGORITHM_TYPE_LABEL_MAPPER[job?.algorithm_type || 'NN_VERTICAL']; + }, + }, + isNNAlgorithm(algorithmType) + ? { + text: '算法', + render(value: any) { + const { + algorithmProjectId, + algorithmId, + config = [], + algorithmUuid, + algorithmProjectUuid, + participantId, + } = JSON.parse(value?.algorithm?.value || '{}'); + return ( + <AlgorithmDrawer.Button + algorithmProjectId={algorithmProjectId} + algorithmId={algorithmId} + parameterVariables={config} + algorithmProjectUuid={algorithmProjectUuid} + algorithmUuid={algorithmUuid} + participantId={participantId} + > + <button className="custom-text-button"> + <WhichAlgorithm + id={algorithmId} + uuid={algorithmUuid} + participantId={participantId} + /> + </button> + </AlgorithmDrawer.Button> + ); + }, + } + : { key: 'loss_type', text: '损失函数类型' }, + { + key: 'role', + text: '训练角色', + render(configMap: any) { + const role = configMap?.role?.value; + return role === TRAIN_ROLE.LEADER ? '标签方' : '特征方'; + }, + }, + { + text: '数据集', + render(_: any, job?: ModelJob) { + const datasetName: string | undefined = job?.dataset_name ?? job?.intersection_dataset_name; + return datasetName ? ( + <Link to={`/datasets/processed/detail/${job?.dataset_id}/dataset_job_detail`}> + {datasetName} + </Link> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ); + }, + }, + { + key: 'dataset_batch_name', + text: '数据批次', + render(configMap: any, job?: ModelJob) { + const datasetBatchName = configMap?.dataset_batch_name?.value; + return datasetBatchName && job?.data_batch_id ? ( + <Link to={`/datasets/processed/detail/${job?.dataset_id}/data_batch`}> + {datasetBatchName} + </Link> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ); + }, + }, + { + text: '资源配置', + render(_: any, job?: ModelJob) { + return ( + job && ( + <ResourceConfigTable.Button + job={job} + popoverProps={{ position: 'br', style: { maxWidth: 440, width: 440 } }} + /> + ) + ); + }, + }, + { + text: '参数配置', + render(_: any, job?: ModelJob) { + let keyList: string[] = []; + + switch (algorithmType) { + case EnumAlgorithmProjectType.NN_VERTICAL: + keyList = [ + 'image', + 'data_source', + 'epoch_num', + 'verbosity', + 'shuffle_data_block', + 'save_checkpoint_secs', + 'save_checkpoint_steps', + 'load_checkpoint_filename', + 'load_checkpoint_filename_with_path', + 'sparse_estimator', + 'load_model_name', + ]; + break; + case EnumAlgorithmProjectType.NN_HORIZONTAL: + keyList = ['epoch_num', 'verbosity', 'image', 'steps_per_sync', 'data_path']; + break; + case EnumAlgorithmProjectType.TREE_VERTICAL: + keyList = [ + 'learning_rate', + 'max_iters', + 'max_depth', + 'l2_regularization', + 'max_bins', + 'num_parallel', + // 高级参数 + 'image', + 'data_source', + 'file_ext', + 'file_type', + 'enable_packing', + 'ignore_fields', + 'cat_fields', + 'send_scores_to_follower', + 'send_metrics_to_follower', + 'verify_example_ids', + 'verbosity', + 'no_data', + 'label_field', + 'load_model_name', + 'load_model_path', + ]; + break; + } + + const textList = keyList.map((key) => { + const value = getDefaultVariableValue(job as any, key); + return { + key: LABEL_MAPPER[key], + value: value !== '' ? value : CONSTANTS.EMPTY_PLACEHOLDER, + }; + }); + const table = ( + <Table + className="custom-table" + size="small" + showHeader={false} + columns={[ + { dataIndex: 'key', title: '' }, + { dataIndex: 'value', title: '' }, + ]} + scroll={{ + y: 500, + }} + border={false} + borderCell={false} + pagination={false} + data={textList} + /> + ); + return ( + <Popover + popupHoverStay={true} + content={table} + position="br" + className={'params-popover-padding'} + > + <button className="custom-text-button">{'查看'}</button> + </Popover> + ); + }, + }, + { + render(_: any, job?: ModelJob) { + return job?.started_at && formatTimestamp(job.started_at); + }, + key: 'started_at', + text: '开始时间', + }, + { + key: 'stopped_at', + text: '结束时间', + render(_: any, job?: ModelJob) { + return job?.stopped_at && formatTimestamp(job.stopped_at); + }, + }, + { + render(_: any, job?: ModelJob) { + if (!job) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + const { state, stopped_at, started_at } = job; + const { RUNNING, STOPPED, COMPLETED, FAILED } = WorkflowState; + const isRunning = state === RUNNING; + const isStopped = [STOPPED, COMPLETED, FAILED].includes(state); + let runningTime = 0; + + if (isRunning || isStopped) { + runningTime = isStopped ? stopped_at! - started_at! : dayjs().unix() - started_at!; + } + + return job ? <CountTime time={runningTime} isStatic={!isRunning} /> : 0; + }, + text: '运行时长', + }, + { + text: '输出模型', + render(_: any, job?: ModelJob) { + if (!job) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + + return job.output_models[0]?.name; + }, + }, + ]; +}; + +function ModelJobDetailDrawer({ + id, + visible, + pod, + onCancel, + showCodeEditorBackBtn = true, + title = '事件详情', + datasetBatchType, + ...restProps +}: Props) { + const projectId = useGetCurrentProjectId(); + const myPureDomainName = useGetCurrentPureDomainName(); + const participantList = useGetCurrentProjectParticipantList(); + const [selectParticipant, setSelectParticipant] = useState<string>(myPureDomainName); + const [metricIsPublic, setMetricIsPublic] = useState(false); + const modelJobDetailQuery = useQuery( + ['fetchModelJobDetail', id], + () => { + return fetchModelJobDetail_new(projectId!, id); + }, + { + enabled: Boolean(visible && id), + retry: 2, + refetchOnWindowFocus: false, + onSuccess: (res) => { + const { metric_is_public } = res.data; + setMetricIsPublic(!!metric_is_public); + }, + }, + ); + + const modelJobDetail = useMemo(() => { + return modelJobDetailQuery.data?.data; + }, [modelJobDetailQuery.data?.data]); + + const datasetBatchDetailQuery = useQuery( + ['fetchDatasetBatchDetail'], + () => fetchDataBatchById(modelJobDetail?.dataset_id!, modelJobDetail?.data_batch_id!), + { + enabled: Boolean(modelJobDetail?.dataset_id), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const datasetBatchDetail = useMemo(() => { + return datasetBatchDetailQuery.data?.data; + }, [datasetBatchDetailQuery.data?.data]); + const isOldModelJob = useMemo(() => { + return !modelJobDetailQuery.data?.data.global_config; + }, [modelJobDetailQuery.data?.data.global_config]); + + const algorithmDetailQuery = useQuery( + [ + 'fetchAlgorithmDetail', + projectId, + modelJobDetail?.global_config?.global_config?.[selectParticipant]?.algorithm_uuid, + ], + () => + fetchAlgorithmByUuid( + projectId!, + modelJobDetail?.global_config?.global_config?.[selectParticipant]?.algorithm_uuid!, + ), + { + enabled: Boolean( + projectId && + modelJobDetail?.global_config?.global_config?.[selectParticipant]?.algorithm_uuid, + ), + refetchOnWindowFocus: false, + }, + ); + + const algorithmDetail = useMemo(() => { + return algorithmDetailQuery.data?.data; + }, [algorithmDetailQuery.data?.data]); + + const { data: jobInstanceDetail } = useQuery( + ['workflow', modelJobDetail?.job_id], + () => fetchJobById(modelJobDetail?.job_id as number).then((res) => res.data), + { + enabled: Boolean(projectId) && Boolean(modelJobDetail?.job_id), + }, + ); + const errorMessage = useMemo(() => { + return jobInstanceDetail?.state !== JobState.COMPLETED && + jobInstanceDetail?.error_message && + (jobInstanceDetail?.error_message?.app || + JSON.stringify(jobInstanceDetail?.error_message?.pods) !== '{}') + ? JSON.stringify(jobInstanceDetail.error_message) + : ''; + }, [jobInstanceDetail]); + + const { data: podLog } = useQuery( + ['model-detail-drawer-pod-log', pod?.name], + () => { + return fetchPodLogs(pod?.name!, modelJobDetail?.job_id!, { maxLines: 5000 }).then( + (res) => res.data, + ); + }, + { enabled: Boolean(pod?.name && modelJobDetail?.job_id), refetchOnWindowFocus: false }, + ); + const configValueMap: { [key: string]: any } = useMemo(() => { + if (!modelJobDetail) { + return {}; + } + + return modelJobDetail.config?.job_definitions?.[0].variables.reduce((acc, cur) => { + acc[cur.name] = { ...cur }; + return acc; + }, {} as { [key: string]: any }); + }, [modelJobDetail]); + + const displayedProps = useMemo(() => { + if (!modelJobDetail?.config) { + return []; + } + let valueMap: { [key: string]: any } = {}; + if (isOldModelJob) { + valueMap = configValueMap; + } else { + valueMap = + modelJobDetail?.global_config?.global_config?.[selectParticipant]?.variables.reduce( + (acc, cur) => { + acc[cur.name] = { ...cur }; + return acc; + }, + {} as { [key: string]: any }, + ) ?? {}; + const algorithmId = algorithmDetail?.id ? algorithmDetail?.id : null; + const participantId = algorithmDetail?.participant_id ? algorithmDetail?.participant_id : 0; + valueMap['algorithm'] = { + value: JSON.stringify({ + algorithmId: algorithmId, + algorithmUuid: algorithmDetail?.uuid, + algorithmProjectId: algorithmDetail?.algorithm_project_id, + algorithmProjectUuid: algorithmDetail?.algorithm_project_uuid, + participantId: participantId, + }), + }; + valueMap['dataset_batch_name'] = { + value: datasetBatchDetail?.name, + }; + } + + return [ + ...getPropertyList(modelJobDetail?.algorithm_type).map((item) => { + const { text, key, render } = item; + + return { + label: text ?? key ?? CONSTANTS.EMPTY_PLACEHOLDER, + value: + typeof render === 'function' + ? render(valueMap, modelJobDetail) + : key && valueMap[key] + ? valueMap[key].value + : CONSTANTS.EMPTY_PLACEHOLDER, + }; + }), + ]; + }, [ + modelJobDetail, + isOldModelJob, + configValueMap, + selectParticipant, + algorithmDetail?.id, + algorithmDetail?.participant_id, + algorithmDetail?.uuid, + algorithmDetail?.algorithm_project_id, + algorithmDetail?.algorithm_project_uuid, + datasetBatchDetail?.name, + ]); + + const refreshModelJobDetail = () => { + modelJobDetailQuery.refetch(); + }; + + function renderInfoLayout() { + return ( + <> + {isOldModelJob ? ( + <LabelStrong isBlock={true}>训练配置</LabelStrong> + ) : ( + <Space> + <LabelStrong isBlock={true}>训练配置</LabelStrong> + <Tabs + className="custom-tabs" + type="text" + activeTab={selectParticipant} + onChange={setSelectParticipant} + > + <TabPane title={'本方'} key={myPureDomainName} /> + {participantList.map((participant) => { + return <TabPane title={participant.name} key={participant.pure_domain_name} />; + })} + </Tabs> + </Space> + )} + + <PropertyList properties={displayedProps} cols={4} /> + <ReportResult + onSwitch={refreshModelJobDetail} + metricIsPublic={metricIsPublic} + id={id} + title={'训练报告'} + algorithmType={modelJobDetail?.algorithm_type} + isNNAlgorithm={modelJobDetail ? isNNAlgorithm(modelJobDetail?.algorithm_type) : false} + hideConfusionMatrix={ + modelJobDetail && + isTreeAlgorithm(modelJobDetail.algorithm_type) && + configValueMap.loss_type === 'mse' + } + /> + <div className="left-container"> + <LabelStrong isBlock={true}>实例信息</LabelStrong> + <Popover + trigger="hover" + position="br" + content={ + <span> + <div className="pop-title">工作流</div> + <Link + className="styled-link" + to={`/workflow-center/workflows/${modelJobDetail?.workflow_id}`} + > + label_jump_to_workflow + </Link> + <div className="pop-title">工作流 ID</div> + <div className="pop-content">{modelJobDetail?.workflow_id}</div> + </span> + } + > + <Button className="right-button" size="mini" type="text"> + 更多信息 + </Button> + </Popover> + </div> + + {modelJobDetail?.job_id ? <InstanceInfo id={id} jobId={modelJobDetail?.job_id} /> : null} + </> + ); + } + function renderCodeEditorLayout() { + return ( + <> + {showCodeEditorBackBtn && ( + <div className="header"> + <BackButton onClick={onCancel}>返回</BackButton> + </div> + )} + <CodeEditor + language="json" + isReadOnly={true} + theme="grey" + height="calc(100vh - 119px)" // 55(drawer header height) + 16*2(content padding) + 32(header height) + value={formatJSONValue(podLog?.join('\n') ?? CONSTANTS.EMPTY_PLACEHOLDER)} + /> + </> + ); + } + + return ( + <Drawer + unmountOnExit={true} + placement="right" + title={ + <Space> + {modelJobDetail?.name} + <StateIndicator + tag={true} + tip={errorMessage} + position="bottom" + {...getModelJobStatus(modelJobDetail?.status as ModelJobStatus, { + isHideAllActionList: true, + })} + /> + {modelJobDetail?.auto_update && ( + <Tag color={datasetBatchType === 'hour' ? 'arcoblue' : 'purple'}> + {datasetBatchType === 'hour' ? '小时级' : '天级'} + </Tag> + )} + </Space> + } + closable={true} + width="1000px" + visible={visible} + onCancel={onCancel} + {...restProps} + > + <div className="drawer-content">{pod ? renderCodeEditorLayout() : renderInfoLayout()}</div> + </Drawer> + ); +} + +export function _Button({ text = '详情', btnDisabled, children, ...restProps }: ButtonProps) { + const [visible, setVisible] = useState(false); + return ( + <> + {children ? ( + <span onClick={() => setVisible(true)}>{children}</span> + ) : ( + <button + disabled={btnDisabled} + type="button" + className="custom-text-button" + onClick={() => setVisible(true)} + > + {text} + </button> + )} + <ModelJobDetailDrawer visible={visible} {...restProps} onCancel={() => setVisible(false)} /> + </> + ); +} + +ModelJobDetailDrawer.Button = _Button; + +export default ModelJobDetailDrawer; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/Create/index.less b/web_console_v2/client/src/views/ModelCenter/ModelTrain/Create/index.less new file mode 100644 index 000000000..8af4e3945 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/Create/index.less @@ -0,0 +1,25 @@ +.steps-content{ + width: 288px; + margin: 0 auto 40px; +} + +.card{ + .arco-card-body{ + padding: 32px 40px; + } +} + +.form-content{ + max-width: 600px; + margin: 0 auto; + .form-section{ + margin-bottom: 20px; + overflow: hidden; + > h3{ + margin-bottom: 20px; + font-weight: 500; + font-size: 14px; + color: #1d252f; + } + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/Create/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/Create/index.tsx new file mode 100644 index 000000000..d71d75912 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/Create/index.tsx @@ -0,0 +1,1388 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useHistory, useParams } from 'react-router'; +import { useQuery } from 'react-query'; + +import { MAX_COMMENT_LENGTH, validNamePattern } from 'shared/validator'; +import { + checkAlgorithmValueIsEmpty, + isTreeAlgorithm, + isNNAlgorithm, + isVerticalAlgorithm, + lossTypeOptions, +} from 'views/ModelCenter/shared'; +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantId, + useGetCurrentProjectParticipantName, + useGetCurrentParticipantPureDomainName, + useGetCurrentPureDomainName, + useIsFormValueChange, +} from 'hooks'; +import { to } from 'shared/helpers'; +import { + createModelJobGroup, + updateModelJobGroup, + updatePeerModelJobGroup, + fetchModelJobGroupDetail, + fetchPeerModelJobGroupDetail, + fetchModelJobDefinition, +} from 'services/modelCenter'; +import { + Avatar, + trainRoleTypeOptions, + algorithmTypeOptions, + treeBaseConfigList, + nnBaseConfigList, + getAdvanceConfigListByDefinition, + getTreeBaseConfigInitialValues, + getTreeAdvanceConfigInitialValues, + getConfigInitialValues, + getNNBaseConfigInitialValues, + getNNAdvanceConfigInitialValues, + getTreeBaseConfigInitialValuesByDefinition, + getNNBaseConfigInitialValuesByDefinition, + hydrateModalGlobalConfig, +} from '../../shared'; + +import { + Form, + Button, + Input, + Card, + Spin, + Select, + Message, + Space, + Steps, + Alert, + Switch, +} from '@arco-design/web-react'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import BlockRadio from 'components/_base/BlockRadio'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BackButton from 'components/BackButton'; +import TitleWithIcon from 'components/TitleWithIcon'; +import DatasesetSelect from 'components/NewDatasetSelect'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import ConfigForm from 'components/ConfigForm'; +import ResourceConfig, { + MixedAlgorithmType, + Value as ResourceConfigValue, +} from 'components/ResourceConfig'; +import { LabelStrong } from 'styles/elements'; + +import routes from '../../routes'; + +import { + FederalType, + LossType, + ModelJobRole, + ResourceTemplateType, + TrainRoleType, + ModelJobDefinitionResult, +} from 'typings/modelCenter'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { AlgorithmType } from 'typings/modelCenter'; +import { Dataset, DatasetKindLabel, DataJobBackEndType } from 'typings/dataset'; +import { OptionInfo } from '@arco-design/web-react/es/Select/interface'; +import FormLabel from 'components/FormLabel'; +import ScheduleTaskSetter, { scheduleTaskValidator } from 'components/ScheduledTaskSetter'; +import { useToggle } from 'react-use'; +import { fetchDatasetDetail } from 'services/dataset'; +import AlgorithmSelect, { AlgorithmSelectValue } from 'components/AlgorithmSelect'; + +import './index.less'; + +const Step = Steps.Step; + +type TreeConfig = { + learning_rate: number | string; + max_iters: number | string; + max_depth: number | string; + l2_regularization: number | string; + max_bins: number | string; + num_parallel: number | string; + [key: string]: any; +}; +type NNConfig = { + epoch_num?: number | string; + verbosity?: number | string; + sparse_estimator?: string; + [key: string]: any; +}; + +export type BaseFormData = { + name?: string; + comment?: string; + federal_type?: FederalType; + algorithm_type?: AlgorithmType; + type?: EnumAlgorithmProjectType; + algorithm?: AlgorithmSelectValue; + role?: TrainRoleType | null; + loss_type?: LossType; + tree_config?: TreeConfig; + nn_config?: NNConfig; + dataset_id?: ID; + resource_config?: ResourceConfigValue; + cron_config?: string; + data_source_manual?: boolean; + custom_data_source?: string; +}; +export type FormData = { + [ModelJobRole.COORDINATOR]: BaseFormData; + [ModelJobRole.PARTICIPANT]: BaseFormData; +}; + +const baseInitialFormValues = { + role: null, + federal_type: FederalType.VERTICAL, + algorithm_type: AlgorithmType.TREE, + type: EnumAlgorithmProjectType.TREE_VERTICAL, + algorithm: { + algorithmId: undefined, + algorithmProjectId: undefined, + algorithmUuid: undefined, + config: [], + path: '', + }, + loss_type: LossType.LOGISTIC, + tree_config: { + learning_rate: 0.3, + max_iters: 10, + max_depth: 5, + l2_regularization: 1, + max_bins: 33, + num_parallel: 5, + }, + nn_config: { + epoch_num: undefined, + verbosity: undefined, + sparse_estimator: undefined, + }, + cron_config: '', + data_source_manual: false, + custom_data_source: '', +}; +const initialFormValues: FormData = { + [ModelJobRole.COORDINATOR]: baseInitialFormValues, + [ModelJobRole.PARTICIPANT]: baseInitialFormValues, +}; + +function getFieldKey(field: string, isParticipant = false) { + return `${isParticipant ? ModelJobRole.PARTICIPANT : ModelJobRole.COORDINATOR}.${field}`; +} + +function calcMixedAlgorithmType(federalType: FederalType, algorithmType: AlgorithmType) { + if (federalType === FederalType.HORIZONTAL) { + return algorithmType === AlgorithmType.TREE + ? EnumAlgorithmProjectType.TREE_HORIZONTAL + : EnumAlgorithmProjectType.NN_HORIZONTAL; + } else { + return algorithmType === AlgorithmType.TREE + ? EnumAlgorithmProjectType.TREE_VERTICAL + : EnumAlgorithmProjectType.NN_VERTICAL; + } +} + +const Create: React.FC = () => { + const history = useHistory(); + const { id, action, role } = useParams<{ + role: 'sender' | 'receiver'; + action: 'create' | 'edit'; + id?: string; + }>(); + const [formInstance] = Form.useForm<FormData>(); + + const [datasetId, setDatasetId] = useState<ID>(''); + const [currentStep, setCurrentStep] = useState(1); + const [dataSourceManual, toggleDataSourceManual] = useToggle(false); + const [selectedAlgorithmType, setSelectedAlgorithmType] = useState<AlgorithmType>( + initialFormValues[ModelJobRole.COORDINATOR].algorithm_type || AlgorithmType.TREE, + ); + const [selectedFederalType, setSelectedFederalType] = useState<FederalType>( + initialFormValues[ModelJobRole.COORDINATOR].federal_type || FederalType.VERTICAL, + ); + const [algorithmOwner, setAlgorithmOwner] = useState<string>(''); + + const selectedDatasetRef = useRef<Dataset>(); + + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(); + const projectId = useGetCurrentProjectId(); + const participantId = useGetCurrentProjectParticipantId(); + const participantName = useGetCurrentProjectParticipantName(); + const myPureDomainName = useGetCurrentPureDomainName(); + const participantPureDomainName = useGetCurrentParticipantPureDomainName(); + + const isReceiver = role === 'receiver'; + const isEdit = action === 'edit'; + + const currentModelJobGroupQuery = useQuery( + ['fetchModelJobGroupDetail', projectId, id], + () => fetchModelJobGroupDetail(projectId!, id!), + { + enabled: (isEdit || (isReceiver && !isEdit)) && Boolean(projectId && id), + retry: 2, + refetchOnWindowFocus: false, + onSuccess(res) { + if (isReceiver && !isEdit) { + // get default dataset for coordinator + setDatasetId(res.data.dataset_id || res.data.intersection_dataset_id); + } + + if (!isEdit) return; + + const currentModelJobGroupData = res.data; + + let algorithmType = AlgorithmType.TREE; + const federalType = isVerticalAlgorithm( + currentModelJobGroupData.algorithm_type as EnumAlgorithmProjectType, + ) + ? FederalType.VERTICAL + : FederalType.HORIZONTAL; + let treeConfig = {} as any; + let nnConfig = {}; + let topLevelConfig: Record<any, any> = {}; + + if (isTreeAlgorithm(currentModelJobGroupData.algorithm_type as EnumAlgorithmProjectType)) { + algorithmType = AlgorithmType.TREE; + + const baseConfigInitialValues = getTreeBaseConfigInitialValues( + currentModelJobGroupData.config!, + ); + const advanceConfigInitialValues = getTreeAdvanceConfigInitialValues( + currentModelJobGroupData.config!, + ); + + treeConfig = { + ...baseConfigInitialValues, + ...advanceConfigInitialValues, + }; + + const topLevelInitialValues = getConfigInitialValues(currentModelJobGroupData.config!, [ + 'role', + 'loss_type', + ]); + + topLevelConfig = topLevelInitialValues; + } else { + algorithmType = AlgorithmType.NN; + const baseConfigInitialValues = getNNBaseConfigInitialValues( + currentModelJobGroupData.config!, + ); + const advanceConfigInitialValues = getNNAdvanceConfigInitialValues( + currentModelJobGroupData.config!, + ); + + nnConfig = { + ...baseConfigInitialValues, + ...advanceConfigInitialValues, + }; + + const topLevelInitialValues = getConfigInitialValues(currentModelJobGroupData.config!, [ + 'role', + 'algorithm', + ]); + + topLevelConfig = { + ...topLevelInitialValues, + algorithm: + isEdit && topLevelInitialValues.algorithm + ? JSON.parse(topLevelInitialValues.algorithm) + : {}, + }; + setAlgorithmOwner( + topLevelConfig?.algorithm?.algorithmId === null || + topLevelConfig?.algorithm?.algorithmId === 0 + ? 'peer' + : 'self', + ); + } + setSelectedAlgorithmType(algorithmType); + setSelectedFederalType(federalType); + toggleDataSourceManual( + !currentModelJobGroupData.dataset_id && currentModelJobGroupData.dataset_id !== 0, + ); + formInstance.setFieldsValue({ + [ModelJobRole.COORDINATOR]: { + name: currentModelJobGroupData.name, + comment: currentModelJobGroupData.comment ?? '', + dataset_id: + currentModelJobGroupData.dataset_id || + currentModelJobGroupData.intersection_dataset_id, + federal_type: federalType, + algorithm_type: algorithmType, + type: calcMixedAlgorithmType(federalType, algorithmType), + tree_config: treeConfig, + nn_config: nnConfig, + ...topLevelConfig, + resource_config: getConfigInitialValues(currentModelJobGroupData.config!, [ + 'master_replicas', + 'master_cpu', + 'master_mem', + 'ps_replicas', + 'ps_cpu', + 'ps_mem', + 'worker_replicas', + 'worker_cpu', + 'worker_mem', + ]), + cron_config: currentModelJobGroupData.cron_config, + data_source_manual: !currentModelJobGroupData.dataset_id, + custom_data_source: treeConfig.data_source ?? '', + }, + }); + }, + }, + ); + const peerModelJobGroupQuery = useQuery( + ['fetchPeerModelJobGroupDetail', projectId, id, participantId], + () => fetchPeerModelJobGroupDetail(projectId!, id!, participantId!), + { + enabled: + currentModelJobGroupQuery.isSuccess && + Boolean(projectId && id && participantId) && + ((isReceiver && !isEdit) || (!isReceiver && isEdit)), + retry: 2, + refetchOnWindowFocus: false, + onSuccess: async (res) => { + const peerModelJobGroupData = res.data; + + const isPeerAuthorized = peerModelJobGroupData?.authorized ?? false; + + // If peer is not authorized, then we should not set peer form value. + if (!isReceiver && isEdit && !isPeerAuthorized) { + return; + } + + let algorithmType = AlgorithmType.TREE; + const federalType = isVerticalAlgorithm( + peerModelJobGroupData.algorithm_type as EnumAlgorithmProjectType, + ) + ? FederalType.VERTICAL + : FederalType.HORIZONTAL; + let treeConfig = {}; + let nnConfig = {}; + let topLevelConfig = {}; + + if (isTreeAlgorithm(peerModelJobGroupData.algorithm_type as EnumAlgorithmProjectType)) { + algorithmType = AlgorithmType.TREE; + + const baseConfigInitialValues = getTreeBaseConfigInitialValues( + peerModelJobGroupData.config!, + ); + const advanceConfigInitialValues = getTreeAdvanceConfigInitialValues( + peerModelJobGroupData.config!, + ); + + treeConfig = { + ...baseConfigInitialValues, + ...advanceConfigInitialValues, + }; + + const topLevelInitialValues = getConfigInitialValues(peerModelJobGroupData.config!, [ + 'role', + 'loss_type', + ]); + + topLevelConfig = { + ...topLevelInitialValues, + role: isReceiver + ? topLevelInitialValues.role === TrainRoleType.FEATURE + ? TrainRoleType.LABEL + : TrainRoleType.FEATURE + : topLevelInitialValues.role, + }; + } else { + algorithmType = AlgorithmType.NN; + + const baseConfigInitialValues = getNNBaseConfigInitialValues( + peerModelJobGroupData.config!, + ); + const advanceConfigInitialValues = getNNAdvanceConfigInitialValues( + peerModelJobGroupData.config!, + ); + + nnConfig = { + ...baseConfigInitialValues, + ...advanceConfigInitialValues, + }; + + // when role = receiver and action = create: replace data_path and data_source + if (isReceiver && !isEdit && datasetId) { + const [datasetDetail, error] = await to(fetchDatasetDetail(datasetId)); + if (error) { + Message.error(error.message); + } else { + const dataPath = datasetDetail?.data?.path ?? ''; + const dataSource = datasetDetail?.data?.data_source ?? ''; + treeConfig = { + ...treeConfig, + data_path: dataPath, + data_source: dataSource, + }; + nnConfig = { + ...nnConfig, + data_path: dataPath, + data_source: dataSource, + }; + } + } + + const topLevelInitialValues = getConfigInitialValues(peerModelJobGroupData.config!, [ + 'role', + 'algorithm', + ]); + + topLevelConfig = { + ...topLevelInitialValues, + role: isReceiver + ? topLevelInitialValues.role === TrainRoleType.FEATURE + ? TrainRoleType.LABEL + : TrainRoleType.FEATURE + : topLevelInitialValues.role, + algorithm: + isEdit && topLevelInitialValues.algorithm + ? JSON.parse(topLevelInitialValues.algorithm) + : {}, + }; + } + + setSelectedAlgorithmType(algorithmType); + setSelectedFederalType(federalType); + + formInstance.setFieldsValue({ + [isReceiver ? ModelJobRole.COORDINATOR : ModelJobRole.PARTICIPANT]: { + name: peerModelJobGroupData.name, + federal_type: federalType, + algorithm_type: algorithmType, + type: calcMixedAlgorithmType(federalType, algorithmType), + tree_config: treeConfig, + nn_config: nnConfig, + dataset_id: datasetId, + ...topLevelConfig, + resource_config: getConfigInitialValues(peerModelJobGroupData.config!, [ + 'master_replicas', + 'master_cpu', + 'master_mem', + 'ps_replicas', + 'ps_cpu', + 'ps_mem', + 'worker_replicas', + 'worker_cpu', + 'worker_mem', + ]), + }, + }); + }, + }, + ); + + const algorithmProjectType = useMemo<EnumAlgorithmProjectType>(() => { + return calcMixedAlgorithmType(selectedFederalType, selectedAlgorithmType); + }, [selectedAlgorithmType, selectedFederalType]); + + const modelJobDefinitionQuery = useQuery(['fetchModelJobDefinition', algorithmProjectType], () => + fetchModelJobDefinition({ + model_job_type: 'TRAINING', + algorithm_type: algorithmProjectType || EnumAlgorithmProjectType.TREE_VERTICAL, + }), + ); + const modelJobDefinition = useMemo(() => { + return modelJobDefinitionQuery?.data?.data; + }, [modelJobDefinitionQuery]); + + const treeAdvancedFormItemList = useMemo(() => { + if (isNNAlgorithm(algorithmProjectType)) { + return []; + } else return getAdvanceConfigListByDefinition(modelJobDefinition?.variables!); + }, [algorithmProjectType, modelJobDefinition]); + + const nnAdvancedFormItemList = useMemo(() => { + if (isTreeAlgorithm(algorithmProjectType)) { + return []; + } else return getAdvanceConfigListByDefinition(modelJobDefinition?.variables!, true); + }, [algorithmProjectType, modelJobDefinition]); + + // Set tree_config/nn_config initialValues when create model job group + useEffect(() => { + if (isReceiver || isEdit) { + return; + } + + let datasetConfigValues: { + data_path?: string; + data_source?: string; + } = {}; + + if (selectedDatasetRef.current) { + datasetConfigValues = { + data_path: selectedDatasetRef.current?.path ?? '', + data_source: selectedDatasetRef.current.data_source ?? '', + }; + } + + if (isTreeAlgorithm(algorithmProjectType)) { + if (!treeAdvancedFormItemList) { + return; + } + + const baseConfigInitialValues = getTreeBaseConfigInitialValuesByDefinition( + modelJobDefinition?.variables!, + ); + const advanceConfigConfigInitialValues = treeAdvancedFormItemList.reduce((acc, cur) => { + acc[cur.field!] = cur.initialValue; + return acc; + }, {} as any); + + formInstance.setFieldsValue({ + [ModelJobRole.COORDINATOR]: { + ...formInstance.getFieldValue(ModelJobRole.COORDINATOR), + tree_config: { + ...baseConfigInitialValues, + ...advanceConfigConfigInitialValues, + ...datasetConfigValues, + }, + }, + }); + } else { + if (!nnAdvancedFormItemList) { + return; + } + + const baseConfigInitialValues = getNNBaseConfigInitialValuesByDefinition( + modelJobDefinition?.variables!, + ); + const advanceConfigConfigInitialValues = nnAdvancedFormItemList.reduce((acc, cur) => { + acc[cur.field!] = cur.initialValue; + return acc; + }, {} as any); + + formInstance.setFieldsValue({ + [ModelJobRole.COORDINATOR]: { + ...formInstance.getFieldValue(ModelJobRole.COORDINATOR), + nn_config: { + epoch_num: 1, + verbosity: 1, + ...baseConfigInitialValues, + ...advanceConfigConfigInitialValues, + ...datasetConfigValues, + }, + }, + }); + } + }, [ + isReceiver, + isEdit, + formInstance, + algorithmProjectType, + treeAdvancedFormItemList, + modelJobDefinition, + nnAdvancedFormItemList, + ]); + + const isLoading = + modelJobDefinitionQuery.isFetching || + peerModelJobGroupQuery.isFetching || + currentModelJobGroupQuery.isFetching; + + const isPeerAuthorized = peerModelJobGroupQuery.data?.data?.authorized ?? false; + const isDisabled = isReceiver || (!isReceiver && isEdit); + const isShowStep = !isReceiver && isEdit && isPeerAuthorized; + const isShowCanNotEditPeerConfigAlert = !isReceiver && isEdit && !isPeerAuthorized; + + return ( + <SharedPageLayout + title={ + <BackButton isShowConfirmModal={isFormValueChanged} onClick={goBackToListPage}> + 模型训练 + </BackButton> + } + contentWrapByCard={false} + centerTitle={isEdit ? '编辑训练' : isReceiver ? '授权模型训练' : '创建训练'} + > + <Spin loading={isLoading}> + <div>{isReceiver ? renderReceiverLayout() : renderSenderLayout()}</div> + </Spin> + </SharedPageLayout> + ); + + function renderReceiverLayout() { + return ( + <> + {!isEdit && renderBannerCard()} + {renderContentCard()} + </> + ); + } + function renderSenderLayout() { + return <>{renderContentCard()}</>; + } + function renderBannerCard() { + const title = `${participantName}向您发起「${ + peerModelJobGroupQuery.data?.data?.name ?? '' + }」训练授权申请`; + return ( + <Card className="card" bordered={false} style={{ marginBottom: 20 }}> + <Space size="medium"> + <Avatar /> + <> + <LabelStrong fontSize={16}>{title ?? '....'}</LabelStrong> + <TitleWithIcon + title={ + '授权后,发起方可以运行模型训练并修改参与方的训练参数,训练指标将对所有参与方可见' + } + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </> + </Space> + </Card> + ); + } + function renderContentCard() { + return ( + <Card className="card" bordered={false}> + {isShowStep && ( + <Steps className="steps-content" current={currentStep} size="small"> + <Step title={'本侧配置'} /> + <Step title={'合作伙伴配置'} /> + </Steps> + )} + <Form<FormData> + className="form-content" + form={formInstance} + initialValues={initialFormValues} + onSubmit={onSubmit} + scrollToFirstError={true} + onValuesChange={onFormValueChange} + > + {isShowCanNotEditPeerConfigAlert && ( + <Alert content={'合作伙伴未授权,不能编辑合作伙伴配置'} style={{ marginBottom: 20 }} /> + )} + <div style={{ display: isShowStep && currentStep === 2 ? 'none' : 'initial' }}> + {renderBaseInfoConfig()} + {renderTrainConfig()} + {renderResourceConfig()} + </div> + <div style={{ display: isShowStep && currentStep === 2 ? 'initial' : 'none' }}> + {renderTrainConfig(true)} + {renderResourceConfig(true)} + </div> + {renderFooterButton()} + </Form> + </Card> + ); + } + + function renderBaseInfoConfig(isParticipant = false) { + const isHideRule = + (isParticipant && currentStep === 1) || (!isParticipant && currentStep === 2); + return ( + <section className="form-section"> + <h3>基本信息</h3> + <Form.Item + field={getFieldKey('name', isParticipant)} + label={'训练名称'} + rules={ + isHideRule + ? [] + : [ + { required: true, message: '必填项' }, + { + match: validNamePattern, + message: + '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ] + } + disabled={isDisabled} + > + <Input placeholder={'请填写'} /> + </Form.Item> + <Form.Item + field={getFieldKey('comment', isParticipant)} + label={'描述'} + rules={ + isHideRule + ? [] + : [ + { + maxLength: MAX_COMMENT_LENGTH, + message: '最多为 200 个字符', + }, + ] + } + > + <Input.TextArea placeholder={'最多为 200 个字符'} /> + </Form.Item> + <Form.Item + field={getFieldKey('federal_type', isParticipant)} + label={'联邦类型'} + hidden={true} + > + <Input /> + </Form.Item> + </section> + ); + } + function renderTrainConfig(isParticipant = false) { + const isHideRule = + (isParticipant && currentStep === 1) || (!isParticipant && currentStep === 2); + + return ( + <section className="form-section"> + <h3>训练配置</h3> + {!isParticipant && ( + <> + <Form.Item + field={getFieldKey('type', isParticipant)} + label={'类型'} + rules={isHideRule ? [] : [{ required: true, message: '必填项' }]} + disabled={isDisabled} + > + <Select + placeholder={'请选择'} + options={algorithmTypeOptions} + onChange={(value) => { + const [algorithmType, federalType] = value.split('_'); + setSelectedAlgorithmType(algorithmType.toLowerCase()); + setSelectedFederalType(federalType.toLowerCase()); + resetAlgorithmFormValue(isParticipant); + if (federalType.toLowerCase() === FederalType.HORIZONTAL) { + resetRoleFormValue(isParticipant); + } + formInstance.setFieldsValue({ + [ModelJobRole.COORDINATOR]: { + ...formInstance.getFieldValue(ModelJobRole.COORDINATOR), + dataset_id: undefined, + }, + }); + }} + /> + </Form.Item> + <Form.Item + field={getFieldKey('algorithm_type', isParticipant)} + label={'算法类型'} + hidden={true} + > + <Input /> + </Form.Item> + </> + )} + + {isTreeAlgorithm(algorithmProjectType) && renderTreeParams(isParticipant)} + {isNNAlgorithm(algorithmProjectType) && renderNNParams(isParticipant)} + <Form.Item + field={getFieldKey('role', isParticipant)} + label={'训练角色'} + rules={isHideRule ? [] : [{ required: true, message: '必填项' }]} + disabled={isDisabled} + hidden={selectedFederalType === FederalType.HORIZONTAL} + > + <BlockRadio isCenter={true} options={trainRoleTypeOptions} /> + </Form.Item> + {!isParticipant && ( + <Form.Item + field={getFieldKey('data_source_manual', isParticipant)} + label={'手动输入数据源'} + disabled={isEdit} + triggerPropName="checked" + > + <Switch onChange={onDataSourceManual} /> + </Form.Item> + )} + {!isParticipant && renderDatasetSelectConfig(dataSourceManual, isParticipant, isHideRule)} + {!isParticipant && !isReceiver && ( + <Form.Item + field={getFieldKey('cron_config', isParticipant)} + label={ + <FormLabel + label={'启用定时重训'} + tooltip={'启用该功能将间隔性地重跑训练任务,且每次训练都将从最新的可用版本开始'} + /> + } + rules={[ + { + validator: scheduleTaskValidator, + message: '请选择时间', + validateTrigger: 'onSubmit', + }, + ]} + > + <ScheduleTaskSetter /> + </Form.Item> + )} + </section> + ); + } + + function renderDatasetSelectConfig( + dataSourceManual: boolean, + isParticipant: boolean, + isHideRule: boolean, + ) { + return dataSourceManual ? ( + <Form.Item + field={getFieldKey('custom_data_source', isParticipant)} + label={'数据源'} + rules={isHideRule ? [] : [{ required: true, message: '必填项' }]} + disabled={isEdit} + > + <Input + placeholder={'请输入数据源'} + onChange={async (val) => { + const configField = + selectedAlgorithmType === AlgorithmType.TREE + ? getFieldKey('tree_config', isParticipant) + : getFieldKey('nn_config', isParticipant); + + const prevConfig = (formInstance.getFieldValue(configField as any) || {}) as + | TreeConfig + | NNConfig; + formInstance.setFieldsValue({ + [configField]: { + ...prevConfig, + data_source: val, + }, + }); + }} + /> + </Form.Item> + ) : ( + <Form.Item + field={getFieldKey('dataset_id', isParticipant)} + label={'数据集'} + rules={isHideRule ? [] : [{ required: true, message: '必填项' }]} + disabled={isEdit} + > + <DatasesetSelect + lazyLoad={{ + page_size: 10, + enable: true, + }} + kind={DatasetKindLabel.PROCESSED} + //TODO:support filter for vertical + datasetJobKind={ + selectedFederalType === FederalType.HORIZONTAL + ? DataJobBackEndType.DATA_ALIGNMENT + : undefined + } + onChange={async (_, option) => { + const dataset = (option as OptionInfo)?.extra as Dataset; + const dataPath = dataset?.path ?? ''; + const dataSource = dataset?.data_source ?? ''; + + selectedDatasetRef.current = dataset; + + const configField = + selectedAlgorithmType === AlgorithmType.TREE + ? getFieldKey('tree_config', isParticipant) + : getFieldKey('nn_config', isParticipant); + + const prevConfig = (formInstance.getFieldValue(configField as any) || {}) as + | TreeConfig + | NNConfig; + formInstance.setFieldsValue({ + [configField]: { + ...prevConfig, + data_path: dataPath, + data_source: dataSource, + }, + }); + }} + /> + </Form.Item> + ); + } + + function renderResourceConfig(isParticipant = false) { + const isHideRule = + (isParticipant && currentStep === 1) || (!isParticipant && currentStep === 2); + return ( + <section className="form-section"> + <h3>资源配置</h3> + <Form.Item + field={getFieldKey('resource_config', isParticipant)} + label={'资源模板'} + rules={isHideRule ? [] : [{ required: true, message: '必填项' }]} + > + <ResourceConfig + algorithmType={algorithmProjectType as MixedAlgorithmType} + defaultResourceType={ResourceTemplateType.CUSTOM} + isIgnoreFirstRender={isReceiver} + localDisabledList={['master.replicas']} + /> + </Form.Item> + </section> + ); + } + function renderFooterButton() { + let submitText = '提交并发送'; + if (isReceiver) { + if (isEdit) { + submitText = '保存编辑'; + } else { + submitText = '确认授权'; + } + } else { + if (isEdit) { + submitText = '保存编辑'; + } else { + submitText = '提交并发送'; + } + } + return ( + <Space> + {isShowStep && currentStep === 1 && ( + <Button type="primary" onClick={onNextStepClick}> + 下一步 + </Button> + )} + + {(!isShowStep || (isShowStep && currentStep === 2)) && ( + <Button type="primary" htmlType="submit"> + {submitText} + </Button> + )} + + {isShowStep && currentStep === 2 && <Button onClick={onPrevStepClick}>上一步</Button>} + + {(!isShowStep || (isShowStep && currentStep === 1)) && ( + <ButtonWithModalConfirm + isShowConfirmModal={isFormValueChanged} + onClick={goBackToListPage} + > + 取消 + </ButtonWithModalConfirm> + )} + + {!isReceiver && !isEdit && ( + <TitleWithIcon + title={'训练报告仅自己可见,如需共享报告,请前往训练详情页开启'} + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + )} + </Space> + ); + } + + function renderTreeParams(isParticipant = false) { + const isHideRule = + (isParticipant && currentStep === 1) || (!isParticipant && currentStep === 2); + return ( + <> + {!isParticipant && ( + <Form.Item + field={getFieldKey('loss_type', isParticipant)} + label={'损失函数类型'} + rules={isHideRule ? [] : [{ required: true, message: '必填项' }]} + disabled={isDisabled} + > + <BlockRadio.WithTip options={lossTypeOptions} isOneHalfMode={true} /> + </Form.Item> + )} + <Form.Item + field={getFieldKey('tree_config', isParticipant)} + label={'参数配置'} + rules={isHideRule ? [] : [{ required: true, message: '必填项' }]} + > + <ConfigForm + cols={2} + formItemList={treeBaseConfigList} + collapseFormItemList={treeAdvancedFormItemList} + formProps={{ + style: { + marginTop: 7, + }, + }} + /> + </Form.Item> + </> + ); + } + function renderNNParams(isParticipant = false) { + const isHideRule = + (isParticipant && currentStep === 1) || (!isParticipant && currentStep === 2); + return ( + <> + <Form.Item + field={getFieldKey('algorithm', isParticipant)} + label={isParticipant ? '算法超参数' : '算法'} + rules={ + isHideRule || isParticipant + ? [] + : [ + { required: true, message: '必填项' }, + { + validator: checkAlgorithmValueIsEmpty, + }, + ] + } + > + <AlgorithmSelect + leftDisabled={isEdit} + algorithmType={[algorithmProjectType]} + onAlgorithmOwnerChange={(value: any) => setAlgorithmOwner(value)} + algorithmOwnerType={algorithmOwner} + isParticipant={isParticipant} + /> + </Form.Item> + + <Form.Item + field={getFieldKey('nn_config', isParticipant)} + label={'参数配置'} + rules={isHideRule ? [] : [{ required: true, message: '必填项' }]} + > + <ConfigForm + cols={2} + formItemList={nnBaseConfigList} + collapseFormItemList={nnAdvancedFormItemList} + /> + </Form.Item> + </> + ); + } + + function resetAlgorithmFormValue(isParticipant = false) { + const defaultAlgorithmValue = { + algorithmId: undefined, + algorithmProjectId: undefined, + algorithmUuid: undefined, + config: [], + path: '', + }; + + const roleField = isParticipant ? ModelJobRole.PARTICIPANT : ModelJobRole.COORDINATOR; + + formInstance.setFieldsValue({ + [roleField]: { + ...formInstance.getFieldValue(roleField), + algorithm: defaultAlgorithmValue, + }, + }); + } + function resetRoleFormValue(isParticipant = false) { + const roleField = isParticipant ? ModelJobRole.PARTICIPANT : ModelJobRole.COORDINATOR; + formInstance.setFieldsValue({ + [roleField]: { + ...formInstance.getFieldValue(roleField), + role: TrainRoleType.LABEL, + }, + }); + } + function goBackToListPage() { + history.push(routes.ModelTrainList); + } + function onPrevStepClick() { + setCurrentStep((currentStep) => currentStep - 1); + } + function onDataSourceManual(checked: boolean) { + toggleDataSourceManual(checked); + } + async function onNextStepClick() { + await formInstance.validate(); + setCurrentStep((currentStep) => currentStep + 1); + } + + function getTemplateDetail() { + const isTree = selectedAlgorithmType === AlgorithmType.TREE; + + if (!modelJobDefinition) { + if (isTree) { + Message.error('找不到训练模型模板(树算法)'); + return; + } else if (selectedFederalType === FederalType.HORIZONTAL) { + Message.error('找不到训练模型模板(nn算法)'); + return; + } else { + Message.error('找不到训练模型模板(横向nn算法)'); + return; + } + } + return modelJobDefinition; + } + + function onSubmit(formValues: FormData) { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + + const templateDetail: ModelJobDefinitionResult | undefined = getTemplateDetail(); + + if (!templateDetail) { + return; + } + + if (isReceiver) { + if (isEdit) { + onReceiverEditSubmit(formValues, templateDetail); + } else { + onReceiverCreateSubmit(formValues, templateDetail); + } + } else { + if (isEdit) { + onSenderEditSubmit(formValues, templateDetail); + } else { + onSenderCreateSubmit(formValues, templateDetail); + } + } + } + async function onSenderCreateSubmit( + formValues: FormData, + templateDetail: ModelJobDefinitionResult, + ) { + const coordinatorFormValues = formValues[ModelJobRole.COORDINATOR]; + const isTree = isTreeAlgorithm(coordinatorFormValues.type!); + + const [res, error] = await to( + createModelJobGroup(projectId!, { + name: coordinatorFormValues.name!, + algorithm_type: algorithmProjectType, + dataset_id: coordinatorFormValues.dataset_id!, + }), + ); + + if (error) { + Message.error(error.message); + return; + } + + const { id: modelJobGroupId } = res.data; + + const [, updateError] = await to( + updateModelJobGroup(projectId!, modelJobGroupId, { + comment: coordinatorFormValues.comment, + dataset_id: coordinatorFormValues.dataset_id!, + cron_config: coordinatorFormValues.cron_config, + global_config: { + global_config: { + [myPureDomainName]: isTree + ? { + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: coordinatorFormValues.role, + loss_type: coordinatorFormValues.loss_type, + ...coordinatorFormValues.tree_config, + ...coordinatorFormValues.resource_config, + }), + } + : { + algorithm_uuid: coordinatorFormValues?.algorithm?.algorithmUuid, + //TODO:support param algorithm_project_uuid + algorithm_parameter: { variables: coordinatorFormValues?.algorithm?.config }, + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: coordinatorFormValues.role, + ...coordinatorFormValues.nn_config, + ...coordinatorFormValues.resource_config, + }), + }, + }, + }, + }), + ); + + if (updateError) { + Message.error(updateError.message); + return; + } + + Message.success('创建成功,等待合作伙伴授权'); + goBackToListPage(); + } + + async function onSenderEditSubmit( + formValues: FormData, + templateDetail: ModelJobDefinitionResult, + ) { + const coordinatorFormValues = formValues[ModelJobRole.COORDINATOR]; + const participantFormValues = { + ...formValues[ModelJobRole.PARTICIPANT], + loss_type: coordinatorFormValues.loss_type, + }; + const isTree = isTreeAlgorithm(coordinatorFormValues.type!); + + const [, updateError] = await to( + updateModelJobGroup(projectId!, id!, { + comment: coordinatorFormValues.comment, + dataset_id: coordinatorFormValues.dataset_id!, + cron_config: coordinatorFormValues.cron_config, + global_config: { + global_config: { + [myPureDomainName]: isTree + ? { + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: coordinatorFormValues.role, + loss_type: coordinatorFormValues.loss_type, + ...coordinatorFormValues.tree_config, + ...coordinatorFormValues.resource_config, + }), + } + : { + algorithm_uuid: coordinatorFormValues?.algorithm?.algorithmUuid, + //TODO:support param algorithm_project_uuid + algorithm_parameter: { variables: coordinatorFormValues?.algorithm?.config }, + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: coordinatorFormValues.role, + ...coordinatorFormValues.nn_config, + ...coordinatorFormValues.resource_config, + }), + }, + }, + }, + }), + ); + + if (updateError) { + Message.error(updateError.message); + return; + } + + if (isShowCanNotEditPeerConfigAlert) { + Message.success('保存成功'); + goBackToListPage(); + return; + } + + if (!peerModelJobGroupQuery.data?.data?.config) { + Message.error('找不到对侧训练模型模板'); + return; + } + + const [, updatePeerError] = await to( + updatePeerModelJobGroup(projectId!, id!, participantId!, { + global_config: { + global_config: { + [participantPureDomainName]: isTree + ? { + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: participantFormValues.role, + loss_type: participantFormValues.loss_type, + ...participantFormValues.tree_config, + ...participantFormValues.resource_config, + }), + } + : { + //TODO:support algorithm_uuid + algorithm_parameter: { variables: participantFormValues?.algorithm?.config }, + // algorithm_uuid: participantFormValues?.algorithm?.algorithmUuid, + variables: hydrateModalGlobalConfig( + templateDetail?.variables!, + { + role: participantFormValues.role, + algorithm: participantFormValues.algorithm, + ...participantFormValues.nn_config, + ...participantFormValues.resource_config, + }, + false, + ), + }, + }, + }, + }), + ); + + if (updatePeerError) { + Message.error(updatePeerError.message); + return; + } + + Message.success('保存成功'); + goBackToListPage(); + } + async function onReceiverCreateSubmit( + formValues: FormData, + templateDetail: ModelJobDefinitionResult, + ) { + const coordinatorFormValues = formValues[ModelJobRole.COORDINATOR]; + + const isTree = isTreeAlgorithm(coordinatorFormValues.type!); + + const [, updateError] = await to( + updateModelJobGroup(projectId!, id!, { + comment: coordinatorFormValues.comment, + dataset_id: coordinatorFormValues.dataset_id!, + authorized: true, + global_config: { + global_config: { + [myPureDomainName]: isTree + ? { + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: coordinatorFormValues.role, + loss_type: coordinatorFormValues.loss_type, + ...coordinatorFormValues.tree_config, + ...coordinatorFormValues.resource_config, + }), + } + : { + algorithm_uuid: coordinatorFormValues?.algorithm?.algorithmUuid, + algorithm_parameter: { variables: coordinatorFormValues?.algorithm?.config }, + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: coordinatorFormValues.role, + ...coordinatorFormValues.nn_config, + ...coordinatorFormValues.resource_config, + }), + }, + }, + }, + }), + ); + + if (updateError) { + Message.error(updateError.message); + return; + } + + Message.success('授权完成,等待合作伙伴运行'); + goBackToListPage(); + } + async function onReceiverEditSubmit( + formValues: FormData, + templateDetail: ModelJobDefinitionResult, + ) { + const coordinatorFormValues = formValues[ModelJobRole.COORDINATOR]; + + const isTree = isTreeAlgorithm(coordinatorFormValues.type!); + + const [, updateError] = await to( + updateModelJobGroup(projectId!, id!, { + comment: coordinatorFormValues.comment, + dataset_id: coordinatorFormValues.dataset_id!, + global_config: { + global_config: { + [myPureDomainName]: isTree + ? { + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: coordinatorFormValues.role, + loss_type: coordinatorFormValues.loss_type, + ...coordinatorFormValues.tree_config, + ...coordinatorFormValues.resource_config, + }), + } + : { + algorithm_uuid: coordinatorFormValues?.algorithm?.algorithmUuid, + algorithm_parameter: { variables: coordinatorFormValues?.algorithm?.config }, + variables: hydrateModalGlobalConfig(templateDetail?.variables!, { + role: coordinatorFormValues.role, + ...coordinatorFormValues.nn_config, + ...coordinatorFormValues.resource_config, + }), + }, + }, + }, + }), + ); + + if (updateError) { + Message.error(updateError.message); + return; + } + + Message.success('保存成功'); + goBackToListPage(); + } +}; + +export default Create; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/AlgorithmProjectSelect/index.module.less b/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/AlgorithmProjectSelect/index.module.less new file mode 100644 index 000000000..9c7f10bd5 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/AlgorithmProjectSelect/index.module.less @@ -0,0 +1,15 @@ +li:has(.second_option_container) { + height: 54px; + line-height: 24px; +} +.second_option_container { + > span { + font-weight: 500; + } +} +.second_option_content { + color: var(--color-text-2); +} +.text_content { + font-size: 12px; +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/AlgorithmProjectSelect/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/AlgorithmProjectSelect/index.tsx new file mode 100644 index 000000000..72075b5d1 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/AlgorithmProjectSelect/index.tsx @@ -0,0 +1,198 @@ +import React, { useMemo } from 'react'; +import { useGetCurrentProjectId, useGetCurrentProjectParticipantList } from 'hooks'; +import { + AlgorithmStatus, + EnumAlgorithmProjectSource, + EnumAlgorithmProjectType, +} from 'typings/algorithm'; +import { ALGORITHM_TYPE_LABEL_MAPPER } from 'views/ModelCenter/shared'; +import { Cascader, Divider, Space, Spin, Tag, Typography } from '@arco-design/web-react'; + +import styles from './index.module.less'; +import { fetchPeerAlgorithmProjectList, fetchProjectList } from 'services/algorithm'; +import { useQuery } from 'react-query'; + +const ALGORITHM_OWNER_TEXT_MAPPER = { + self: '我方算法', + peer: '合作伙伴算法', + preset: '预置算法', +}; + +const ALGORITHM_OWNER_TAG_COLOR_MAPPER = { + self: 'purple', + peer: 'green', + preset: 'blue', +}; + +interface Props { + value?: ID; + algorithmType?: EnumAlgorithmProjectType[]; + onChange?: (algorithmProjectUuid: ID) => void; + supportEdit?: boolean; +} +export default function AlgorithmProjectSelect({ + algorithmType, + value, + onChange: onChangeFromProps, + supportEdit = true, +}: Props) { + const participantList = useGetCurrentProjectParticipantList(); + const projectId = useGetCurrentProjectId(); + + const algorithmProjectListQuery = useQuery( + ['fetchAllAlgorithmProjectList', algorithmType, projectId], + () => + fetchProjectList(projectId, { + type: algorithmType, + }), + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + const preAlgorithmProjectListQuery = useQuery( + ['fetchPreAlgorithmProjectListQuery', algorithmType], + () => + fetchProjectList(0, { + type: algorithmType, + sources: EnumAlgorithmProjectSource.PRESET, + }), + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + const peerAlgorithmProjectListQuery = useQuery( + ['fetchPeerAlgorithmProjectListQuery', projectId, algorithmType], + () => + fetchPeerAlgorithmProjectList(projectId, 0, { + filter: `(type:${JSON.stringify(algorithmType)})`, + }), + { + enabled: Boolean(projectId), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const algorithmProjectList = useMemo(() => { + return [ + ...(algorithmProjectListQuery?.data?.data || []), + ...(preAlgorithmProjectListQuery.data?.data || []), + ].filter( + (algorithmProject) => + algorithmProject.source === 'PRESET' || + algorithmProject.publish_status === AlgorithmStatus.PUBLISHED, + ); + }, [algorithmProjectListQuery, preAlgorithmProjectListQuery]); + const peerAlgorithmProjectList = useMemo(() => { + return peerAlgorithmProjectListQuery.data?.data || []; + }, [peerAlgorithmProjectListQuery]); + + const cascaderOptions = useMemo(() => { + return [ + { + value: 'self', + label: '我方算法', + disabled: algorithmProjectList?.length === 0, + children: algorithmProjectList?.map((item) => ({ + ...item, + value: item.uuid, + label: item.name, + participantName: item.source === EnumAlgorithmProjectSource.PRESET ? '预置' : '我方', + })), + }, + { + value: 'peer', + label: '合作伙伴算法', + disabled: peerAlgorithmProjectList?.length === 0, + children: peerAlgorithmProjectList?.map((item) => ({ + ...item, + value: item.uuid, + label: item.name, + participantName: participantList.find( + (participant) => participant.id === item.participant_id, + )?.name, + })), + }, + ]; + }, [algorithmProjectList, peerAlgorithmProjectList, participantList]); + + const algorithmOwnerType = useMemo(() => { + if (algorithmProjectList?.find((item) => item.uuid === value)) { + return 'self'; + } else if (peerAlgorithmProjectList.find((item) => item.uuid === value)) { + return 'peer'; + } + return undefined; + }, [value, algorithmProjectList, peerAlgorithmProjectList]); + const algorithmProjectName = useMemo(() => { + return [...algorithmProjectList, ...peerAlgorithmProjectList].find( + (item) => item.uuid === value, + )?.name; + }, [value, algorithmProjectList, peerAlgorithmProjectList]); + + const algorithmTagType = useMemo(() => { + const algorithmProject = algorithmProjectList?.find((item) => item.uuid === value); + if (algorithmProject) { + return algorithmProject.source === EnumAlgorithmProjectSource.PRESET ? 'preset' : 'self'; + } else if (peerAlgorithmProjectList.find((item) => item.uuid === value)) { + return 'peer'; + } + return undefined; + }, [algorithmProjectList, peerAlgorithmProjectList, value]); + + const isLoading = useMemo(() => { + return ( + algorithmProjectListQuery.isFetching || + peerAlgorithmProjectListQuery.isFetching || + preAlgorithmProjectListQuery.isFetching + ); + }, [ + algorithmProjectListQuery.isFetching, + peerAlgorithmProjectListQuery.isFetching, + preAlgorithmProjectListQuery.isFetching, + ]); + + return supportEdit ? ( + <Cascader + loading={isLoading} + options={cascaderOptions} + placeholder="请选择算法" + showSearch={true} + onChange={(value) => { + handleChange(value?.[1] as ID); + }} + renderOption={(option, level) => { + if (level === 0) { + return <span>{option.label}</span>; + } + return ( + <div className={styles.second_option_container}> + <span style={{ display: 'block' }}>{option.name}</span> + <Space className={styles.second_option_content} split={<Divider type="vertical" />}> + <span>{option.participantName}</span> + <span>{ALGORITHM_TYPE_LABEL_MAPPER?.[option.type as string]}</span> + </Space> + </div> + ); + }} + /> + ) : ( + <Spin loading={isLoading}> + {!algorithmOwnerType || !algorithmProjectName || !algorithmTagType ? ( + <Typography.Text bold={true}> 暂无数据</Typography.Text> + ) : ( + <Space> + <Typography.Text bold={true}>{algorithmProjectName}</Typography.Text> + <Tag color={ALGORITHM_OWNER_TAG_COLOR_MAPPER[algorithmTagType]}> + {ALGORITHM_OWNER_TEXT_MAPPER[algorithmTagType]} + </Tag> + </Space> + )} + </Spin> + ); + function handleChange(algorithmProjectUuid: ID) { + onChangeFromProps?.(algorithmProjectUuid); + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/index.tsx new file mode 100644 index 000000000..726f433d5 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/CreateCentralization/index.tsx @@ -0,0 +1,332 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { useHistory, useParams } from 'react-router'; +import { useQuery } from 'react-query'; +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useGetCurrentPureDomainName, + useIsFormValueChange, +} from 'hooks'; +import { + createModeJobGroupV2, + fetchModelJobGroupDetail, + updateModelJobGroup, +} from 'services/modelCenter'; +import { fetchDatasetDetail } from 'services/dataset'; +import { MAX_COMMENT_LENGTH } from 'shared/validator'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { DataJobBackEndType, DatasetKindLabel } from 'typings/dataset'; +import routes from 'views/ModelCenter/routes'; +import { ALGORITHM_TYPE_LABEL_MAPPER } from 'views/ModelCenter/shared'; + +import { + Avatar, + Button, + Card, + Form, + Input, + Message, + Space, + Tag, + Typography, +} from '@arco-design/web-react'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import BackButton from 'components/BackButton'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BlockRadio from 'components/_base/BlockRadio'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import TitleWithIcon from 'components/TitleWithIcon'; +import DatasetSelect from 'components/NewDatasetSelect'; +import AlgorithmProjectSelect from './AlgorithmProjectSelect'; +import { LabelStrong } from 'styles/elements'; + +const federalTypeOptions = [ + { + value: EnumAlgorithmProjectType.TREE_VERTICAL, + label: ALGORITHM_TYPE_LABEL_MAPPER[EnumAlgorithmProjectType.TREE_VERTICAL], + }, + { + value: EnumAlgorithmProjectType.NN_VERTICAL, + label: ALGORITHM_TYPE_LABEL_MAPPER[EnumAlgorithmProjectType.NN_VERTICAL], + }, + { + value: EnumAlgorithmProjectType.NN_HORIZONTAL, + label: ALGORITHM_TYPE_LABEL_MAPPER[EnumAlgorithmProjectType.NN_HORIZONTAL], + }, +]; + +const defaultRules = [{ required: true, message: '必选项' }]; + +export default function CreateCentralization() { + const history = useHistory(); + const { role, id: modelJobGroupId } = useParams<{ role: string; id: string }>(); + const isReceiver = role === 'receiver'; + + const [formInstance] = Form.useForm(); + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(); + + const [selectedAlgorithmType, setSelectedAlgorithmType] = useState<EnumAlgorithmProjectType>( + EnumAlgorithmProjectType.TREE_VERTICAL, + ); + const projectId = useGetCurrentProjectId(); + const myPureDomain = useGetCurrentPureDomainName(); + const participantList = useGetCurrentProjectParticipantList(); + + const modelJobGroupDetailQuery = useQuery( + ['fetchModelJobGroupDetail', projectId, modelJobGroupId], + () => fetchModelJobGroupDetail(projectId!, modelJobGroupId), + { + enabled: !!projectId && !!modelJobGroupId, + retry: 2, + refetchOnWindowFocus: false, + }, + ); + const datasetDetailQuery = useQuery( + ['fetchDatasetDetail', modelJobGroupDetailQuery.data?.data.dataset_id], + () => fetchDatasetDetail(modelJobGroupDetailQuery.data?.data.dataset_id), + { + enabled: !!modelJobGroupDetailQuery.data?.data.dataset_id, + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const modelJobGroupDetail = useMemo(() => { + return modelJobGroupDetailQuery.data?.data; + }, [modelJobGroupDetailQuery.data?.data]); + const datasetDetail = useMemo(() => { + return datasetDetailQuery.data?.data; + }, [datasetDetailQuery.data?.data]); + + const coordinatorName = useMemo(() => { + return ( + participantList.find((participant) => participant.id === modelJobGroupDetail?.coordinator_id) + ?.name ?? '未知合作伙伴' + ); + }, [modelJobGroupDetail?.coordinator_id, participantList]); + + useEffect(() => { + if (!modelJobGroupDetail) { + return; + } + formInstance.setFieldsValue({ + ...modelJobGroupDetail, + algorithm_project_list: { + algorithmProjects: modelJobGroupDetail.algorithm_project_uuid_list?.algorithm_projects, + }, + }); + }, [formInstance, modelJobGroupDetail]); + + useEffect(() => { + if (!modelJobGroupDetail) { + return; + } + setSelectedAlgorithmType(modelJobGroupDetail.algorithm_type as EnumAlgorithmProjectType); + }, [modelJobGroupDetail]); + return ( + <SharedPageLayout + title={ + <BackButton isShowConfirmModal={isFormValueChanged} onClick={goBackToListPage}> + 模型训练 + </BackButton> + } + contentWrapByCard={false} + centerTitle={'创建模型训练'} + > + {isReceiver && ( + <Card className="card" bordered={false} style={{ marginBottom: 20 }}> + <Space size="medium"> + <Avatar /> + <> + <LabelStrong + fontSize={16} + >{`${coordinatorName}向您发起「${modelJobGroupDetail?.name}」的模型训练作业`}</LabelStrong> + <TitleWithIcon + title={'所有合作伙伴授权完成后,任意合作方均可发起模型训练任务。'} + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </> + </Space> + </Card> + )} + <Card className="card" bordered={false} style={{ height: '100%' }}> + <Form + className="form-content" + form={formInstance} + onChange={onFormValueChange} + onSubmit={handelSubmit} + > + <section className="form-section"> + <h3>基本信息</h3> + <Form.Item + field="name" + label="模型训练名称" + rules={[{ required: true, message: '必填项' }]} + > + {isReceiver ? ( + <Typography.Text bold={true}>{modelJobGroupDetail?.name}</Typography.Text> + ) : ( + <Input placeholder="请输入模型训练名称" /> + )} + </Form.Item> + <Form.Item + field={'comment'} + label={'描述'} + rules={[ + { + maxLength: MAX_COMMENT_LENGTH, + message: '最多为 200 个字符', + }, + ]} + > + <Input.TextArea placeholder={'最多为 200 个字符'} /> + </Form.Item> + </section> + <section className="form-section"> + <h3>训练配置</h3> + <Form.Item + field={'algorithm_type'} + label={'联邦类型'} + rules={[{ required: true, message: '必选项' }]} + initialValue={selectedAlgorithmType} + > + {isReceiver ? ( + <Typography.Text bold={true}> + { + ALGORITHM_TYPE_LABEL_MAPPER[ + modelJobGroupDetail?.algorithm_type || EnumAlgorithmProjectType.TREE_VERTICAL + ] + } + </Typography.Text> + ) : ( + <BlockRadio + options={federalTypeOptions} + isCenter={true} + onChange={(value) => { + setSelectedAlgorithmType(value); + formInstance.setFieldValue('dataset_id', undefined); + }} + /> + )} + </Form.Item> + <Form.Item + label={'数据集'} + field={'dataset_id'} + rules={defaultRules} + shouldUpdate={true} + > + {isReceiver ? ( + <Space> + <Typography.Text bold={true}>{datasetDetail?.name}</Typography.Text> + <Tag color="arcoblue">结果</Tag> + </Space> + ) : ( + <DatasetSelect + lazyLoad={{ + enable: true, + page_size: 10, + }} + kind={DatasetKindLabel.PROCESSED} + datasetJobKind={ + selectedAlgorithmType === EnumAlgorithmProjectType.NN_HORIZONTAL + ? DataJobBackEndType.DATA_ALIGNMENT + : undefined + } + /> + )} + </Form.Item> + {[ + EnumAlgorithmProjectType.NN_HORIZONTAL, + EnumAlgorithmProjectType.NN_VERTICAL, + ].includes(selectedAlgorithmType) && ( + <> + <Form.Item + field={resetField(myPureDomain, 'algorithm_project_list.algorithmProjects')} + label="我方算法" + rules={defaultRules} + > + <AlgorithmProjectSelect + algorithmType={[selectedAlgorithmType]} + supportEdit={!isReceiver} + /> + </Form.Item> + {participantList.map((participant) => { + return ( + <Form.Item + key={participant.id} + field={resetField( + participant.pure_domain_name, + 'algorithm_project_list.algorithmProjects', + )} + label={`${participant.name}算法`} + rules={defaultRules} + > + <AlgorithmProjectSelect + algorithmType={[selectedAlgorithmType]} + supportEdit={!isReceiver} + /> + </Form.Item> + ); + })} + </> + )} + </section> + <Space> + <Button type="primary" htmlType="submit"> + {isReceiver ? '授权' : '提交并发送'} + </Button> + + <ButtonWithModalConfirm + isShowConfirmModal={isFormValueChanged} + onClick={goBackToListPage} + > + 取消 + </ButtonWithModalConfirm> + <TitleWithIcon + title={'所有合作伙伴授权完成后,任意合作方均可发起模型训练任务。'} + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </Space> + </Form> + </Card> + </SharedPageLayout> + ); + function resetField(participantName: string, fieldName: string) { + return `${fieldName}.${participantName}`; + } + function goBackToListPage() { + history.push(routes.ModelTrainList); + } + + async function handelSubmit(value: any) { + if (!projectId) { + Message.info('请选择工作区!'); + return; + } + if (!isReceiver) { + try { + await createModeJobGroupV2(projectId, value); + Message.info('创建成功'); + history.push(routes.ModelTrainList); + } catch (error: any) { + Message.error(error.message); + } + } else { + try { + await updateModelJobGroup(projectId, modelJobGroupDetail?.id!, { + authorized: true, + comment: value?.comment, + }); + Message.info('授权成功'); + history.push(routes.ModelTrainList); + } catch (error: any) { + Message.error(error.message); + } + } + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/Detail/index.module.less b/web_console_v2/client/src/views/ModelCenter/ModelTrain/Detail/index.module.less new file mode 100644 index 000000000..9c54f4373 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/Detail/index.module.less @@ -0,0 +1,48 @@ +@import '~styles/mixins.less'; +.detail_container { + padding: 20px 20px 0; + border-bottom: 1px solid var(--lineColor); + h3 { + margin-top: 0; + margin-bottom: -3px; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + .detail_comment_space { + font-size: 12px; + color: var(--textColorSecondary); + } + .detail_header_col { + margin-top: 9px; + text-align: right; + } +} + +.detail_content { + padding: 0 20px; + .round_tag { + border-radius: 32px; + } + .table_header { + display: flex; + margin-top: 14px; + margin-bottom: 20px; + justify-content: space-between; + } +} +.detail_comment { + .MixinEllipsis(400px); +} + +.model_progress_container { + width: 100px; +} + +.algorithm_popover_padding { + width: 400px; + max-width: 400px !important; + :global(.arco-popover-content) { + padding: 0; + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/Detail/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/Detail/index.tsx new file mode 100644 index 000000000..4b769c1dd --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/Detail/index.tsx @@ -0,0 +1,878 @@ +import React, { FC, useMemo, useState } from 'react'; +import { generatePath, useHistory, useParams, Link } from 'react-router-dom'; +import { useMutation, useQuery } from 'react-query'; +import dayjs from 'dayjs'; + +import { + useGetAppFlagValue, + useGetCurrentProjectId, + useGetCurrentProjectParticipantId, + useGetCurrentProjectParticipantList, + useGetCurrentPureDomainName, + useTablePaginationWithUrlState, + useUrlState, +} from 'hooks'; +import { + authorizeModelJobGroup, + deleteModelJobGroup, + fetchModelJobGroupDetail, + fetchPeerModelJobGroupDetail, + launchModelJobGroup, + stopModelJob, + fetchModelJobList_new, + stopAutoUpdateModelJob, + fetchAutoUpdateModelJobDetail, +} from 'services/modelCenter'; +import { fetchDatasetDetail, fetchDatasetJobDetail } from 'services/dataset'; +import { formatTimestamp } from 'shared/date'; +import { + Avatar, + AUTH_STATUS_TEXT_MAP, + getConfigInitialValues, + getModelJobStatus, + isNNAlgorithm, + isVerticalNNAlgorithm, + MODEL_GROUP_STATUS_MAPPER, + resetAuthInfo, + statusFilters, + FILTER_MODEL_JOB_OPERATOR_MAPPER, + isTreeAlgorithm, +} from 'views/ModelCenter/shared'; +import { CONSTANTS } from 'shared/constants'; + +import { + Grid, + Button, + Space, + Tag, + Message, + Table, + Spin, + Tooltip, + Popover, +} from '@arco-design/web-react'; +import BackButton from 'components/BackButton'; +import MoreActions from 'components/MoreActions'; +import PropertyList from 'components/PropertyList'; +import SharedPageLayout from 'components/SharedPageLayout'; +import ModelJobDetailDrawer from '../../ModelJobDetailDrawer'; + +import routes from '../../routes'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { LabelStrong } from 'styles/elements'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { + AutoModelJobStatus, + ModelGroupStatus, + ModelJob, + ModelJobRole, + ModelJobStatus, +} from 'typings/modelCenter'; +import { DatasetKindBackEndType, DatasetType__archived } from 'typings/dataset'; +import StateIndicator from 'components/StateIndicator'; +import { WorkflowState } from 'typings/workflow'; +import CountTime from 'components/CountTime'; +import Modal from 'components/Modal'; +import AlgorithmType from 'components/AlgorithmType'; +import { IconCheckCircleFill, IconExclamationCircleFill } from '@arco-design/web-react/icon'; +import TrainJobCompareModal from '../../TrainJobCompareModal'; +import WhichAlgorithm from 'components/WhichAlgorithm'; + +import styles from './index.module.less'; +import ProgressWithText from 'components/ProgressWithText'; +import { FlagKey } from 'typings/flag'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { expression2Filter } from 'shared/filter'; +import AlgorithmProjectSelect from '../CreateCentralization/AlgorithmProjectSelect'; + +const { Row, Col } = Grid; + +type TRouteParams = { + id: string; +}; +const AUTO_STATUS_TEXT_MAPPER: Record<AutoModelJobStatus, string> = { + [AutoModelJobStatus.INITIAL]: '发起定时续训任务', + [AutoModelJobStatus.ACTIVE]: '停止定时续训任务', + [AutoModelJobStatus.STOPPED]: '配置定时续训任务', +}; + +const Detail: FC = () => { + const history = useHistory(); + const params = useParams<TRouteParams>(); + const [autoBtnLoading, setAutoBtnLoading] = useState<boolean>(false); + const { urlState: pageInfoState, paginationProps } = useTablePaginationWithUrlState(); + const [urlState, setUrlState] = useUrlState<{ + filter?: string; + page?: number; + pageSize?: number; + }>({}); + const projectId = useGetCurrentProjectId(); + const participantId = useGetCurrentProjectParticipantId(); + const participantList = useGetCurrentProjectParticipantList(); + const myPureDomainName = useGetCurrentPureDomainName(); + + const model_job_global_config_enabled = useGetAppFlagValue( + FlagKey.MODEL_JOB_GLOBAL_CONFIG_ENABLED, + ); + + const queryKeys = ['modelJobDetail', params.id, projectId]; + + const detailQuery = useQuery( + queryKeys, + () => { + return fetchModelJobGroupDetail(projectId!, params.id); + }, + { + enabled: Boolean(projectId) && Boolean(params.id), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const modelJobListQuery = useQuery( + [ + 'fetchModelJobListQuery', + projectId, + params.id, + pageInfoState.page, + pageInfoState.pageSize, + urlState.filter, + ], + () => + fetchModelJobList_new(projectId!, { + project_id: projectId as string, + group_id: params.id, + page: pageInfoState.page, + page_size: pageInfoState.pageSize, + filter: urlState.filter || undefined, + }), + { + enabled: Boolean(projectId) && Boolean(params.id), + retry: 2, + refetchOnWindowFocus: false, + keepPreviousData: true, + }, + ); + + const datasetDetailQuery = useQuery( + ['datasetDetailQuery', detailQuery.data?.data?.dataset_id], + () => { + return fetchDatasetDetail(detailQuery.data?.data?.dataset_id); + }, + { + enabled: detailQuery.data?.data?.dataset_id !== undefined, + }, + ); + + const authorizeMutate = useMutation( + (payload: { id: ID; authorized: boolean }) => { + return authorizeModelJobGroup(projectId!, payload.id, payload.authorized); + }, + { + onSuccess(_, { authorized }) { + detailQuery.refetch(); + Message.success(!authorized ? '撤销成功' : '授权成功'); + }, + onError(_, { authorized }) { + detailQuery.refetch(); + Message.error(!authorized ? '撤销失败' : '授权失败'); + }, + }, + ); + + const detail = useMemo(() => detailQuery.data?.data, [detailQuery]); + const modelJobList = useMemo(() => modelJobListQuery.data?.data, [modelJobListQuery]); + const datasetDetail = useMemo(() => datasetDetailQuery.data?.data, [datasetDetailQuery]); + + const datasetJobQuery = useQuery( + [ + 'fetchDatasetJobDetail', + projectId, + datasetDetail?.parent_dataset_job_id, + datasetDetail?.dataset_type, + ], + () => fetchDatasetJobDetail(projectId!, datasetDetail?.parent_dataset_job_id!), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: + Boolean(projectId && datasetDetail?.parent_dataset_job_id) && + datasetDetail?.dataset_type === DatasetType__archived.STREAMING, + }, + ); + + const datasetJob = useMemo(() => datasetJobQuery.data?.data, [datasetJobQuery]); + + const isOldModelGroup = useMemo(() => { + return Boolean(detail?.config?.job_definitions?.length); + }, [detail?.config?.job_definitions?.length]); + const progressConfig = useMemo(() => { + if (!detail?.auth_frontend_status) { + return undefined; + } + return MODEL_GROUP_STATUS_MAPPER?.[detail.auth_frontend_status]; + }, [detail]); + + const displayedProps = useMemo( + () => { + const { loss_type, algorithm } = getConfigInitialValues(detail?.config!, [ + 'loss_type', + 'algorithm', + ]); + let algorithmValue = { + algorithmId: undefined, + algorithmUuid: undefined, + participantId: undefined, + }; + try { + algorithmValue = JSON.parse(algorithm); + } catch (e) {} + const { algorithmId, algorithmUuid, participantId } = algorithmValue; + const { name, status, percent } = progressConfig ?? {}; + const authInfo = resetAuthInfo( + detail?.participants_info?.participants_map, + participantList, + myPureDomainName, + ); + return [ + { + value: detail?.role + ? detail.role === ModelJobRole.COORDINATOR + ? '我方' + : participantList.find((item) => item.id === detail.coordinator_id)?.name || + CONSTANTS.EMPTY_PLACEHOLDER + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '发起方', + }, + { + label: '授权状态', + value: ( + <ProgressWithText + className={styles.model_progress_container} + statusText={name} + status={status} + percent={percent} + toolTipContent={ + detail?.auth_frontend_status && + [ModelGroupStatus.PART_AUTH_PENDING, ModelGroupStatus.SELF_AUTH_PENDING].includes( + detail?.auth_frontend_status, + ) ? ( + <> + {authInfo.map((item: any) => ( + <div key={item.name}>{`${item.name}: ${ + AUTH_STATUS_TEXT_MAP?.[item.authStatus] + }`}</div> + ))} + </> + ) : undefined + } + /> + ), + }, + { + value: detail?.creator_username ?? CONSTANTS.EMPTY_PLACEHOLDER, + label: '创建者', + }, + { + value: + datasetDetail?.dataset_kind === DatasetKindBackEndType.PROCESSED ? ( + <Link + to={`/datasets/${DatasetKindBackEndType.PROCESSED.toLowerCase()}/detail/${ + detail?.dataset_id + }/dataset_job_detail`} + > + {datasetDetail?.name} + </Link> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + label: '数据集', + }, + detail?.algorithm_type && isNNAlgorithm(detail?.algorithm_type as EnumAlgorithmProjectType) + ? { + value: ( + <WhichAlgorithm + id={algorithmId!} + uuid={algorithmUuid} + participantId={participantId} + formatter={(algorithm: Algorithm) => algorithm.name} + /> + ), + label: '算法', + } + : { value: loss_type, label: '损失类型' }, + { + value: detail?.updated_at + ? formatTimestamp(detail.updated_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '更新时间', + }, + { + value: detail?.created_at + ? formatTimestamp(detail.created_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '创建时间', + }, + ]; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [detail, datasetDetailQuery, participantList, progressConfig, myPureDomainName], + ); + + const displayedProps_new = useMemo(() => { + const { name, status, percent } = progressConfig ?? {}; + const authInfo = resetAuthInfo( + detail?.participants_info?.participants_map, + participantList, + myPureDomainName, + ); + const keyList = Object.keys(detail?.algorithm_project_uuid_list?.algorithm_projects ?? {}); + const textList = keyList + .map((item) => { + return { + name: item, + algorithmProjectUuid: detail?.algorithm_project_uuid_list?.algorithm_projects?.[item], + }; + }) + .sort((a, b) => (a.name > b.name ? 1 : -1)); + + const table = ( + <Table + className="custom-table" + size="small" + columns={[ + { dataIndex: 'name', title: '参与方', width: 150 }, + { + dataIndex: 'algorithmProjectUuid', + title: '算法', + render: (val) => ( + <AlgorithmProjectSelect + algorithmType={[detail?.algorithm_type as EnumAlgorithmProjectType]} + value={val} + supportEdit={false} + /> + ), + }, + ]} + scroll={{ + y: 300, + }} + border={false} + borderCell={false} + pagination={false} + data={textList} + /> + ); + const propsList = [ + { + value: detail?.role + ? detail.role === ModelJobRole.COORDINATOR + ? '我方' + : participantList.find((item) => item.id === detail.coordinator_id)?.name || + CONSTANTS.EMPTY_PLACEHOLDER + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '发起方', + }, + { + label: '授权状态', + value: ( + <ProgressWithText + className={styles.model_progress_container} + statusText={name} + status={status} + percent={percent} + toolTipContent={ + detail?.auth_frontend_status && + [ModelGroupStatus.PART_AUTH_PENDING, ModelGroupStatus.SELF_AUTH_PENDING].includes( + detail?.auth_frontend_status, + ) ? ( + <> + {authInfo.map((item: any) => ( + <div key={item.name}>{`${item.name}: ${ + AUTH_STATUS_TEXT_MAP?.[item.authStatus] + }`}</div> + ))} + </> + ) : undefined + } + /> + ), + }, + { + value: detail?.creator_username ?? CONSTANTS.EMPTY_PLACEHOLDER, + label: '创建者', + }, + { + value: + datasetDetail?.dataset_kind === DatasetKindBackEndType.PROCESSED ? ( + <Space> + <Link + to={`/datasets/${DatasetKindBackEndType.PROCESSED.toLowerCase()}/detail/${ + detail?.dataset_id + }/dataset_job_detail`} + > + {datasetDetail?.name} + </Link> + {datasetDetail?.dataset_type === DatasetType__archived.STREAMING && + datasetJob?.time_range && ( + <Tag color={datasetJob?.time_range?.hours === 1 ? 'arcoblue' : 'purple'}> + {datasetJob?.time_range?.hours === 1 ? '小时级' : '天级'} + </Tag> + )} + </Space> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + label: '数据集', + }, + { + value: ( + <Popover + popupHoverStay={true} + content={table} + position="bl" + className={styles.algorithm_popover_padding} + > + <button className="custom-text-button">{'查看'}</button> + </Popover> + ), + label: '算法', + }, + { + value: detail?.updated_at + ? formatTimestamp(detail.updated_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '更新时间', + }, + { + value: detail?.created_at + ? formatTimestamp(detail.created_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '创建时间', + }, + ]; + isTreeAlgorithm(detail?.algorithm_type as EnumAlgorithmProjectType) && propsList.splice(4, 1); + return propsList; + }, [ + datasetDetail?.dataset_kind, + datasetDetail?.dataset_type, + datasetDetail?.name, + detail, + myPureDomainName, + participantList, + progressConfig, + datasetJob?.time_range, + ]); + + const columns = useMemo<ColumnProps<ModelJob>[]>(() => { + return [ + { + title: '名称', + dataIndex: 'version', + name: 'version', + width: 120, + render: (_, record) => { + if (record.auto_update) { + return ( + <Space> + {`V${record.version}`} + <Tag color="blue">定时</Tag> + </Space> + ); + } + return `V${record.version}`; + }, + }, + { + title: '发起方', + dataIndex: 'coordinator_id', + name: 'coordinator_id', + width: 100, + render: (val, record) => { + if (record.role === 'COORDINATOR') { + return '我方'; + } + return participantList.find((item) => item.id === val)?.name || '-'; + }, + }, + { + title: '任务状态', + dataIndex: 'status', + name: 'status', + width: 180, + filteredValue: expression2Filter(urlState.filter).status, + filters: statusFilters.filters, + render: (_, record) => { + return ( + <StateIndicator + {...getModelJobStatus(record.status ?? ModelJobStatus.UNKNOWN, { + isHideAllActionList: true, + })} + /> + ); + }, + }, + { + title: '运行时长', + dataIndex: 'running_time', + name: 'running_time', + width: 150, + render: (_, record) => { + let isRunning = false; + let isStopped = true; + let runningTime = 0; + + const { state } = record; + const { RUNNING, STOPPED, COMPLETED, FAILED } = WorkflowState; + isRunning = state === RUNNING; + isStopped = [STOPPED, COMPLETED, FAILED].includes(state); + + if (isRunning || isStopped) { + const { stopped_at, started_at } = record; + runningTime = isStopped ? stopped_at! - started_at! : dayjs().unix() - started_at!; + } + return <CountTime time={runningTime} isStatic={!isRunning} />; + }, + }, + { + title: '开始时间', + dataIndex: 'started_at', + name: 'started_at', + width: 150, + sorter(a: ModelJob, b: ModelJob) { + return (a?.started_at ?? 0) - (b?.started_at ?? 0); + }, + render: (date: number) => (date ? formatTimestamp(date) : CONSTANTS.EMPTY_PLACEHOLDER), + }, + { + title: '结束时间', + dataIndex: 'stopped_at', + name: 'stopped_at', + width: 150, + sorter(a: ModelJob, b: ModelJob) { + return (a?.stopped_at ?? 0) - (b?.stopped_at ?? 0); + }, + render: (date: number) => (date ? formatTimestamp(date) : CONSTANTS.EMPTY_PLACEHOLDER), + }, + { + title: '操作', + dataIndex: 'operation', + name: 'operation', + fixed: 'right', + width: 120, + render: (_: any, record) => ( + <> + <span> + <ModelJobDetailDrawer.Button + id={record.id} + text={'详情'} + btnDisabled={record.status === ModelJobStatus.PENDING} + datasetBatchType={datasetJob?.time_range?.hours === 1 ? 'hour' : 'day'} + /> + </span> + <button + className="custom-text-button" + style={{ marginLeft: 15 }} + disabled={record.status !== ModelJobStatus.RUNNING} + onClick={() => { + stopModelJob(projectId!, record.id) + .then(() => { + Message.success('终止成功'); + modelJobListQuery.refetch(); + }) + .catch((error) => Message.error(error.message)); + }} + > + 终止 + </button> + </> + ), + }, + ]; + }, [ + modelJobListQuery, + participantList, + projectId, + urlState.filter, + datasetJob?.time_range?.hours, + ]); + + return ( + <SharedPageLayout + title={ + <BackButton onClick={() => history.push(routes.ModelTrainList)}>{'模型训练'}</BackButton> + } + cardPadding={0} + > + <Spin loading={detailQuery.isFetching}> + <div className={styles.detail_container}> + <Row> + <Col span={12}> + <Space size="medium"> + <Avatar /> + <div> + <h3>{detail?.name ?? '....'}</h3> + <Space className={styles.detail_comment_space}> + {detail?.algorithm_type && ( + <AlgorithmType + type={detail.algorithm_type as EnumAlgorithmProjectType} + tagProps={{ + size: 'small', + }} + /> + )} + <Tag size="small" style={{ fontWeight: 'normal' }}> + ID: {detail?.id} + </Tag> + <Tooltip content={detail?.comment}> + <div className={styles.detail_comment}> + {detail?.comment ?? CONSTANTS.EMPTY_PLACEHOLDER} + </div> + </Tooltip> + </Space> + </div> + </Space> + </Col> + <Col className={styles.detail_header_col} span={12}> + <Space> + {detail?.role === 'PARTICIPANT' && ( + <Space> + <span> + {detail.authorized ? ( + <> + <IconCheckCircleFill style={{ color: 'rgb(var(--success-6))' }} /> + 我方已授权 + </> + ) : ( + <> + <IconExclamationCircleFill style={{ color: 'rgb(var(--primary-6))' }} /> + 待我方授权 + </> + )} + </span> + <button + className="custom-text-button" + onClick={() => { + Modal.confirm({ + title: detail.authorized ? '确认撤销授权?' : '确认授权?', + content: detail.authorized + ? '撤销授权后,发起方不可运行模型训练,正在运行的任务不受影响' + : '授权后,发起方可以运行模型训练', + okText: '确认', + onOk: () => { + authorizeMutate.mutate({ + id: detail.id, + authorized: !detail.authorized, + }); + }, + }); + }} + > + {detail.authorized ? '撤销授权' : '授权'} + </button> + </Space> + )} + {/* TODO: 中心化后支持每个参与方发起模型训练任务 */} + <Tooltip + content={ + detail?.role === 'COORDINATOR' || !isOldModelGroup + ? undefined + : '旧版模型训练作业非发起方暂不支持发起模型训练任务' + } + > + <Button + type="primary" + onClick={() => { + model_job_global_config_enabled && !isOldModelGroup + ? onStartModelTrainJobClick('once') + : onStartNewModelJobButtonClick(); + }} + disabled={!detail?.name || (detail?.role !== 'COORDINATOR' && isOldModelGroup)} + > + 发起新任务 + </Button> + </Tooltip> + {model_job_global_config_enabled && + !isOldModelGroup && + datasetDetail?.dataset_type === DatasetType__archived.STREAMING && + isVerticalNNAlgorithm(detail?.algorithm_type as EnumAlgorithmProjectType) && ( + <Button + type="primary" + loading={autoBtnLoading} + onClick={async () => { + if (detail?.auto_update_status === AutoModelJobStatus.ACTIVE) { + setAutoBtnLoading(true); + Modal.stop({ + title: '确定停止定时续训任务', + content: '请谨慎操作', + okText: '停止', + onOk: async () => { + try { + await stopAutoUpdateModelJob(projectId!, detail?.id!); + Message.success('停止定时续训任务成功'); + detailQuery.refetch(); + } catch (err: any) { + Message.error(err.message); + } + setAutoBtnLoading(false); + }, + onCancel: () => { + setAutoBtnLoading(false); + }, + }); + } else { + onStartModelTrainJobClick('repeat'); + } + }} + disabled={!detail?.name} + > + { + AUTO_STATUS_TEXT_MAPPER?.[ + detail?.auto_update_status || AutoModelJobStatus.INITIAL + ] + } + </Button> + )} + + {isOldModelGroup && ( + <Button onClick={onEditButtonClick} disabled={!detail?.name}> + 编辑 + </Button> + )} + <MoreActions + actionList={[ + { + label: '删除', + danger: true, + onClick: onDeleteButtonClick, + disabled: !detail?.name, + }, + ]} + /> + </Space> + </Col> + </Row> + <PropertyList + cols={4} + properties={isOldModelGroup ? displayedProps : displayedProps_new} + align="center" + /> + </div> + <div className={styles.detail_content}> + <div className={styles.table_header}> + <Space> + <LabelStrong>训练任务</LabelStrong> + <Tag className={styles.round_tag}>{detail?.model_jobs?.length ?? 0}</Tag> + </Space> + {detail?.algorithm_type && ( + <TrainJobCompareModal.Button + algorithmType={detail?.algorithm_type} + list={(detail?.model_jobs ?? []).slice(0, 10) /** NOTE: 只取前十条训练任务来对比 */} + /> + )} + </div> + <Table + className="custom-table custom-table-left-side-filter" + loading={modelJobListQuery.isFetching} + data={modelJobList ?? []} + rowKey="id" + columns={columns} + pagination={{ + ...paginationProps, + total: modelJobListQuery.data?.page_meta?.total_items ?? modelJobList?.length, + }} + onChange={(_, __, filters, extra) => { + if (extra.action === 'filter') { + setUrlState((preState) => ({ + ...preState, + page: 1, + filter: filterExpressionGenerator( + { + status: filters.status, + }, + FILTER_MODEL_JOB_OPERATOR_MAPPER, + ), + })); + } + }} + /> + </div> + </Spin> + </SharedPageLayout> + ); + + async function onStartNewModelJobButtonClick() { + let isPeerAuthorized = false; + + try { + const resp = await fetchPeerModelJobGroupDetail(projectId!, params.id!, participantId!); + isPeerAuthorized = resp?.data?.authorized ?? false; + } catch (error) { + Message.error(error.message); + } + + if (!isPeerAuthorized) { + Message.info('合作伙伴未授权,不能发起新任务'); + return; + } + + launchModelJobGroup(projectId!, params.id) + .then(() => { + Message.success('发起成功'); + setUrlState({ page: 1, filter: undefined }); + modelJobListQuery.refetch(); + }) + .catch((error) => Message.error(error.message)); + } + function onEditButtonClick() { + history.push( + generatePath(routes.ModelTrainCreate, { + role: detail!.role === ModelJobRole.COORDINATOR ? 'sender' : 'receiver', + action: 'edit', + id: params.id, + }), + ); + } + async function onStartModelTrainJobClick(type: string) { + if (detail?.auth_frontend_status !== ModelGroupStatus.ALL_AUTHORIZED) { + Message.info('所有合作伙伴授权通过后才可以发起模型训练任务'); + return false; + } + if (detail?.auto_update_status === AutoModelJobStatus.STOPPED && type === 'repeat') { + if (!projectId || !detail?.id) { + Message.info('请选择工作区!'); + return; + } + try { + await fetchAutoUpdateModelJobDetail(projectId, detail?.id); + } catch (err: any) { + if (err.message.indexOf('is running') !== -1) { + Message.info('有定时任务正在运行,请停止后重试!'); + return; + } + } + } + history.push( + generatePath(routes.ModelTrainJobCreate, { + type: type, + id: params.id, + step: 'coordinator', + }), + ); + } + function onDeleteButtonClick() { + Modal.delete({ + title: `确认要删除「${detail?.name}」?`, + content: '删除后,该模型训练下的所有信息无法复原,请谨慎操作', + onOk() { + deleteModelJobGroup(projectId!, params.id) + .then(() => { + Message.success('删除成功'); + history.push(routes.ModelTrainList); + }) + .catch((error) => Message.error(error.message)); + }, + }); + } +}; +export default Detail; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/List/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/List/index.tsx new file mode 100644 index 000000000..1cb86bcfc --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/List/index.tsx @@ -0,0 +1,478 @@ +import React, { FC, useMemo } from 'react'; +import { generatePath, useHistory } from 'react-router'; +import { useMutation, useQueries, useQuery } from 'react-query'; + +import { + useGetAppFlagValue, + useGetCurrentProjectId, + useGetCurrentProjectParticipantId, + useGetCurrentProjectParticipantList, + useGetCurrentPureDomainName, + useTablePaginationWithUrlState, + useUrlState, +} from 'hooks'; +import { + fetchModelJobGroupList, + deleteModelJobGroup, + authorizeModelJobGroup, + fetchPeerModelJobGroupDetail, + fetchModelJobGroupDetail, +} from 'services/modelCenter'; +import { TIME_INTERVAL, CONSTANTS } from 'shared/constants'; +import { formatTimestamp } from 'shared/date'; +import { expression2Filter } from 'shared/filter'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { + algorithmTypeFilters, + roleFilters, + statusFilters, + FILTER_MODEL_TRAIN_OPERATOR_MAPPER, + MODEL_GROUP_STATUS_MAPPER, + resetAuthInfo, + AUTH_STATUS_TEXT_MAP, + getModelJobStatus, +} from 'views/ModelCenter/shared'; +import { launchModelJobGroup } from 'services/modelCenter'; + +import { Link } from 'react-router-dom'; +import { Button, Input, Message, Space, Table } from '@arco-design/web-react'; +import { IconPlus } from '@arco-design/web-react/icon'; +import GridRow from 'components/_base/GridRow'; +import SharedPageLayout from 'components/SharedPageLayout'; +import MoreActions from 'components/MoreActions'; +import TodoPopover from 'components/TodoPopover'; +import Modal from 'components/Modal'; + +import { ColumnProps } from '@arco-design/web-react/es/Table'; +import { ModelGroupStatus, ModelJobGroup, ModelJobRole } from 'typings/modelCenter'; + +import routes from '../../routes'; +import StateIndicator from 'components/StateIndicator'; +import ButtonWithPopconfirm from 'components/ButtonWithPopconfirm'; +import AlgorithmType from 'components/AlgorithmType'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import ProgressWithText from 'components/ProgressWithText'; +import { Flag, FlagKey } from 'typings/flag'; +import { fetchParticipantFlagById } from 'services/flag'; + +type TProps = {}; +const { Search } = Input; +const List: FC<TProps> = function (props: TProps) { + const history = useHistory(); + const { urlState: pageInfoState, paginationProps } = useTablePaginationWithUrlState(); + const [urlState, setUrlState] = useUrlState({ + //TODO: BE support states filter & sort + states: [], + updated_at_sort: '', + filter: filterExpressionGenerator( + { + configured: true, + }, + FILTER_MODEL_TRAIN_OPERATOR_MAPPER, + ), + }); + + const projectId = useGetCurrentProjectId(); + const participantId = useGetCurrentProjectParticipantId(); + const participantList = useGetCurrentProjectParticipantList(); + const model_job_global_config_enabled = useGetAppFlagValue( + FlagKey.MODEL_JOB_GLOBAL_CONFIG_ENABLED, + ); + const myPureDomainName = useGetCurrentPureDomainName(); + + const participantsFlagQueries = useQueries( + participantList.map((participant) => { + return { + queryKey: ['fetchParticipantFlag', participant.id], + queryFn: () => fetchParticipantFlagById(participant.id), + retry: 2, + enabled: Boolean(participant.id), + refetchOnWindowFocus: false, + }; + }), + ); + + const listQuery = useQuery( + ['fetchModelJobGroupList', projectId, pageInfoState.page, pageInfoState.pageSize, urlState], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchModelJobGroupList(projectId!, { + page: pageInfoState.page, + pageSize: pageInfoState.pageSize, + filter: urlState.filter, + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + keepPreviousData: true, + refetchOnWindowFocus: false, + }, + ); + + const authorizeMutate = useMutation( + (payload: { id: ID; authorized: boolean }) => { + return authorizeModelJobGroup(projectId!, payload.id, payload.authorized); + }, + { + onSuccess(_, { authorized }) { + listQuery.refetch(); + Message.success(!authorized ? '撤销成功' : '授权成功'); + }, + onError(_, { authorized }) { + listQuery.refetch(); + Message.error(!authorized ? '撤销失败' : '授权失败'); + }, + }, + ); + const linkToNewCreatePage = useMemo(() => { + let flag = true; + participantsFlagQueries.forEach((item) => { + const participantFlag = item.data as { data: Flag } | undefined; + if (participantFlag?.data.model_job_global_config_enabled === false) { + flag = false; + } + }); + return flag; + }, [participantsFlagQueries]); + + const list = useMemo(() => { + if (!listQuery.data?.data) return []; + return listQuery.data.data; + }, [listQuery.data]); + + const columns = useMemo<ColumnProps<ModelJobGroup>[]>(() => { + return [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + width: 200, + ellipsis: true, + render: (name: string, record) => { + return ( + <Link + to={generatePath(routes.ModelTrainDetail, { + id: record.id, + })} + > + {name} + </Link> + ); + }, + }, + { + title: '类型', + dataIndex: 'algorithm_type', + name: 'algorithm_type', + width: 150, + filters: algorithmTypeFilters.filters, + filteredValue: expression2Filter(urlState.filter)?.algorithm_type, + render: (type) => { + return <AlgorithmType type={type as EnumAlgorithmProjectType} />; + }, + }, + { + title: '发起方', + dataIndex: 'role', + name: 'role', + width: 120, + filters: roleFilters.filters, + filteredValue: expression2Filter(urlState.filter)?.role, + filterMultiple: false, + render: (role: string, record) => + role + ? role === ModelJobRole.COORDINATOR + ? '我方' + : participantList.find((item) => item.id === record.coordinator_id)?.name || + CONSTANTS.EMPTY_PLACEHOLDER + : CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + title: '授权状态', + dataIndex: 'auth_frontend_status', + name: 'auth_frontend_status', + width: 120, + render: (value: ModelGroupStatus, record: any) => { + const progressConfig = MODEL_GROUP_STATUS_MAPPER?.[value]; + const authInfo = resetAuthInfo( + record.participants_info.participants_map, + participantList, + myPureDomainName, + ); + return ( + <ProgressWithText + statusText={progressConfig?.name} + status={progressConfig?.status} + percent={progressConfig?.percent} + toolTipContent={ + [ModelGroupStatus.PART_AUTH_PENDING, ModelGroupStatus.SELF_AUTH_PENDING].includes( + value, + ) ? ( + <> + {authInfo.map((item: any) => ( + <div key={item.name}>{`${item.name}: ${ + AUTH_STATUS_TEXT_MAP?.[item.authStatus] + }`}</div> + ))} + </> + ) : undefined + } + /> + ); + }, + }, + { + title: '任务总数', + dataIndex: 'latest_version', + name: 'model_jobs', + width: 100, + align: 'center', + render: (value: any) => { + return typeof value === 'number' ? value : CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + title: '最新任务状态', + dataIndex: 'latest_job_state', + name: 'latest_job_state', + width: 150, + // TODO: 后端筛选 + ...statusFilters, + filteredValue: urlState.states ?? [], + render: (value: any, record) => { + if (!value) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + return ( + <StateIndicator + {...getModelJobStatus(record.latest_job_state, { + isHideAllActionList: true, + })} + /> + ); + }, + }, + { + title: '创建者', + dataIndex: 'creator_username', + name: 'creator', + width: 120, + render: (value: any) => value ?? CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + title: '更新时间', + dataIndex: 'updated_at', + name: 'updated_at', + width: 150, + sorter(a: ModelJobGroup, b: ModelJobGroup) { + return a.created_at - b.created_at; + }, + defaultSortOrder: urlState?.updated_at_sort, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + { + title: '操作', + dataIndex: 'authorized', + name: 'operation', + fixed: 'right', + key: 'operate', + width: 200, + render: (authorized: boolean, record) => ( + <Space> + <button + className="custom-text-button" + style={{ + width: 60, + textAlign: 'left', + }} + onClick={async () => { + let isPeerAuthorized = false; + let isOldModelGroup = true; + try { + const res = await fetchModelJobGroupDetail(projectId!, record.id!); + const detail = res.data; + isOldModelGroup = Boolean(detail?.config?.job_definitions?.length); + } catch (error: any) { + Message.error(error.message); + } + if (isOldModelGroup && record.role !== 'COORDINATOR') { + Message.info('旧版模型训练作业非发起方暂不支持发起模型训练任务'); + return; + } + try { + // TODO:能否发起训练任务判定逻辑后续根据 auth_frontend_status 字段判断 + const resp = await fetchPeerModelJobGroupDetail( + projectId!, + record.id!, + participantId!, + ); + isPeerAuthorized = resp?.data?.authorized ?? false; + } catch (error: any) { + Message.error(error.message); + } + + if (!isPeerAuthorized) { + Message.info('合作伙伴未授权,不能发起新任务'); + return; + } + + model_job_global_config_enabled && !isOldModelGroup + ? history.push( + generatePath(routes.ModelTrainJobCreate, { + type: 'once', + id: record?.id, + step: 'coordinator', + }), + ) + : launchModelJobGroup(projectId!, record.id) + .then((resp) => { + Message.success('发起成功'); + listQuery.refetch(); + }) + .catch((error) => Message.error(error.message)); + }} + > + 发起新任务 + </button> + + <ButtonWithPopconfirm + title={ + record.authorized + ? '撤销授权后,发起方不可运行模型训练,正在运行的任务不受影响' + : '授权后,发起方可以运行模型训练' + } + buttonProps={{ + type: 'text', + className: 'custom-text-button', + style: { + width: 60, + textAlign: 'left', + }, + }} + buttonText={record.authorized ? '撤销' : '授权'} + onConfirm={() => { + authorizeMutate.mutate({ + id: record.id, + authorized: !record.authorized, + }); + }} + /> + + <MoreActions + actionList={[ + { + label: '删除' as any, + danger: true, + onClick() { + Modal.delete({ + title: `确认要删除「${record.name}」?`, + content: '删除后,该模型训练下的所有信息无法复原,请谨慎操作', + onOk() { + deleteModelJobGroup(projectId!, record.id) + .then((resp) => { + Message.success('删除成功'); + listQuery.refetch(); + }) + .catch((error) => Message.error(error.message)); + }, + }); + }, + }, + ]} + /> + </Space> + ), + }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlState, projectId, participantId, myPureDomainName, participantList]); + + return ( + <SharedPageLayout title={'模型训练'} rightTitle={<TodoPopover.NewTrainModel />}> + <GridRow justify="space-between" align="center"> + <Button + type="primary" + className={'custom-operation-button'} + icon={<IconPlus />} + onClick={goToCreatePage} + > + 创建训练 + </Button> + <Search + className={'custom-input'} + allowClear + placeholder={'输入模型训练名称'} + defaultValue={expression2Filter(urlState.filter).name} + onSearch={onSearch} + onClear={() => onSearch('')} + /> + </GridRow> + <Table + className="custom-table custom-table-left-side-filter" + rowKey="id" + loading={listQuery.isFetching} + data={list} + scroll={{ x: '100%' }} + columns={columns} + pagination={{ + ...paginationProps, + total: listQuery.data?.page_meta?.total_items ?? undefined, + }} + onChange={(pagination, sorter, filters, extra) => { + switch (extra.action) { + case 'sort': + //TODO: BE support sort + setUrlState((prevState) => ({ + ...prevState, + [`${sorter.field}_sort`]: sorter.direction, + })); + break; + case 'filter': { + const copyFilters = { + ...filters, + name: expression2Filter(urlState.filter).name, + configured: true, + }; + setUrlState((prevState) => ({ + ...prevState, + page: 1, + filter: filterExpressionGenerator(copyFilters, FILTER_MODEL_TRAIN_OPERATOR_MAPPER), + states: filters.latest_job_state, + })); + break; + } + default: + } + }} + /> + </SharedPageLayout> + ); + + function goToCreatePage() { + model_job_global_config_enabled && linkToNewCreatePage + ? history.push(generatePath(routes.ModelTrainCreateCentralization, { role: 'sender' })) + : history.push( + generatePath(routes.ModelTrainCreate, { + role: 'sender', + action: 'create', + }), + ); + } + + function onSearch(value: string) { + const filters = expression2Filter(urlState.filter); + filters.name = value; + setUrlState((prevState) => ({ + ...prevState, + keyword: value, + page: 1, + filter: filterExpressionGenerator(filters, FILTER_MODEL_TRAIN_OPERATOR_MAPPER), + })); + } +}; + +export default List; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/AlgorithmVersionSelect/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/AlgorithmVersionSelect/index.tsx new file mode 100644 index 000000000..c3153a95b --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/AlgorithmVersionSelect/index.tsx @@ -0,0 +1,132 @@ +import React, { useMemo } from 'react'; +import { Select, Grid, Input } from '@arco-design/web-react'; +import { AlgorithmParameter, AlgorithmProject, AlgorithmVersionStatus } from 'typings/algorithm'; +import { useQuery } from 'react-query'; +import { fetchAlgorithmList, fetchPeerAlgorithmList } from 'services/algorithm'; +import { useGetCurrentProjectId } from 'hooks'; + +const { Row, Col } = Grid; +type AlgorithmVersionValue = { + algorithmUuid?: ID; + config?: AlgorithmParameter[]; +}; +interface Props { + algorithmProjectList: AlgorithmProject[]; + peerAlgorithmProjectList: AlgorithmProject[]; + algorithmProjectUuid: ID; + onChange?: (value: AlgorithmVersionValue) => void; + value?: AlgorithmVersionValue; +} + +export default function AlgorithmVersionSelect({ + algorithmProjectList, + peerAlgorithmProjectList, + algorithmProjectUuid, + onChange: onSelectedAlgorithmVersionChange, + value, +}: Props) { + const projectId = useGetCurrentProjectId(); + const { algorithmOwner, selectedAlgorithmProject } = useMemo(() => { + if (algorithmProjectList?.find((item) => item.uuid === algorithmProjectUuid)) { + return { + algorithmOwner: 'self', + selectedAlgorithmProject: algorithmProjectList?.find( + (item) => item.uuid === algorithmProjectUuid, + ), + }; + } + if (peerAlgorithmProjectList?.find((item) => item.uuid === algorithmProjectUuid)) { + return { + algorithmOwner: 'peer', + selectedAlgorithmProject: peerAlgorithmProjectList?.find( + (item) => item.uuid === algorithmProjectUuid, + ), + }; + } + return {}; + }, [algorithmProjectList, algorithmProjectUuid, peerAlgorithmProjectList]); + + const configValueList = useMemo(() => { + return value?.config || []; + }, [value?.config]); + + const algorithmVersionListQuery = useQuery( + ['fetchAlgorithmVersionList', selectedAlgorithmProject, algorithmOwner], + () => { + if (algorithmOwner === 'self') { + return fetchAlgorithmList(0, { algo_project_id: selectedAlgorithmProject?.id! }); + } else { + return fetchPeerAlgorithmList(projectId, selectedAlgorithmProject?.participant_id!, { + algorithm_project_uuid: selectedAlgorithmProject?.uuid!, + }); + } + }, + { + enabled: Boolean(projectId && selectedAlgorithmProject && algorithmOwner), + }, + ); + + const algorithmVersionListOptions = useMemo(() => { + return algorithmVersionListQuery.data?.data + .filter( + (item) => item.status === AlgorithmVersionStatus.PUBLISHED || item.source === 'PRESET', + ) + .map((item) => { + return { + label: `V${item.version}`, + value: item.uuid as string, + extra: item, + }; + }); + }, [algorithmVersionListQuery.data?.data]); + + return ( + <> + <Select + value={value?.algorithmUuid || undefined} + options={algorithmVersionListOptions} + onChange={handleSelectChange} + /> + {configValueList.length > 0 && ( + <> + <Row gutter={[12, 12]}> + <Col span={12}>超参数</Col> + </Row> + + {configValueList.map((item, index) => ( + <Row key={`${value?.algorithmUuid}_$${item.name}_${index}`} gutter={[12, 12]}> + <Col span={12}> + <Input + value={item.name} + onChange={(value) => onConfigValueChange(value, 'name', index)} + disabled={true} + /> + </Col> + <Col span={12}> + <Input + value={item.value} + onChange={(value) => onConfigValueChange(value, 'value', index)} + /> + </Col> + </Row> + ))} + </> + )} + </> + ); + function handleSelectChange(val: any) { + const selectedAlgorithm = algorithmVersionListOptions?.find((item) => item.value === val); + onSelectedAlgorithmVersionChange?.({ + algorithmUuid: val, + config: selectedAlgorithm?.extra.parameter?.variables || [], + }); + } + function onConfigValueChange(val: string, key: string, index: number) { + const newConfigValueList = [...configValueList]; + newConfigValueList[index] = { ...newConfigValueList[index], [key]: val }; + onSelectedAlgorithmVersionChange?.({ + ...value, + config: newConfigValueList, + }); + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/StepOneCoordinator/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/StepOneCoordinator/index.tsx new file mode 100644 index 000000000..76256d2ed --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/StepOneCoordinator/index.tsx @@ -0,0 +1,294 @@ +import React, { useEffect, useMemo } from 'react'; +import { generatePath, useHistory } from 'react-router'; +import routes from 'views/ModelCenter/routes'; +import { ModelJobGroup, ResourceTemplateType, TrainRoleType } from 'typings/modelCenter'; +import { AlgorithmProject, EnumAlgorithmProjectType } from 'typings/algorithm'; +import { MAX_COMMENT_LENGTH } from 'shared/validator'; +import { + ALGORITHM_TYPE_LABEL_MAPPER, + isNNAlgorithm, + isTreeAlgorithm, + lossTypeOptions, + nnBaseConfigList, + trainRoleTypeOptions, + treeBaseConfigList, +} from 'views/ModelCenter/shared'; + +import { Form, Input, Space, Button, Spin, Tag, Select, Typography } from '@arco-design/web-react'; +import BlockRadio from 'components/_base/BlockRadio'; +import ConfigForm, { ItemProps } from 'components/ConfigForm'; +import ResourceConfig, { MixedAlgorithmType } from 'components/ResourceConfig'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import { useGetCurrentPureDomainName } from 'hooks'; +import AlgorithmProjectSelect from '../../CreateCentralization/AlgorithmProjectSelect'; +import AlgorithmVersionSelect from '../AlgorithmVersionSelect'; +import { useQuery } from 'react-query'; +import { fetchDataBatchs } from 'services/dataset'; + +type Props = { + isLoading?: boolean; + modelGroup?: ModelJobGroup; + jobType: string; + datasetName?: string; + isFormValueChanged?: boolean; + stepOneFormConfigValues?: Record<string, any>; + onFirstStepSubmit: (formInfo: Record<string, any>) => void; + onFormValueChange?: (...args: any[]) => void; + formInitialValues?: Record<string, any>; + treeAdvancedFormItemList?: ItemProps[]; + nnAdvancedFormItemList: ItemProps[]; + algorithmProjectList?: AlgorithmProject[]; + peerAlgorithmProjectList?: AlgorithmProject[]; + datasetBatchType?: 'day' | 'hour'; +}; + +export default function StepOneCoordinator({ + isLoading, + modelGroup, + jobType, + datasetName, + isFormValueChanged, + onFirstStepSubmit, + onFormValueChange, + formInitialValues, + nnAdvancedFormItemList, + treeAdvancedFormItemList, + algorithmProjectList, + peerAlgorithmProjectList, + datasetBatchType = 'day', +}: Props) { + const history = useHistory(); + const [formInstance] = Form.useForm(); + const myPureDomainName = useGetCurrentPureDomainName(); + + const dataBatchsQuery = useQuery( + ['fetchDataBatch'], + () => fetchDataBatchs(modelGroup?.dataset_id!), + { + enabled: !!modelGroup?.dataset_id, + }, + ); + const dataBatchsOptions = useMemo(() => { + return dataBatchsQuery.data?.data.map((item) => { + return { + label: item.name, + value: item.id, + }; + }); + }, [dataBatchsQuery.data?.data]); + + useEffect(() => { + if (!formInitialValues) { + return; + } + formInstance.setFieldsValue(formInitialValues); + }, [formInitialValues, formInstance]); + return ( + <Spin loading={isLoading}> + <Form + className="form-content" + form={formInstance} + scrollToFirstError={true} + initialValues={formInitialValues} + onValuesChange={onFormValueChange} + onSubmit={onNextStepClick} + > + <section className="form-section"> + <h3>基本信息</h3> + <Form.Item label="训练名称"> + <Typography.Text bold={true}>{formInitialValues?.name}</Typography.Text> + </Form.Item> + <Form.Item + field={'comment'} + label={'描述'} + rules={[ + { + maxLength: MAX_COMMENT_LENGTH, + message: '最多为 200 个字符', + }, + ]} + > + <Input.TextArea placeholder={'最多为 200 个字符'} /> + </Form.Item> + </section> + <section className="form-section"> + <h3>训练配置</h3> + + <Form.Item label={'联邦类型'}> + <Typography.Text bold={true}> + {ALGORITHM_TYPE_LABEL_MAPPER?.[modelGroup?.algorithm_type!]} + </Typography.Text> + </Form.Item> + + {isTreeAlgorithm(modelGroup?.algorithm_type as EnumAlgorithmProjectType) && + renderTreeParams()} + {isNNAlgorithm(modelGroup?.algorithm_type as EnumAlgorithmProjectType) && + renderNNParams()} + {modelGroup?.algorithm_type !== EnumAlgorithmProjectType.NN_HORIZONTAL && ( + <Form.Item + field={'role'} + label={'训练角色'} + rules={[{ required: true, message: '必须选择训练角色' }]} + > + <BlockRadio isCenter={true} options={trainRoleTypeOptions} /> + </Form.Item> + )} + + {jobType !== 'repeat' && ( + <Form.Item label={'数据集'}> + <Spin loading={false}> + <Space> + <Typography.Text bold={true}>{datasetName || ''}</Typography.Text> + <Tag color="arcoblue">结果</Tag> + </Space> + </Spin> + </Form.Item> + )} + </section> + <section className="form-section"> + <h3>我方资源配置</h3> + <Form.Item + field={'resource_config'} + label={'资源模版'} + rules={[{ required: true, message: '必填项' }]} + > + <ResourceConfig + algorithmType={modelGroup?.algorithm_type as MixedAlgorithmType} + defaultResourceType={ResourceTemplateType.CUSTOM} + isIgnoreFirstRender={false} + localDisabledList={['master.replicas']} + /> + </Form.Item> + </section> + {jobType === 'repeat' && ( + <section className="form-section"> + <h3>定时续训</h3> + <Form.Item label={'定时'}> + <Typography.Text bold={true}> + {datasetBatchType === 'hour' ? '每小时' : '每天'} + </Typography.Text> + </Form.Item> + <Form.Item + label={'数据集'} + field="data_batch_id" + rules={[{ required: true, message: '必填项' }]} + > + <Select + prefix={ + <Space align="center"> + <Typography.Text bold={true}>{datasetName || ''}</Typography.Text> + <Tag color={datasetBatchType === 'hour' ? 'arcoblue' : 'purple'}> + {datasetBatchType === 'hour' ? '小时级' : '天级'} + </Tag> + </Space> + } + placeholder="请选择数据批次" + options={dataBatchsOptions} + allowClear={true} + loading={dataBatchsQuery.isFetching} + /> + </Form.Item> + </section> + )} + <Space> + <Button type="primary" htmlType="submit"> + 下一步 + </Button> + <ButtonWithModalConfirm + isShowConfirmModal={isFormValueChanged} + onClick={() => { + history.goBack(); + }} + > + 取消 + </ButtonWithModalConfirm> + </Space> + </Form> + </Spin> + ); + function renderTreeParams() { + return ( + <> + { + <Form.Item + field={'loss_type'} + label={'损失函数类型'} + rules={[{ required: true, message: '必须选择损失函数类型' }]} + > + <BlockRadio.WithTip options={lossTypeOptions} isOneHalfMode={true} /> + </Form.Item> + } + <Form.Item + field={'tree_config'} + label={'参数配置'} + rules={[{ required: true, message: '必须填写参数配置' }]} + > + <ConfigForm + cols={2} + formItemList={treeBaseConfigList} + isResetOnFormItemListChange={true} + collapseFormItemList={treeAdvancedFormItemList} + formProps={{ + style: { + marginTop: 7, + }, + }} + /> + </Form.Item> + </> + ); + } + function renderNNParams() { + return ( + <> + <Form.Item label={'算法'} field={`algorithmProjects.${myPureDomainName}`}> + <AlgorithmProjectSelect + algorithmType={[modelGroup?.algorithm_type as EnumAlgorithmProjectType]} + supportEdit={false} + /> + </Form.Item> + <Form.Item + label={'算法版本'} + field={`${myPureDomainName}.algorithm`} + rules={[{ required: true, message: '必填项' }]} + > + <AlgorithmVersionSelect + algorithmProjectList={algorithmProjectList || []} + peerAlgorithmProjectList={peerAlgorithmProjectList || []} + algorithmProjectUuid={formInstance.getFieldValue( + `algorithmProjects.${myPureDomainName}`, + )} + /> + </Form.Item> + <Form.Item + field={'nn_config'} + label={'参数配置'} + rules={[{ required: true, message: '必填项' }]} + > + <ConfigForm + cols={2} + isResetOnFormItemListChange={true} + formItemList={nnBaseConfigList} + collapseFormItemList={nnAdvancedFormItemList} + /> + </Form.Item> + </> + ); + } + function onNextStepClick() { + formInstance.validate(); + const coordinatorConfig = formInstance.getFieldsValue(); + onFirstStepSubmit?.({ + role: TrainRoleType.LABEL, + ...formInitialValues, + ...coordinatorConfig, + }); + history.push( + generatePath(routes.ModelTrainJobCreate, { + id: modelGroup?.id, + type: jobType, + step: 'participant', + }), + ); + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/StepTwoParticipant/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/StepTwoParticipant/index.tsx new file mode 100644 index 000000000..d267196d2 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/StepTwoParticipant/index.tsx @@ -0,0 +1,285 @@ +import React, { useEffect } from 'react'; +import { useHistory } from 'react-router'; +import { useGetCurrentProjectParticipantList } from 'hooks'; +import { AlgorithmProject, EnumAlgorithmProjectType } from 'typings/algorithm'; +import { ModelJobGroup, ResourceTemplateType, TrainRoleType } from 'typings/modelCenter'; +import { + isNNAlgorithm, + isTreeAlgorithm, + nnBaseConfigList, + treeBaseConfigList, +} from 'views/ModelCenter/shared'; +import { Button, Checkbox, Form, Space, Tooltip, Typography } from '@arco-design/web-react'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import ConfigForm, { ItemProps } from 'components/ConfigForm'; +import ResourceConfig, { MixedAlgorithmType } from 'components/ResourceConfig'; +import AlgorithmProjectSelect from '../../CreateCentralization/AlgorithmProjectSelect'; +import AlgorithmVersionSelect from '../AlgorithmVersionSelect'; +import { IconQuestionCircle } from '@arco-design/web-react/icon'; + +type Props = { + modelGroup?: ModelJobGroup; + stepOneFormConfigValues?: Record<string, any>; + formInitialValues?: Record<string, any>; + isFormValueChanged?: boolean; + onFormValueChange?: (...args: any[]) => void; + onSecondStepSubmit?: (value: any) => void; + saveStepTwoValues?: (formInfo: Record<string, any>) => void; + treeAdvancedFormItemList?: ItemProps[]; + nnAdvancedFormItemList?: ItemProps[]; + algorithmProjectList?: AlgorithmProject[]; + peerAlgorithmProjectList?: AlgorithmProject[]; + submitLoading?: boolean; +}; + +export default function StepTwoParticipant({ + modelGroup, + formInitialValues, + isFormValueChanged, + onFormValueChange, + onSecondStepSubmit, + saveStepTwoValues, + stepOneFormConfigValues, + nnAdvancedFormItemList, + treeAdvancedFormItemList, + algorithmProjectList, + peerAlgorithmProjectList, + submitLoading, +}: Props) { + const history = useHistory(); + const [formInstance] = Form.useForm(); + const participantList = useGetCurrentProjectParticipantList(); + + useEffect(() => { + if (!stepOneFormConfigValues) { + history.goBack(); + } + }, [stepOneFormConfigValues, history]); + + useEffect(() => { + if (!formInitialValues) { + return; + } + participantList.forEach((participant) => { + formInstance.setFieldValue( + resetFiled(participant.pure_domain_name, 'algorithmProjectUuid'), + formInitialValues?.[participant.pure_domain_name].algorithmProjectUuid, + ); + formInstance.setFieldValue( + resetFiled(participant.pure_domain_name, 'nn_config'), + formInitialValues?.[participant.pure_domain_name].nn_config, + ); + formInstance.setFieldValue( + resetFiled(participant.pure_domain_name, 'tree_config'), + formInitialValues?.[participant.pure_domain_name].tree_config, + ); + }); + }, [formInitialValues, formInstance, participantList]); + useEffect(() => { + if (!formInitialValues) { + return; + } + participantList.forEach((participant) => { + if (formInitialValues?.[participant.pure_domain_name].resource_config) { + formInstance.setFieldValue( + resetFiled(participant.pure_domain_name, 'resource_config'), + formInitialValues?.[participant.pure_domain_name].resource_config, + ); + } + }); + }, [formInitialValues, formInstance, participantList]); + return ( + <Form + className="form-content" + form={formInstance} + scrollToFirstError={true} + initialValues={formInitialValues} + onValuesChange={onFormValueChange} + onSubmit={onSecondStepSubmit} + > + {modelGroup?.algorithm_type === EnumAlgorithmProjectType.NN_HORIZONTAL ? ( + <> + {participantList.map((participant) => { + return ( + <section key={participant.id} className="form-section"> + <h3>{participant.pure_domain_name}配置</h3> + <Form.Item + label={'算法'} + field={resetFiled(participant.pure_domain_name, 'algorithmProjectUuid')} + > + <AlgorithmProjectSelect + algorithmType={[modelGroup?.algorithm_type as EnumAlgorithmProjectType]} + supportEdit={false} + /> + </Form.Item> + <Form.Item + label={'算法版本'} + field={`${participant.pure_domain_name}.algorithm`} + rules={[{ required: true, message: '必填项' }]} + > + <AlgorithmVersionSelect + algorithmProjectList={algorithmProjectList || []} + peerAlgorithmProjectList={peerAlgorithmProjectList || []} + algorithmProjectUuid={formInstance.getFieldValue( + resetFiled(participant.pure_domain_name, 'algorithmProjectUuid'), + )} + /> + </Form.Item> + <Form.Item + field={resetFiled(participant.pure_domain_name, 'nn_config')} + label={'参数配置'} + rules={[{ required: true, message: '必填项' }]} + > + <ConfigForm + cols={2} + formItemList={nnBaseConfigList} + collapseFormItemList={nnAdvancedFormItemList} + isResetOnFormItemListChange={true} + /> + </Form.Item> + <Form.Item + field={resetFiled(participant.pure_domain_name, 'resource_config')} + label={'资源模版'} + rules={[{ required: true, message: '必填项' }]} + > + <ResourceConfig + algorithmType={modelGroup?.algorithm_type as MixedAlgorithmType} + defaultResourceType={ResourceTemplateType.CUSTOM} + isIgnoreFirstRender={false} + localDisabledList={['master.replicas']} + collapsedOpen={false} + /> + </Form.Item> + </section> + ); + })} + </> + ) : ( + <> + {participantList.map((participant) => { + return ( + <div key={participant.id}> + <section className="form-section"> + <h3>{participant.name}训练配置</h3> + + {isTreeAlgorithm(modelGroup?.algorithm_type as EnumAlgorithmProjectType) && ( + <> + <Form.Item label={'损失函数类型'}> + <Typography.Text bold={true}> + {stepOneFormConfigValues?.loss_type} + </Typography.Text> + </Form.Item> + + <Form.Item + field={resetFiled(participant.pure_domain_name, 'tree_config')} + label={'参数配置'} + rules={[{ required: true, message: '必须填写参数配置' }]} + > + <ConfigForm + cols={2} + isResetOnFormItemListChange={true} + formItemList={treeBaseConfigList} + collapseFormItemList={treeAdvancedFormItemList} + formProps={{ + style: { + marginTop: 7, + }, + }} + /> + </Form.Item> + </> + )} + {isNNAlgorithm(modelGroup?.algorithm_type as EnumAlgorithmProjectType) && ( + <> + <Form.Item + label={'算法'} + field={resetFiled(participant.pure_domain_name, 'algorithmProjectUuid')} + > + <AlgorithmProjectSelect + algorithmType={[modelGroup?.algorithm_type as EnumAlgorithmProjectType]} + supportEdit={false} + /> + </Form.Item> + <Form.Item + label={'算法版本'} + field={`${participant.pure_domain_name}.algorithm`} + rules={[{ required: true, message: '必填项' }]} + > + <AlgorithmVersionSelect + algorithmProjectList={algorithmProjectList || []} + peerAlgorithmProjectList={peerAlgorithmProjectList || []} + algorithmProjectUuid={formInstance.getFieldValue( + resetFiled(participant.pure_domain_name, 'algorithmProjectUuid'), + )} + /> + </Form.Item> + + <Form.Item + field={resetFiled(participant.pure_domain_name, 'nn_config')} + label={'参数配置'} + rules={[{ required: true, message: '必填项' }]} + > + <ConfigForm + cols={2} + formItemList={nnBaseConfigList} + collapseFormItemList={nnAdvancedFormItemList} + isResetOnFormItemListChange={true} + /> + </Form.Item> + </> + )} + <Form.Item label={'训练角色'}> + <Typography.Text bold={true}> + {stepOneFormConfigValues?.role === TrainRoleType.LABEL ? '特征方' : '标签方'} + </Typography.Text> + </Form.Item> + </section> + <section className="form-section"> + <h3>{participant.name}资源配置</h3> + <Form.Item + field={resetFiled(participant.pure_domain_name, 'resource_config')} + label={'资源模版'} + rules={[{ required: true, message: '必填项' }]} + > + <ResourceConfig + algorithmType={modelGroup?.algorithm_type as MixedAlgorithmType} + defaultResourceType={ResourceTemplateType.CUSTOM} + isIgnoreFirstRender={false} + localDisabledList={['master.replicas']} + /> + </Form.Item> + </section> + </div> + ); + })} + </> + )} + <Space align="center"> + <Button loading={submitLoading} type="primary" htmlType="submit"> + 提交 + </Button> + <ButtonWithModalConfirm + isShowConfirmModal={false} + onClick={() => { + saveStepTwoValues?.(formInstance.getFields()); + history.goBack(); + }} + > + 上一步 + </ButtonWithModalConfirm> + <Form.Item field="metric_is_public" triggerPropName="checked" style={{ marginBottom: 0 }}> + <Checkbox style={{ width: 200, fontSize: 12 }}> + 共享训练指标 + <Tooltip content="共享后,合作伙伴能够查看本方训练指标"> + <IconQuestionCircle /> + </Tooltip> + </Checkbox> + </Form.Item> + </Space> + </Form> + ); + + function resetFiled(participantName: string, filedName: string) { + return `${participantName}.${filedName}`; + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/index.module.less b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/index.module.less new file mode 100644 index 000000000..c089349d8 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/index.module.less @@ -0,0 +1,9 @@ +.step_container{ + width: 500px; + max-width: 780px; +} +.form_area{ + flex: 1; + margin: 20px auto 0; + background-color: white; +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/index.tsx new file mode 100644 index 000000000..ea3b7a15e --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/ModelTrainJobCreate/index.tsx @@ -0,0 +1,580 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useParams, useHistory, Route, generatePath } from 'react-router'; +import routes from '../../routes'; +import { + createModelJob, + fetchModelJobDefinition, + fetchModelJobGroupDetail, + updateModelJob, + fetchAutoUpdateModelJobDetail, +} from 'services/modelCenter'; +import { fetchDatasetDetail, fetchDatasetJobDetail } from 'services/dataset'; +import { fetchPeerAlgorithmProjectList, fetchProjectList } from 'services/algorithm'; +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useGetCurrentPureDomainName, + useIsFormValueChange, +} from 'hooks'; +import { AutoModelJobStatus, EnumModelJobType, LossType, TrainRoleType } from 'typings/modelCenter'; +import { Participant } from 'typings/participant'; +import SharedPageLayout from 'components/SharedPageLayout'; +import { Message, Steps, Grid } from '@arco-design/web-react'; +import StepOneCoordinator from './StepOneCoordinator'; +import BackButton from 'components/BackButton'; +import StepTwoParticipant from './StepTwoParticipant'; +import { EnumAlgorithmProjectSource, EnumAlgorithmProjectType } from 'typings/algorithm'; +import { + getAdvanceConfigListByDefinition, + getConfigInitialValuesByDefinition, + getNNBaseConfigInitialValuesByDefinition, + getTreeBaseConfigInitialValuesByDefinition, + hydrateModalGlobalConfig, + isNNAlgorithm, + isTreeAlgorithm, +} from 'views/ModelCenter/shared'; + +import styles from './index.module.less'; + +const { Row } = Grid; +const { Step } = Steps; + +type TRouteParams = { + id: string; + step: keyof typeof CreateSteps; + type: string; +}; +enum CreateSteps { + coordinator = 1, + participant = 2, +} + +function ModelTrainJobCreate() { + const history = useHistory(); + const { id: modelGroupId, step: createStep, type: jobType } = useParams<TRouteParams>(); + const [currentStep, setCurrentStep] = useState(CreateSteps[createStep || 'coordinator']); + const [formConfig, setFormConfig] = useState<Record<string, any>>(); + const [stepTwoFormConfig, setStepTwoFormConfig] = useState<Record<string, any>>(); + const [submitLoading, setSubmitLoading] = useState<boolean>(false); + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(); + const projectId = useGetCurrentProjectId(); + const myPureDomainName = useGetCurrentPureDomainName(); + const participantList = useGetCurrentProjectParticipantList(); + + const modelGroupDetailQuery = useQuery( + ['fetchModelGroupDetail', projectId, modelGroupId], + () => { + if (!projectId) { + Message.info('请选择工作区!'); + return; + } + return fetchModelJobGroupDetail(projectId, modelGroupId); + }, + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const modelGroupDetail = useMemo(() => modelGroupDetailQuery.data?.data, [modelGroupDetailQuery]); + + const modelJobDefinitionQuery = useQuery( + ['fetchModelJobDefinition', modelGroupDetail?.algorithm_type], + () => + fetchModelJobDefinition({ + model_job_type: 'TRAINING', + algorithm_type: modelGroupDetail?.algorithm_type || EnumAlgorithmProjectType.TREE_VERTICAL, + }), + { + refetchOnWindowFocus: false, + }, + ); + + const datasetDetailQuery = useQuery( + ['fetchDatasetDetail', modelGroupDetail?.dataset_id], + () => fetchDatasetDetail(modelGroupDetail?.dataset_id), + { + enabled: Boolean(modelGroupDetail?.dataset_id) || modelGroupDetail?.dataset_id === 0, + retry: 2, + refetchOnWindowFocus: false, + }, + ); + const datasetJobQuery = useQuery( + ['fetchDatasetJobDetail', projectId, datasetDetailQuery.data?.data.parent_dataset_job_id], + () => fetchDatasetJobDetail(projectId!, datasetDetailQuery.data?.data.parent_dataset_job_id!), + { + refetchOnWindowFocus: false, + retry: 2, + enabled: + Boolean(projectId && datasetDetailQuery.data?.data.parent_dataset_job_id) && + jobType === 'repeat', + }, + ); + + const algorithmProjectListQuery = useQuery( + ['fetchAllAlgorithmProjectList', modelGroupDetail?.algorithm_type, projectId], + () => + fetchProjectList(projectId, { + type: [modelGroupDetail?.algorithm_type], + }), + { + enabled: Boolean(projectId && modelGroupDetail?.algorithm_type), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + const preAlgorithmProjectListQuery = useQuery( + ['fetchPreAlgorithmProjectListQuery', modelGroupDetail?.algorithm_type], + () => + fetchProjectList(0, { + type: [modelGroupDetail?.algorithm_type], + sources: EnumAlgorithmProjectSource.PRESET, + }), + { + enabled: !!modelGroupDetail?.algorithm_type, + retry: 2, + refetchOnWindowFocus: false, + }, + ); + const peerAlgorithmProjectListQuery = useQuery( + ['fetchPeerAlgorithmProjectListQuery', projectId, modelGroupDetail?.algorithm_type], + () => + fetchPeerAlgorithmProjectList(projectId, 0, { + filter: `(type:${JSON.stringify([modelGroupDetail?.algorithm_type])})`, + }), + { + enabled: Boolean(projectId && modelGroupDetail?.algorithm_type), + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const modelJobDefinition = useMemo(() => { + return modelJobDefinitionQuery?.data?.data; + }, [modelJobDefinitionQuery]); + + const treeAdvancedFormItemList = useMemo(() => { + if (isNNAlgorithm(modelGroupDetail?.algorithm_type as EnumAlgorithmProjectType)) { + return []; + } else return getAdvanceConfigListByDefinition(modelJobDefinition?.variables!); + }, [modelGroupDetail?.algorithm_type, modelJobDefinition]); + + const nnAdvancedFormItemList = useMemo(() => { + if (isTreeAlgorithm(modelGroupDetail?.algorithm_type as EnumAlgorithmProjectType)) { + return []; + } else return getAdvanceConfigListByDefinition(modelJobDefinition?.variables!, true); + }, [modelGroupDetail?.algorithm_type, modelJobDefinition]); + + const { treeBaseConfigInitialValues, nnBaseConfigInitialValues } = useMemo(() => { + if (!modelJobDefinition?.variables) { + return {}; + } + return { + treeBaseConfigInitialValues: getTreeBaseConfigInitialValuesByDefinition( + modelJobDefinition.variables, + ), + nnBaseConfigInitialValues: getNNBaseConfigInitialValuesByDefinition( + modelJobDefinition.variables, + ), + }; + }, [modelJobDefinition?.variables]); + + const { treeAdvanceConfigInitialValues, nnAdvanceConfigInitialValues } = useMemo(() => { + return { + treeAdvanceConfigInitialValues: treeAdvancedFormItemList.reduce((acc, cur) => { + acc[cur.field!] = cur.initialValue; + return acc; + }, {} as any), + nnAdvanceConfigInitialValues: nnAdvancedFormItemList.reduce((acc, cur) => { + acc[cur.field!] = cur.initialValue; + return acc; + }, {} as any), + }; + }, [treeAdvancedFormItemList, nnAdvancedFormItemList]); + + const baseNN = useMemo(() => { + return { + epoch_num: 1, + verbosity: 1, + ...nnBaseConfigInitialValues, + ...nnAdvanceConfigInitialValues, + }; + }, [nnBaseConfigInitialValues, nnAdvanceConfigInitialValues]); + const baseTree = useMemo(() => { + return { + learning_rate: 0.3, + max_iters: 10, + max_depth: 5, + l2_regularization: 1, + max_bins: 33, + num_parallel: 5, + ...treeBaseConfigInitialValues, + ...treeAdvanceConfigInitialValues, + }; + }, [treeBaseConfigInitialValues, treeAdvanceConfigInitialValues]); + + const datasetDetail = useMemo(() => datasetDetailQuery.data?.data, [datasetDetailQuery]); + const datasetJob = useMemo(() => datasetJobQuery.data?.data, [datasetJobQuery]); + + const algorithmProjectList = useMemo(() => { + return [ + ...(algorithmProjectListQuery?.data?.data || []), + ...(preAlgorithmProjectListQuery.data?.data || []), + ]; + }, [algorithmProjectListQuery, preAlgorithmProjectListQuery]); + + const peerAlgorithmProjectList = useMemo(() => { + return peerAlgorithmProjectListQuery.data?.data || []; + }, [peerAlgorithmProjectListQuery]); + + const stepOneInitialValues = useMemo(() => { + return ( + formConfig ?? { + name: `${modelGroupDetail?.name}-v${modelGroupDetail?.latest_version! + 1}`, + algorithmProjects: modelGroupDetail?.algorithm_project_uuid_list?.algorithm_projects, + nn_config: baseNN, + loss_type: LossType.LOGISTIC, + tree_config: baseTree, + } + ); + }, [ + baseNN, + baseTree, + formConfig, + modelGroupDetail?.algorithm_project_uuid_list?.algorithm_projects, + modelGroupDetail?.latest_version, + modelGroupDetail?.name, + ]); + const stepTwoInitialValues = useMemo(() => { + if (stepTwoFormConfig) { + return stepTwoFormConfig; + } + const participantConfigInitialValues: Record<string, any> = {}; + participantList.forEach((participant: Participant) => { + participantConfigInitialValues[participant.pure_domain_name] = { + loss_type: formConfig?.loss_type, + algorithmProjectUuid: + modelGroupDetail?.algorithm_project_uuid_list?.algorithm_projects[ + participant.pure_domain_name + ], + nn_config: baseNN, + tree_config: baseTree, + }; + }); + return participantConfigInitialValues; + }, [ + stepTwoFormConfig, + participantList, + formConfig?.loss_type, + modelGroupDetail?.algorithm_project_uuid_list?.algorithm_projects, + baseNN, + baseTree, + ]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const lastModelJobQuery = useQuery( + [ + 'fetchLastAutoUpdateModelJob', + projectId, + modelGroupDetail?.id!, + modelGroupDetail?.auto_update_status, + ], + () => fetchAutoUpdateModelJobDetail(projectId!, modelGroupDetail?.id!), + { + enabled: + jobType === 'repeat' && + !!projectId && + !!modelGroupDetail?.id && + modelGroupDetail?.auto_update_status === AutoModelJobStatus.STOPPED, + retry: 1, + refetchOnWindowFocus: false, + onSuccess: (res) => { + const modelJobDetail = res.data; + const variablesList = ['loss_type', 'role']; + const sourceList = [ + 'master_replicas', + 'master_cpu', + 'master_mem', + 'ps_replicas', + 'ps_cpu', + 'ps_mem', + 'worker_replicas', + 'worker_cpu', + 'worker_mem', + ]; + const globalConfig = modelJobDetail.global_config?.global_config; + const coordinatorConfig = globalConfig?.[myPureDomainName]; + const coordinatorMapValue = getConfigInitialValuesByDefinition( + coordinatorConfig?.variables!, + variablesList, + ); + const coordinatorSource = getConfigInitialValuesByDefinition( + coordinatorConfig?.variables!, + sourceList, + ); + + const participantConfig: Record<string, any> = {}; + participantList.forEach((participant: Participant) => { + const curParticipantConfig = globalConfig?.[participant.pure_domain_name]; + const curParticipantSource = getConfigInitialValuesByDefinition( + curParticipantConfig?.variables!, + sourceList, + ); + participantConfig[participant.pure_domain_name] = { + loss_type: coordinatorMapValue?.loss_type, + algorithmProjectUuid: + modelGroupDetail?.algorithm_project_uuid_list?.algorithm_projects[ + participant.pure_domain_name + ], + algorithm: { + algorithmUuid: curParticipantConfig?.algorithm_uuid, + config: curParticipantConfig?.algorithm_parameter?.variables, + }, + nn_config: { + ...baseNN, + ...getNNBaseConfigInitialValuesByDefinition(curParticipantConfig?.variables!), + ...getAdvanceConfigListByDefinition(curParticipantConfig?.variables!, true).reduce( + (acc, cur) => { + acc[cur.field!] = cur.initialValue; + return acc; + }, + {} as any, + ), + }, + tree_config: { + ...baseTree, + ...getTreeBaseConfigInitialValuesByDefinition(curParticipantConfig?.variables!), + ...getAdvanceConfigListByDefinition(curParticipantConfig?.variables!).reduce( + (acc, cur) => { + acc[cur.field!] = cur.initialValue; + return acc; + }, + {} as any, + ), + }, + source_config: curParticipantSource, + }; + }); + + setFormConfig({ + name: `${modelGroupDetail?.name}-v${modelGroupDetail?.latest_version! + 1}`, + algorithmProjects: modelGroupDetail?.algorithm_project_uuid_list?.algorithm_projects, + data_batch_id: modelJobDetail.data_batch_id || undefined, + loss_type: coordinatorMapValue.loss_type, + role: coordinatorMapValue.role, + [myPureDomainName]: { + algorithm: { + algorithmUuid: coordinatorConfig?.algorithm_uuid, + config: coordinatorConfig?.algorithm_parameter?.variables, + }, + }, + nn_config: { + ...baseNN, + ...getNNBaseConfigInitialValuesByDefinition(coordinatorConfig?.variables!), + ...getAdvanceConfigListByDefinition(coordinatorConfig?.variables!, true).reduce( + (acc, cur) => { + acc[cur.field!] = cur.initialValue; + return acc; + }, + {} as any, + ), + }, + tree_config: { + ...baseTree, + ...getTreeBaseConfigInitialValuesByDefinition(coordinatorConfig?.variables!), + ...getAdvanceConfigListByDefinition(coordinatorConfig?.variables!).reduce( + (acc, cur) => { + acc[cur.field!] = cur.initialValue; + return acc; + }, + {} as any, + ), + }, + resource_config: coordinatorSource, + }); + setStepTwoFormConfig(participantConfig); + }, + onError: () => { + Message.info('获取历史定时续训任务配置失败,请重新填写配置信息!'); + }, + }, + ); + + useEffect(() => { + setCurrentStep(CreateSteps[createStep || 'coordinator']); + }, [createStep]); + + return ( + <SharedPageLayout + title={ + <BackButton onClick={() => history.goBack()} isShowConfirmModal={isFormValueChanged}> + {modelGroupDetail?.name} + </BackButton> + } + centerTitle={ + jobType === 'repeat' + ? modelGroupDetail?.auto_update_status === AutoModelJobStatus.STOPPED + ? '配置定时续训任务' + : '创建定时续训任务' + : '创建新任务' + } + > + <Row justify="center"> + <Steps className={styles.step_container} current={currentStep} size="small"> + <Step title="我方配置" /> + <Step title="合作伙伴配置" /> + </Steps> + </Row> + <section className={styles.form_area}> + <Route + path={generatePath(routes.ModelTrainJobCreate, { + id: modelGroupId, + type: jobType, + step: 'coordinator', + })} + exact + render={() => ( + <StepOneCoordinator + isLoading={modelGroupDetailQuery.isFetching || datasetDetailQuery.isFetching} + onFormValueChange={onFormValueChange} + modelGroup={modelGroupDetail} + jobType={jobType} + datasetName={datasetDetail?.name} + formInitialValues={stepOneInitialValues} + isFormValueChanged={isFormValueChanged} + onFirstStepSubmit={(value) => setFormConfig(value)} + treeAdvancedFormItemList={treeAdvancedFormItemList} + nnAdvancedFormItemList={nnAdvancedFormItemList} + algorithmProjectList={algorithmProjectList} + peerAlgorithmProjectList={peerAlgorithmProjectList} + datasetBatchType={datasetJob?.time_range?.hours === 1 ? 'hour' : 'day'} + /> + )} + /> + <Route + path={generatePath(routes.ModelTrainJobCreate, { + id: modelGroupId, + type: jobType, + step: 'participant', + })} + exact + render={() => ( + <StepTwoParticipant + modelGroup={modelGroupDetail} + formInitialValues={stepTwoInitialValues} + isFormValueChanged={isFormValueChanged} + stepOneFormConfigValues={formConfig} + onFormValueChange={onFormValueChange} + onSecondStepSubmit={(value) => { + createTrainModelJob(value); + }} + saveStepTwoValues={(value) => setStepTwoFormConfig(value)} + treeAdvancedFormItemList={treeAdvancedFormItemList} + nnAdvancedFormItemList={nnAdvancedFormItemList} + algorithmProjectList={algorithmProjectList} + peerAlgorithmProjectList={peerAlgorithmProjectList} + submitLoading={submitLoading} + /> + )} + /> + </section> + </SharedPageLayout> + ); + + async function createTrainModelJob(value: any) { + setSubmitLoading(true); + if (!projectId) { + return Message.info('请选择工作区!'); + } + + const isTree = isTreeAlgorithm(modelGroupDetail?.algorithm_type as EnumAlgorithmProjectType); + const coordinatorConfigValues = formConfig; + const participantsConfigValues = value; + const metricIsPublic = value.metric_is_public; + const participantsPayload: Record<string, any> = {}; + const participantRole = + modelGroupDetail?.algorithm_type === EnumAlgorithmProjectType.NN_HORIZONTAL + ? TrainRoleType.FEATURE + : coordinatorConfigValues?.role === TrainRoleType.FEATURE + ? TrainRoleType.LABEL + : TrainRoleType.FEATURE; + participantList.forEach((participant: Participant) => { + const curParticipantConfig = participantsConfigValues?.[participant.pure_domain_name]; + participantsPayload[participant.pure_domain_name] = isTree + ? { + variables: hydrateModalGlobalConfig(modelJobDefinition?.variables!, { + role: participantRole, + loss_type: coordinatorConfigValues?.loss_type, + ...curParticipantConfig?.tree_config, + ...curParticipantConfig?.resource_config, + }), + } + : { + algorithm_uuid: curParticipantConfig?.algorithm?.algorithmUuid, + algorithm_parameter: { variables: curParticipantConfig?.algorithm?.config }, + variables: hydrateModalGlobalConfig(modelJobDefinition?.variables!, { + role: participantRole, + ...curParticipantConfig?.nn_config, + ...curParticipantConfig?.resource_config, + }), + }; + }); + try { + const res = await createModelJob(projectId, { + name: `${modelGroupDetail?.name}-v${modelGroupDetail?.latest_version! + 1}`, + comment: coordinatorConfigValues?.comment, + group_id: modelGroupDetail?.id, + model_job_type: EnumModelJobType.TRAINING, + algorithm_type: modelGroupDetail?.algorithm_type as EnumAlgorithmProjectType, + data_batch_id: coordinatorConfigValues?.data_batch_id, + global_config: { + dataset_uuid: datasetDetail?.uuid, + global_config: { + [myPureDomainName]: isTree + ? { + variables: hydrateModalGlobalConfig(modelJobDefinition?.variables!, { + role: coordinatorConfigValues?.role, + loss_type: coordinatorConfigValues?.loss_type, + ...coordinatorConfigValues?.tree_config, + ...coordinatorConfigValues?.resource_config, + }), + } + : { + algorithm_uuid: + coordinatorConfigValues?.[myPureDomainName].algorithm?.algorithmUuid, + algorithm_parameter: { + variables: coordinatorConfigValues?.[myPureDomainName].algorithm?.config, + }, + variables: hydrateModalGlobalConfig(modelJobDefinition?.variables!, { + role: coordinatorConfigValues?.role, + ...coordinatorConfigValues?.nn_config, + ...coordinatorConfigValues?.resource_config, + }), + }, + ...participantsPayload, + }, + }, + }); + metricIsPublic && + (await updateModelJob(projectId, res.data.id, { metric_is_public: metricIsPublic })); + Message.success( + jobType === 'repeat' + ? modelGroupDetail?.auto_update_status === AutoModelJobStatus.STOPPED + ? '配置定时续训任务成功!' + : '创建定时续训任务成功!' + : '创建模型训练任务成功!', + ); + setSubmitLoading(false); + history.push( + generatePath(routes.ModelTrainDetail, { + id: modelGroupDetail?.id, + }), + ); + } catch (error: any) { + Message.error(error.message); + } + } +} + +export default ModelTrainJobCreate; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelTrain/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelTrain/index.tsx new file mode 100644 index 000000000..726a6bf67 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelTrain/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Route } from 'react-router'; +import List from './List'; +import Detail from './Detail'; +import routesMap from '../routes'; + +const ModelTrain: React.FC = () => { + return ( + <> + <Route exact path={routesMap.ModelTrainList} component={List} /> + <Route exact path={routesMap.ModelTrainDetail} component={Detail} /> + </> + ); +}; + +export default ModelTrain; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelFormModal/index.module.less b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelFormModal/index.module.less new file mode 100644 index 000000000..c984639e1 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelFormModal/index.module.less @@ -0,0 +1,4 @@ +.footer_row{ + padding-top: 15px; + border-top: 1px solid var(--backgroundColorGray); +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelFormModal/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelFormModal/index.tsx new file mode 100644 index 000000000..5a9817542 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelFormModal/index.tsx @@ -0,0 +1,95 @@ +import React, { FC, useEffect } from 'react'; + +import { Modal, Form, Input, Button } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import ButtonWithPopconfirm from 'components/ButtonWithPopconfirm'; + +import { validNamePattern, MAX_COMMENT_LENGTH } from 'shared/validator'; + +import styles from './index.module.less'; + +export interface Props<T = any> { + visible: boolean; + isEdit?: boolean; + isLoading?: boolean; + initialValues?: any; + onOk?: (values: T) => void; + onCancel?: () => void; +} + +export interface ModelFormData { + id?: number; + name: string; + comment: string; +} + +const ModelFormModal: FC<Props<ModelFormData>> = ({ + visible, + isEdit = false, + isLoading = false, + onOk, + onCancel, + initialValues, +}) => { + const [formInstance] = Form.useForm<any>(); + + useEffect(() => { + if (visible && isEdit && initialValues && formInstance) { + formInstance.setFieldsValue({ + ...initialValues, + }); + } + }, [visible, isEdit, initialValues, formInstance]); + + return ( + <Modal + title={'编辑模型'} + visible={visible} + maskClosable={false} + afterClose={afterClose} + onCancel={onCancel} + footer={null} + > + <Form layout="vertical" form={formInstance} onSubmit={onOk}> + <Form.Item + field="name" + label={'模型名称'} + rules={[ + { required: true, message: '模型集名称为必填项' }, + { + match: validNamePattern, + message: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ]} + > + <Input disabled={true} /> + </Form.Item> + <Form.Item + field="comment" + label={'模型描述'} + rules={[{ max: MAX_COMMENT_LENGTH, message: '最多为 200 个字符' }]} + > + <Input.TextArea rows={4} placeholder={'最多为 200 个字符'} /> + </Form.Item> + <Form.Item field="id" style={{ display: 'none' }}> + <Input /> + </Form.Item> + <Form.Item wrapperCol={{ span: 24 }} style={{ marginBottom: 0 }}> + <GridRow className={styles.footer_row} justify="end" gap="12"> + <ButtonWithPopconfirm buttonText={'取消'} onConfirm={onCancel} /> + <Button type="primary" htmlType="submit" loading={isLoading}> + {isEdit ? '保存' : '创建'} + </Button> + </GridRow> + </Form.Item> + </Form> + </Modal> + ); + + function afterClose() { + // clear all fields + formInstance.resetFields(); + } +}; + +export default ModelFormModal; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelTable/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelTable/index.tsx new file mode 100644 index 000000000..c1bd02756 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/ModelTable/index.tsx @@ -0,0 +1,180 @@ +import React, { FC } from 'react'; + +import { formatTimestamp } from 'shared/date'; + +import Table from 'components/Table'; +import MoreActions from 'components/MoreActions'; + +import { Model } from 'typings/modelCenter'; +import { generatePath, useHistory } from 'react-router'; +import routes from 'views/ModelCenter/routes'; +import CONSTANTS from 'shared/constants'; +import AlgorithmType from 'components/AlgorithmType'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; + +type ColumnsGetterOptions = { + onDeleteClick?: (model: Model) => void; + onEditClick?: (model: Model) => void; + onModelSourceClick?: (model: Model, to: string) => void; + withoutActions?: boolean; + isOldModelCenter?: boolean; +}; + +export const getTableColumns = (options: ColumnsGetterOptions) => { + const cols = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + width: 200, + // TODO: Click name go to detail page + }, + { + title: '模型类型', + dataIndex: 'algorithm_type', + key: 'algorithm_type', + width: 150, + render: (type: EnumAlgorithmProjectType) => { + return <AlgorithmType type={type} />; + }, + }, + { + title: '模型来源', + dataIndex: 'job_id', + key: 'job_id', + width: 200, + render: (value: any, record: Model) => { + const { + job_id, + model_job_id, + group_id, + workflow_id, + job_name, + model_job_name, + workflow_name, + } = record; + + let to = ''; + let displayText = ''; + if (job_id && workflow_id && job_name && workflow_name) { + to = `/workflow-center/workflows/${workflow_id}`; + displayText = `${workflow_name}工作流-${job_name}任务`; + } + if (model_job_id && group_id && model_job_name) { + to = options.isOldModelCenter + ? `/model-center/model-management/model-set/${group_id}` + : generatePath(routes.ModelTrainDetail, { + id: group_id, + }); + displayText = `${model_job_name}训练任务`; + } + + return ( + <button + className="custom-text-button" + style={{ textAlign: 'left' }} + onClick={() => { + options?.onModelSourceClick?.(record, to); + }} + > + {displayText} + </button> + ); + }, + }, + { + title: '模型描述', + dataIndex: 'comment', + key: 'comment', + width: 200, + render: (comment: string) => comment || CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 150, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + sorter: (a: Model, b: Model) => a.created_at - b.created_at, + }, + ]; + if (!options.withoutActions) { + cols.push({ + title: '操作', + dataIndex: 'operation', + key: 'operation', + fixed: 'right', + width: 100, + render: (_: number, record: Model) => { + return ( + <> + <MoreActions + actionList={[ + { + label: '编辑', + onClick: () => { + options?.onEditClick?.(record); + }, + }, + { + label: '删除', + onClick: () => { + options?.onDeleteClick?.(record); + }, + danger: true, + }, + ]} + /> + </> + ); + }, + } as any); + } + + return cols; +}; + +type Props = { + loading: boolean; + isOldModelCenter: boolean; + dataSource: any[]; + onDeleteClick?: (record: Model) => void; + onEditClick?: (record: Model) => void; + onShowSizeChange?: (current: number, size: number) => void; + onPageChange?: (page: number, pageSize: number) => void; +}; +const ModelTable: FC<Props> = ({ + loading, + isOldModelCenter = false, + dataSource, + onDeleteClick, + onEditClick, + onShowSizeChange, + onPageChange, +}) => { + const history = useHistory(); + return ( + <> + <Table + rowKey="id" + scroll={{ x: '100%' }} + loading={loading} + data={dataSource} + columns={getTableColumns({ + isOldModelCenter, + onDeleteClick, + onEditClick, + onModelSourceClick: (model: Model, to: string) => { + if (to) { + history.push(to); + } + }, + })} + onShowSizeChange={onShowSizeChange} + onPageChange={onPageChange} + /> + </> + ); +}; + +export default ModelTable; diff --git a/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/index.module.less b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/index.module.less new file mode 100644 index 000000000..c0c14a73c --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/index.module.less @@ -0,0 +1,3 @@ +.search_container{ + width: 280px; +} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/index.tsx new file mode 100644 index 000000000..dd1d4342e --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ModelWarehouse/index.tsx @@ -0,0 +1,164 @@ +import React, { FC, useState, useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { useToggle } from 'react-use'; +import { useQuery } from 'react-query'; + +import { fetchModelList, updateModel, deleteModel } from 'services/modelCenter'; + +import { TIME_INTERVAL } from 'shared/constants'; +import { projectState } from 'stores/project'; +import { useUrlState } from 'hooks'; + +import { Input, Message } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import SharedPageLayout from 'components/SharedPageLayout'; +import Modal from 'components/Modal'; +import ModelTable from './ModelTable'; +import ModelFormModal, { ModelFormData } from './ModelFormModal'; + +import { Model, ModelUpdatePayload } from 'typings/modelCenter'; + +import styles from './index.module.less'; + +type Props = { + isOldModelCenter: boolean; +}; + +const Page: FC<Props> = ({ isOldModelCenter = false }) => { + const [selectedData, setSelectedData] = useState<Model>(); + + const [isModelFormModalVisiable, toggleIsModelFormModalVisiable] = useToggle(false); + const [isUpdating, toggleIsUpdating] = useToggle(false); + + const selectedProject = useRecoilValue(projectState); + const [urlState, setUrlState] = useUrlState({ + keyword: '', + }); + + const listQuery = useQuery( + ['fetchModelList', urlState.keyword, selectedProject.current?.id], + () => { + if (!selectedProject.current?.id) { + Message.info('请选择工作区'); + return; + } + return fetchModelList(selectedProject.current?.id, { + keyword: urlState.keyword, + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + }, + ); + + const tableDataSource = useMemo(() => { + if (!listQuery.data) { + return []; + } + let list = listQuery.data.data || []; + + // Filter deleted model + list = list.filter((item) => !item.deleted_at); + + return list; + }, [listQuery.data]); + + return ( + <SharedPageLayout title={'模型仓库'}> + <GridRow justify="end" align="center"> + <Input.Search + className={`${styles.search_container} custom-input`} + allowClear + onSearch={onSearch} + onClear={() => onSearch('')} + placeholder={'输入模型名称'} + defaultValue={urlState.keyword} + /> + </GridRow> + <ModelTable + loading={listQuery.isFetching} + dataSource={tableDataSource} + onDeleteClick={onDeleteClick} + onEditClick={onEditClick} + isOldModelCenter={isOldModelCenter} + /> + <ModelFormModal + visible={isModelFormModalVisiable} + isEdit={true} + isLoading={isUpdating} + onCancel={onModalClose} + onOk={onModalSubmit} + initialValues={selectedData} + /> + </SharedPageLayout> + ); + + function onSearch( + value: string, + event?: + | React.ChangeEvent<HTMLInputElement> + | React.MouseEvent<HTMLElement> + | React.KeyboardEvent<HTMLInputElement>, + ) { + setUrlState((prevState) => ({ + ...prevState, + keyword: value, + page: 1, + })); + } + + function onDeleteClick(record: Model) { + Modal.delete({ + title: '确认要删除该模型吗?', + content: '删除后,不影响正在使用该模型的任务,使用该模型的历史任务不能再正常运行,请谨慎删除', + onOk: async () => { + try { + if (!selectedProject.current?.id) { + Message.info('请选择工作区'); + return; + } + await deleteModel(selectedProject.current?.id, record.id); + listQuery.refetch(); + Message.success('删除成功'); + } catch (error) { + Message.error(error.message); + } + }, + }); + } + + function onEditClick(record: Model) { + setSelectedData(record); + toggleIsModelFormModalVisiable(true); + } + + async function onModalSubmit(value: ModelFormData) { + toggleIsUpdating(true); + + try { + const payload: ModelUpdatePayload = { + comment: value.comment, + }; + if (!selectedProject.current?.id) { + Message.info('请选择工作区'); + return; + } + await updateModel(selectedProject.current?.id, selectedData?.id!, payload); + toggleIsModelFormModalVisiable(false); + listQuery.refetch(); + Message.success('修改成功'); + } catch (error) { + Message.error(error.message); + } finally { + toggleIsUpdating(false); + } + } + + function onModalClose() { + setSelectedData(undefined); + toggleIsModelFormModalVisiable(false); + } +}; + +export default Page; diff --git a/web_console_v2/client/src/views/ModelCenter/ReportResult.module.less b/web_console_v2/client/src/views/ModelCenter/ReportResult.module.less new file mode 100644 index 000000000..58b978323 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ReportResult.module.less @@ -0,0 +1,9 @@ +.space_container{ + margin: 16px 0; + width: 100%; + font-size: 12px; + :global(.arco-space-item:nth-child(3)) { + margin-left: auto; + margin-right: 8px; + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ReportResult.tsx b/web_console_v2/client/src/views/ModelCenter/ReportResult.tsx new file mode 100644 index 000000000..3461bd617 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ReportResult.tsx @@ -0,0 +1,132 @@ +import React, { CSSProperties, FC, useMemo, useState } from 'react'; +import { Grid, Tabs, Space, Typography, Switch, Tooltip, Message } from '@arco-design/web-react'; +import { toString } from 'lodash-es'; +import StatisticList from 'components/StatisticList'; +import ConfusionMatrix from 'components/ConfusionMatrix'; +import FeatureImportance from 'components/FeatureImportance'; +import LineChartWithCard from 'components/LineChartWithCard'; +import { useGetCurrentProjectId, useGetCurrentProjectParticipantList } from 'hooks'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import { updateModelJob } from 'services/modelCenter'; + +import styles from './ReportResult.module.less'; + +type Props = { + id: ID; + algorithmType?: EnumAlgorithmProjectType; + title?: string; + style?: CSSProperties; + isTraining?: boolean; + isNNAlgorithm?: boolean; + hideConfusionMatrix?: boolean; + metricIsPublic?: boolean; + onSwitch?: () => void; +}; + +const ReportResult: FC<Props> = ({ + id, + title, + style, + algorithmType, + isTraining = true, + isNNAlgorithm, + hideConfusionMatrix = false, + metricIsPublic = false, + onSwitch, +}) => { + const stringifyId = toString(id); + + const projectId = toString(useGetCurrentProjectId()); + const participantList = useGetCurrentProjectParticipantList(); + const [switchLoading, setSwitchLoading] = useState(false); + const [resultTarget, setResultTarget] = useState<string>(stringifyId); + const participantId = useMemo(() => { + return resultTarget === stringifyId ? undefined : resultTarget; + }, [resultTarget, stringifyId]); + const handleOnChangeIsPublic = (checked: boolean) => { + setSwitchLoading(true); + updateModelJob(projectId, stringifyId, { + metric_is_public: checked, + }).then( + (res) => { + Message.success('编辑成功'); + setSwitchLoading(false); + onSwitch && onSwitch(); + }, + (err) => { + Message.error(err.message); + setSwitchLoading(false); + }, + ); + }; + + return ( + <div style={style}> + <Space className={styles.space_container}> + <Typography.Text className="custom-typography" bold={true}> + {title ?? '评估报告'} + </Typography.Text> + <Tabs + className="custom-tabs" + type="text" + activeTab={resultTarget} + onChange={setResultTarget} + > + <Tabs.TabPane title="本方" key={stringifyId} /> + {(isTraining || algorithmType !== EnumAlgorithmProjectType.NN_HORIZONTAL) && + (participantList ?? []).map((peer: any) => ( + <Tabs.TabPane title={peer.name} key={toString(peer.id)} /> + ))} + </Tabs> + {(isTraining || algorithmType !== EnumAlgorithmProjectType.NN_HORIZONTAL) && ( + <Space> + 共享训练报告 + <Tooltip content={'开启后,将与合作伙伴共享本次训练指标'}> + <IconInfoCircle /> + </Tooltip> + <Switch + loading={switchLoading} + checked={metricIsPublic} + onChange={handleOnChangeIsPublic} + /> + </Space> + )} + </Space> + <Space direction="vertical" style={{ width: '100%' }}> + <StatisticList.ModelEvaluation + id={stringifyId} + participantId={participantId} + isTraining={isTraining} + /> + {isNNAlgorithm ? ( + <LineChartWithCard.ModelMetrics + id={stringifyId} + participantId={participantId} + isTraining={isTraining} + /> + ) : ( + <Grid.Row gutter={20}> + {!hideConfusionMatrix && ( + <Grid.Col span={12}> + <ConfusionMatrix.ModelEvaluationVariant + id={stringifyId} + participantId={participantId} + /> + </Grid.Col> + )} + <Grid.Col span={hideConfusionMatrix ? 24 : 12}> + <FeatureImportance.ModelEvaluationVariant + tip={'数值越高,表示该特征对模型的影响越大'} + id={stringifyId} + participantId={participantId} + /> + </Grid.Col> + </Grid.Row> + )} + </Space> + </div> + ); +}; + +export default ReportResult; diff --git a/web_console_v2/client/src/views/ModelCenter/ResourceConfigTable.module.less b/web_console_v2/client/src/views/ModelCenter/ResourceConfigTable.module.less new file mode 100644 index 000000000..db9c58e22 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ResourceConfigTable.module.less @@ -0,0 +1,5 @@ +.table_container{ + :global(.arco-table-td){ + border-bottom: none; + } +} diff --git a/web_console_v2/client/src/views/ModelCenter/ResourceConfigTable.tsx b/web_console_v2/client/src/views/ModelCenter/ResourceConfigTable.tsx new file mode 100644 index 000000000..e7b7384c9 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/ResourceConfigTable.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { CONSTANTS } from 'shared/constants'; +import { ModelJob } from 'typings/modelCenter'; +import { Tag, Popover, PopoverProps, TableColumnProps, Table } from '@arco-design/web-react'; + +import styles from './ResourceConfigTable.module.less'; + +const columns: TableColumnProps[] = [ + { + title: '', + dataIndex: 'type', + render(value: string) { + return <Tag>{value.toUpperCase()}</Tag>; + }, + }, + { + title: '', + dataIndex: 'cpu', + }, + { + title: '', + dataIndex: 'mem', + }, + { + title: '', + dataIndex: 'replicas', + }, +]; + +function genText(field: string, value: number) { + if (/cpu$/i.test(field)) { + return `${Math.floor(value / 1000)} Core`; + } + if (/mem$/i.test(field)) { + return `${value} GiB`; + } + if (/replicas$/i.test(field)) { + return `${value} 实例数`; + } +} + +type ResourceConfigTableProps = { + job: ModelJob; +}; + +type ResourceConfigTableButtonProps = ResourceConfigTableProps & { + btnText?: string; + popoverProps?: PopoverProps; +}; + +const ResourceConfigTable: React.FC<ResourceConfigTableProps> & { + Button: React.FC<ResourceConfigTableButtonProps>; +} = ({ job }) => { + const { config } = job; + + if (!config) { + return null; + } + + const { job_definitions } = config; + const { variables } = job_definitions[0]; + const group: any = {}; + + for (const item of variables) { + if (!/cpu|mem|replica/i.test(item.name)) { + continue; + } + + const [type, prop] = item.name.split('_'); + if (!group[type]) { + group[type] = {}; + } + + const numericVal = parseInt(item.value); + + if (numericVal > 0) { + group[type][prop] = genText(item.name, numericVal); + } + } + const tableData = ['master', 'ps', 'worker'] + .filter((type) => group[type] != null) + .map((type) => { + return { + type, + cpu: CONSTANTS.EMPTY_PLACEHOLDER, + mem: CONSTANTS.EMPTY_PLACEHOLDER, + replicas: CONSTANTS.EMPTY_PLACEHOLDER, + ...group[type], + }; + }); + + return ( + <Table + rowKey="type" + className={`${styles.table_container} custom-table`} + border={false} + showHeader={false} + size="small" + columns={columns} + data={tableData} + pagination={false} + /> + ); +}; + +const PopoverButton: React.FC<ResourceConfigTableButtonProps> = ({ + job, + btnText = '查看', + popoverProps = {}, +}) => { + return ( + <Popover + {...popoverProps} + content={<ResourceConfigTable job={job} />} + getPopupContainer={() => document.body} + > + <button className="custom-text-button">{btnText}</button> + </Popover> + ); +}; + +ResourceConfigTable.Button = PopoverButton; + +export default ResourceConfigTable; diff --git a/web_console_v2/client/src/views/ModelCenter/TrainJobCompareModal.tsx b/web_console_v2/client/src/views/ModelCenter/TrainJobCompareModal.tsx new file mode 100644 index 000000000..1b27b4148 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/TrainJobCompareModal.tsx @@ -0,0 +1,183 @@ +import React, { useMemo, useState } from 'react'; +import { Modal, Table, TableColumnProps } from '@arco-design/web-react'; +import { ModelJob } from 'typings/modelCenter'; +import { useBatchModelJobMetricsAndConfig } from 'hooks/modelCenter'; +import CONSTANTS from 'shared/constants'; +import { + LABEL_MAPPER, + NOT_NN_ADVANCE_CONFIG_FIELD_LIST, + NOT_TREE_ADVANCE_CONFIG_FIELD_LIST, +} from './shared'; +import { isNNAlgorithm, isTreeAlgorithm } from 'views/ModelCenter/shared'; +import { Variable } from 'typings/variable'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; + +const metricRenderFunc = (val: string) => val ?? CONSTANTS.EMPTY_PLACEHOLDER; +const columns: TableColumnProps[] = [ + { + title: '名称', + dataIndex: 'id', + width: 200, + }, + { + title: '运行参数', + dataIndex: 'config', + width: 300, + render(conf) { + return <div style={{ whiteSpace: 'pre-wrap' }}>{conf}</div>; + }, + }, +]; + +const treeColumns = [ + { + title: 'ACC', + dataIndex: 'acc', + width: 80, + render: metricRenderFunc, + }, + { + title: 'AUC', + dataIndex: 'auc', + width: 80, + render: metricRenderFunc, + }, + { + key: 'precision', + title: 'PRECISION', + dataIndex: 'precision', + width: 80, + render: metricRenderFunc, + }, + { + key: 'recall', + title: 'RECALL', + dataIndex: 'recall', + width: 80, + render: metricRenderFunc, + }, + { + key: 'f1', + title: 'F1', + dataIndex: 'f1', + width: 80, + render: metricRenderFunc, + }, + { + key: 'ks', + title: 'KS', + dataIndex: 'ks', + width: 80, + render: metricRenderFunc, + }, +]; + +const nnColumns = [ + { + title: 'AUC', + dataIndex: 'auc', + width: 80, + render: metricRenderFunc, + }, + { + title: 'Log Loss', + dataIndex: 'loss', + watch: 80, + render: metricRenderFunc, + }, +]; + +type Props = { + visible: boolean; + list: ModelJob[]; + algorithmType: EnumAlgorithmProjectType; + isTraining?: boolean; + onCancel?: () => void; +}; + +const TrainJobCompareModal: React.FC<Props> & { Button: React.FC<any> } = ({ + visible, + list, + isTraining = true, + onCancel, + algorithmType, +}) => { + const { dataList, isLoading } = useBatchModelJobMetricsAndConfig(list, visible); + const finalColumns = useMemo(() => { + const metricColumns = + algorithmType === EnumAlgorithmProjectType.NN_HORIZONTAL || + algorithmType === EnumAlgorithmProjectType.NN_VERTICAL + ? nnColumns + : treeColumns; + return [...columns, ...metricColumns]; + }, [algorithmType]); + const formattedList = useMemo(() => { + return dataList.map((item) => { + const metric = (isTraining ? item.metric.train : item.metric.eval) ?? {}; + const variables = item.config ?? []; + for (const k in metric) { + const { values = [] } = metric[k] || {}; + const numberValue = values[values.length - 1]; + + if (isNaN(numberValue)) { + metric[k] = CONSTANTS.EMPTY_PLACEHOLDER; + continue; + } + metric[k] = numberValue.toFixed(3); + } + + return { + id: item.id, + config: variables + .filter((v: Variable) => + (isNNAlgorithm(item.job.algorithm_type) + ? NOT_NN_ADVANCE_CONFIG_FIELD_LIST + : isTreeAlgorithm(item.job.algorithm_type) + ? NOT_TREE_ADVANCE_CONFIG_FIELD_LIST + : [] + ).includes(v.name), + ) + .map((v: Variable) => [LABEL_MAPPER[v.name] ?? v.name, v.value].join('=')) + .join('\n'), + ...metric, + }; + }); + }, [dataList, isTraining]); + + return ( + <Modal + visible={visible} + title={'训练任务对比'} + footer={null} + style={{ width: 1000 }} + onCancel={onCancel} + > + <Table + loading={isLoading} + rowKey="id" + columns={finalColumns} + data={formattedList} + pagination={false} + /> + </Modal> + ); +}; + +const Button: React.FC<{ btnText?: string } & Omit<Props, 'visible'>> = ({ + btnText = '对比', + ...modalProps +}) => { + const [visible, setVisible] = useState(false); + return ( + <> + <button className="custom-text-button" onClick={() => setVisible(!visible)}> + {btnText} + </button> + <TrainJobCompareModal visible={visible} {...modalProps} onCancel={() => setVisible(false)} /> + </> + ); +}; + +TrainJobCompareModal.Button = Button; + +export default TrainJobCompareModal; diff --git a/web_console_v2/client/src/views/ModelCenter/index.less b/web_console_v2/client/src/views/ModelCenter/index.less new file mode 100644 index 000000000..26817b410 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/index.less @@ -0,0 +1,39 @@ +.drawer-content{ + flex:1; + .header { + display: flex; + justify-content: space-between; + align-items: center; + } +} +.params-popover-padding{ + width: 600px; + max-width: 600px !important; + .arco-popover-content { + padding-left: 0px; + padding-right: 0px; + } +} +.left-container { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + margin-top: 24px; + .right-button{ + margin-left: auto; + } +} +.pop-title{ + color: #4e5969; +} +.pop-content{ + color: #1d2129; +} +.styled-link{ + display: inline-block; + font-weight: 400; + font-size: 12px; + line-height: 20px; + margin-bottom: 12px; +} diff --git a/web_console_v2/client/src/views/ModelCenter/index.tsx b/web_console_v2/client/src/views/ModelCenter/index.tsx new file mode 100644 index 000000000..bbbd780e8 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Route, RouteProps } from 'react-router'; + +import ModelTrain from './ModelTrain'; +import ModelWarehouse from './ModelWarehouse'; +import ModelEvaluation from './ModelEvaluation'; +import routesMap from './routes'; + +const routes: Array<RouteProps> = [ + { + path: routesMap.ModelTrain, + component: ModelTrain, + }, + { + path: routesMap.ModelWarehouse, + component: ModelWarehouse, + }, + { + path: routesMap.ModelEvaluation, + component: ModelEvaluation, + }, +]; + +const Index: React.FC = () => { + return ( + <> + {routes.map((r) => ( + <Route key={r.path as string} path={r.path} component={r.component} /> + ))} + </> + ); +}; + +export default Index; diff --git a/web_console_v2/client/src/views/ModelCenter/routes.tsx b/web_console_v2/client/src/views/ModelCenter/routes.tsx new file mode 100644 index 000000000..8cf392cea --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/routes.tsx @@ -0,0 +1,62 @@ +const INDEX_PATH = '/model-center'; +const ModelTrain = `${INDEX_PATH}/model-train`; +const ModelEvaluation = `${INDEX_PATH}/:module(model-evaluation|offline-prediction)`; +const OfflinePrediction = ModelEvaluation; + +const routes: Record<string, string> = { + ModelTrain, + ModelTrainList: `${ModelTrain}/list`, + ModelTrainCreate: `${ModelTrain}/:role(receiver|sender)/:action(create|edit)/:id?`, + ModelTrainDetail: `${ModelTrain}/detail/:id`, + ModelTrainJobCreate: `${ModelTrain}/model-train-job/:type/:id/:step`, + ModelTrainCreateCentralization: `${ModelTrain}/:role(receiver|sender)/create-centralization/:id?`, + + ModelWarehouse: `${INDEX_PATH}/model-warehouse`, + + ModelEvaluation, + ModelEvaluationList: `${ModelEvaluation}/list`, + ModelEvaluationCreate: `${ModelEvaluation}/:role(receiver|sender)/:action(create|edit)/:id?`, + ModelEvaluationDetail: `${ModelEvaluation}/detail/:id/:tab(result|info)?`, + + OfflinePrediction, + OfflinePredictionList: `${OfflinePrediction}/list`, + OfflinePredictionCreate: `${OfflinePrediction}/:role(receiver|sender)/:action(create|edit)/:id?`, + OfflinePredictionDetail: `${OfflinePrediction}/detail/:id/:tab(result|info)?`, +}; + +export default routes; + +export enum ModelEvaluationModuleType { + Evaluation = 'model-evaluation', + Prediction = 'offline-prediction', +} + +export enum ModelEvaluationCreateRole { + Receiver = 'receiver', + Sender = 'sender', +} + +export enum ModelEvaluationCreateAction { + Create = 'create', + Edit = 'edit', +} + +export enum ModelEvaluationDetailTab { + Result = 'result', + Info = 'info', +} + +export interface ModelEvaluationListParams { + module: ModelEvaluationModuleType; +} + +export interface ModelEvaluationCreateParams extends ModelEvaluationListParams { + role: ModelEvaluationCreateRole; + action: ModelEvaluationCreateAction; + id: string; +} + +export interface ModelEvaluationDetailParams extends ModelEvaluationListParams { + tab: ModelEvaluationDetailTab; + id: string; +} diff --git a/web_console_v2/client/src/views/ModelCenter/shared.module.less b/web_console_v2/client/src/views/ModelCenter/shared.module.less new file mode 100644 index 000000000..7bd9c3485 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/shared.module.less @@ -0,0 +1,22 @@ +@import '~styles/mixins.less'; +.avatar_container{ + .MixinSquare(44px); + background-color: var(--primary-1); + color: white; + border-radius: 4px; + font-size: 18px; + text-align: center; + &::before { + display: inline-block; + width: 100%; + height: 100%; + content: ''; + background-image: url('../../assets/icons/atom-icon-algorithm-management.svg'); + background-repeat: no-repeat; + background-size: contain; + } +} +.plus_icon{ + margin-right: 4px; + vertical-align: 0.03em !important; +} diff --git a/web_console_v2/client/src/views/ModelCenter/shared.test.ts b/web_console_v2/client/src/views/ModelCenter/shared.test.ts new file mode 100644 index 000000000..4c6e123e5 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/shared.test.ts @@ -0,0 +1,735 @@ +import { + getAdvanceConfigList, + hydrateWorkflowConfig, + getDataSource, + isTreeAlgorithm, + isNNAlgorithm, + isOldAlgorithm, + isHorizontalAlgorithm, + isVerticalAlgorithm, +} from './shared'; + +import { JobType } from 'typings/job'; +import { VariableAccessMode, VariableValueType } from 'typings/variable'; +import { WorkflowConfig } from 'typings/workflow'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { AlgorithmType } from 'typings/modelCenter'; + +const TREE_TEMPLATE_CONFIG: WorkflowConfig = { + group_alias: 'sys_preset_tree_model', + job_definitions: [ + { + name: 'tree-model', + job_type: JobType.TREE_MODEL_TRAINING, + is_federated: true, + variables: [ + { + name: 'image', + value: 'artifact.bytedance.com/fedlearner/fedlearner:d5d0bb5', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true,"tooltip":"建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用"}' as any, + typed_value: 'artifact.bytedance.com/fedlearner/fedlearner:d5d0bb5', + value_type: VariableValueType.STRING, + }, + { + name: 'mode', + value: 'train', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":true,"enum":["train","eval"]}' as any, + typed_value: 'train', + value_type: VariableValueType.STRING, + }, + { + name: 'data_source', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"求交数据集名称"}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'data_path', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"数据存放位置"}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'validation_data_path', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'file_ext', + value: '.data', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true,"tooltip":"example: .data, .csv or .tfrecord 文件后缀"}' as any, + typed_value: '.data', + value_type: VariableValueType.STRING, + }, + { + name: 'file_type', + value: 'tfrecord', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":true,"enum":["csv","tfrecord"],"tooltip":"文件类型,csv或tfrecord"}' as any, + typed_value: 'tfrecord', + value_type: VariableValueType.STRING, + }, + { + name: 'load_model_path', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"模型文件地址"}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'loss_type', + value: 'logistic', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":false,"enum":["logistic","mse"],"tooltip":"损失函数类型,logistic或mse,默认logistic"}' as any, + typed_value: 'logistic', + value_type: VariableValueType.STRING, + }, + { + name: 'learning_rate', + value: '0.3', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '0.3', + value_type: VariableValueType.STRING, + }, + { + name: 'max_iters', + value: '10', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"树的数量"}' as any, + typed_value: '10', + value_type: VariableValueType.STRING, + }, + { + name: 'max_depth', + value: '5', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '5', + value_type: VariableValueType.STRING, + }, + { + name: 'max_bins', + value: '33', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"最大分箱数"}' as any, + typed_value: '33', + value_type: VariableValueType.STRING, + }, + { + name: 'l2_regularization', + value: '1', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"L2惩罚系数"}' as any, + typed_value: '1', + value_type: VariableValueType.STRING, + }, + { + name: 'num_parallel', + value: '5', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"进程数量"}' as any, + typed_value: '5', + value_type: VariableValueType.STRING, + }, + { + name: 'enable_packing', + value: 'true', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":false,"enum":["true","false"],"tooltip":"是否开启优化"}' as any, + typed_value: 'true', + value_type: VariableValueType.STRING, + }, + { + name: 'ignore_fields', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"不入模特征,以逗号分隔如:name,age,sex"}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'cat_fields', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"类别类型特征,特征的值需要是非负整数。以逗号分隔如:alive,country,sex"}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'send_scores_to_follower', + value: 'false', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":false,"enum":["false","true"]}' as any, + typed_value: 'false', + value_type: VariableValueType.STRING, + }, + { + name: 'send_metrics_to_follower', + value: 'false', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":false,"enum":["false","true"]}' as any, + typed_value: 'false', + value_type: VariableValueType.STRING, + }, + { + name: 'verify_example_ids', + value: 'false', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: + '{"component":"Select","required":false,"tooltip":"是否检查example_id对齐 If set to true, the first column of the data will be treated as example ids that must match between leader and follower","enum":["false","true"]}', + typed_value: 'false', + value_type: VariableValueType.STRING, + }, + { + name: 'verbosity', + value: '1', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: + '{"component":"Select","required":false,"enum":["0","1","2"],"tooltip":"日志输出等级"}', + typed_value: '1', + value_type: VariableValueType.STRING, + }, + { + name: 'no_data', + value: 'false', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: + '{"component":"Select","required":false,"tooltip":"Leader是否没数据,不建议乱用","enum":["false","true"]}', + typed_value: 'false', + value_type: VariableValueType.STRING, + }, + { + name: 'worker_cpu', + value: '8000m', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '8000m', + value_type: VariableValueType.STRING, + }, + { + name: 'worker_mem', + value: '16Gi', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '16Gi', + value_type: VariableValueType.STRING, + }, + { + name: 'role', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":true,"enum":["Leader","Follower"]}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'label_field', + value: 'label', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"label特征名"}' as any, + typed_value: 'label', + value_type: VariableValueType.STRING, + }, + { + name: 'load_model_name', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"按任务名称加载模型,{STORAGE_ROOT_PATH}/job_output/{LOAD_MODEL_NAME}/exported_models"}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + ], + yaml_template: '', + easy_mode: true, + dependencies: [], + }, + ], + variables: [], +}; + +const NN_TEMPLATE_CONFIG: WorkflowConfig = { + group_alias: 'sys_preset_nn_model', + variables: [ + { + name: 'image', + value: 'artifact.bytedance.com/fedlearner/fedlearner:21d2ae4', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: 'artifact.bytedance.com/fedlearner/fedlearner:21d2ae4', + value_type: VariableValueType.STRING, + }, + ], + job_definitions: [ + { + name: 'nn-model', + job_type: JobType.NN_MODEL_TRANINING, + is_federated: true, + variables: [ + { + name: 'master_cpu', + value: '3000m', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '3000m', + value_type: VariableValueType.STRING, + }, + { + name: 'master_mem', + value: '4Gi', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '4Gi', + value_type: VariableValueType.STRING, + }, + { + name: 'worker_cpu', + value: '2000m', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '2000m', + value_type: VariableValueType.STRING, + }, + { + name: 'worker_mem', + value: '4Gi', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '4Gi', + value_type: VariableValueType.STRING, + }, + { + name: 'ps_replicas', + value: '1', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '1', + value_type: VariableValueType.STRING, + }, + { + name: 'master_replicas', + value: '1', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '1', + value_type: VariableValueType.STRING, + }, + { + name: 'ps_cpu', + value: '2000m', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '2000m', + value_type: VariableValueType.STRING, + }, + { + name: 'ps_mem', + value: '4Gi', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '4Gi', + value_type: VariableValueType.STRING, + }, + { + name: 'worker_replicas', + value: '1', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true}' as any, + typed_value: '1', + value_type: VariableValueType.STRING, + }, + { + name: 'data_source', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'epoch_num', + value: '1', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '1', + value_type: VariableValueType.STRING, + }, + { + name: 'shuffle_data_block', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'verbosity', + value: '1', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":false,"enum":["0","1","2"]}' as any, + typed_value: '1', + value_type: VariableValueType.STRING, + }, + { + name: 'mode', + value: 'train', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":true,"enum":["train","eval"]}' as any, + typed_value: 'train', + value_type: VariableValueType.STRING, + }, + { + name: 'save_checkpoint_secs', + value: '600', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '600', + value_type: VariableValueType.STRING, + }, + { + name: 'save_checkpoint_steps', + value: '1000', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '1000', + value_type: VariableValueType.STRING, + }, + { + name: 'load_checkpoint_filename', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'load_checkpoint_filename_with_path', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'sparse_estimator', + value: 'True', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: 'True', + value_type: VariableValueType.STRING, + }, + { + name: 'role', + value: 'Leader', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":true,"enum":["Leader","Follower"]}' as any, + typed_value: 'Leader', + value_type: VariableValueType.STRING, + }, + { + name: 'load_model_name', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false}' as any, + typed_value: '', + value: '', + value_type: VariableValueType.STRING, + }, + { + name: 'algorithm', + value: '{"config":[],"path":""}', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"AlgorithmSelect","required":true}' as any, + value_type: VariableValueType.OBJECT, + typed_value: { + config: [], + path: '', + }, + }, + ], + yaml_template: '', + easy_mode: true, + dependencies: [], + }, + ], +}; + +it('getAdvanceConfigList', () => { + expect(getAdvanceConfigList(TREE_TEMPLATE_CONFIG)).toEqual([ + { + label: '镜像', + field: 'image', + initialValue: 'artifact.bytedance.com/fedlearner/fedlearner:d5d0bb5', + tip: expect.any(String), + }, + { + field: 'data_source', + initialValue: '', + label: '数据源', + tip: expect.any(String), + }, + { + label: '数据源', + field: 'data_path', + initialValue: '', + tip: expect.any(String), + }, + { + label: '验证数据集地址', + field: 'validation_data_path', + initialValue: '', + tip: undefined, + }, + { + label: '文件扩展名', + field: 'file_ext', + initialValue: '.data', + tip: expect.any(String), + }, + { + label: '文件类型', + field: 'file_type', + initialValue: 'tfrecord', + tip: expect.any(String), + }, + { + label: '加载模型路径', + field: 'load_model_path', + initialValue: '', + tip: expect.any(String), + }, + { + label: '是否优化', + field: 'enable_packing', + initialValue: 'true', + tip: expect.any(String), + }, + { + label: '忽略字段', + field: 'ignore_fields', + initialValue: '', + tip: expect.any(String), + }, + { + label: '类型变量字段', + field: 'cat_fields', + initialValue: '', + tip: expect.any(String), + }, + { + label: '是否将预测值发送至 follower', + field: 'send_scores_to_follower', + initialValue: 'false', + tip: expect.any(String), + }, + { + label: '是否将指标发送至 follower', + field: 'send_metrics_to_follower', + initialValue: 'false', + tip: expect.any(String), + }, + { + label: '是否检验 example_ids', + field: 'verify_example_ids', + initialValue: 'false', + tip: expect.any(String), + }, + { + label: '日志输出等级', + field: 'verbosity', + initialValue: '1', + tip: expect.any(String), + }, + { + label: '标签方是否无特征', + field: 'no_data', + initialValue: 'false', + tip: expect.any(String), + }, + { + label: '标签字段', + field: 'label_field', + initialValue: 'label', + tip: expect.any(String), + }, + { + label: '加载模型名称', + field: 'load_model_name', + initialValue: '', + tip: expect.any(String), + }, + ]); + expect(getAdvanceConfigList(NN_TEMPLATE_CONFIG, true)).toEqual([ + { + label: '镜像', + field: 'image', + initialValue: 'artifact.bytedance.com/fedlearner/fedlearner:21d2ae4', + tip: undefined, + }, + { + field: 'data_source', + initialValue: '', + label: '数据源', + tip: undefined, + }, + { + label: '是否打乱顺序', + field: 'shuffle_data_block', + initialValue: '', + tip: expect.any(String), + }, + { + label: '保存备份间隔秒数', + field: 'save_checkpoint_secs', + initialValue: '600', + tip: expect.any(String), + }, + { + label: '保存备份间隔步数', + field: 'save_checkpoint_steps', + initialValue: '1000', + tip: expect.any(String), + }, + { + label: '加载文件名', + field: 'load_checkpoint_filename', + initialValue: '', + tip: expect.any(String), + }, + { + label: '加载文件路径', + field: 'load_checkpoint_filename_with_path', + initialValue: '', + tip: expect.any(String), + }, + { + field: 'sparse_estimator', + initialValue: 'True', + tip: expect.any(String), + label: 'sparse_estimator', + }, + { + label: '加载模型名称', + field: 'load_model_name', + initialValue: '', + tip: expect.any(String), + }, + ]); +}); + +it('hydrateWorkflowConfig', () => { + expect(hydrateWorkflowConfig(TREE_TEMPLATE_CONFIG, {})).toEqual(TREE_TEMPLATE_CONFIG); + expect( + hydrateWorkflowConfig(TREE_TEMPLATE_CONFIG, { + image: '1', + mode: '2', + data_source: '3', + }), + ).toEqual({ + ...TREE_TEMPLATE_CONFIG, + job_definitions: [ + { + ...TREE_TEMPLATE_CONFIG.job_definitions[0], + variables: [ + { + name: 'image', + value: '1', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":true,"tooltip":"建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用"}' as any, + typed_value: 'artifact.bytedance.com/fedlearner/fedlearner:d5d0bb5', + value_type: VariableValueType.STRING, + }, + { + name: 'mode', + value: '2', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Select","required":true,"enum":["train","eval"]}' as any, + typed_value: 'train', + value_type: VariableValueType.STRING, + }, + { + name: 'data_source', + access_mode: VariableAccessMode.PEER_WRITABLE, + widget_schema: '{"component":"Input","required":false,"tooltip":"求交数据集名称"}' as any, + typed_value: '', + value: '3', + value_type: VariableValueType.STRING, + }, + ].concat(TREE_TEMPLATE_CONFIG.job_definitions[0].variables.slice(3) as any), + }, + ], + }); +}); + +it('getDataSource', () => { + expect(getDataSource('')).toBe(''); + expect(getDataSource('adasdsadsadsadsad')).toBe(''); + expect(getDataSource('data_source')).toBe(''); + expect(getDataSource('data_source/')).toBe(''); + expect(getDataSource('data_source/abc')).toBe(''); + expect(getDataSource('/data_source')).toBe(''); + expect(getDataSource('/data_source/')).toBe(''); + expect(getDataSource('/data_source/abc')).toBe('abc'); + expect( + getDataSource( + 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/u0bae4aa7dcde477e8ee-psi-data-join-job', + ), + ).toBe('u0bae4aa7dcde477e8ee-psi-data-join-job'); +}); + +it('isTreeAlgorithm', () => { + expect(isTreeAlgorithm(AlgorithmType.TREE)).toBe(true); + expect(isTreeAlgorithm(AlgorithmType.NN)).toBe(false); + expect(isTreeAlgorithm(EnumAlgorithmProjectType.TREE_VERTICAL)).toBe(true); + expect(isTreeAlgorithm(EnumAlgorithmProjectType.TREE_HORIZONTAL)).toBe(true); + expect(isTreeAlgorithm(EnumAlgorithmProjectType.NN_VERTICAL)).toBe(false); + expect(isTreeAlgorithm(EnumAlgorithmProjectType.NN_HORIZONTAL)).toBe(false); + expect(isTreeAlgorithm(EnumAlgorithmProjectType.NN_LOCAL)).toBe(false); + expect(isTreeAlgorithm(EnumAlgorithmProjectType.UNSPECIFIED)).toBe(false); +}); +it('isNNAlgorithm', () => { + expect(isNNAlgorithm(AlgorithmType.TREE)).toBe(false); + expect(isNNAlgorithm(AlgorithmType.NN)).toBe(true); + expect(isNNAlgorithm(EnumAlgorithmProjectType.TREE_VERTICAL)).toBe(false); + expect(isNNAlgorithm(EnumAlgorithmProjectType.TREE_HORIZONTAL)).toBe(false); + expect(isNNAlgorithm(EnumAlgorithmProjectType.NN_VERTICAL)).toBe(true); + expect(isNNAlgorithm(EnumAlgorithmProjectType.NN_HORIZONTAL)).toBe(true); + expect(isNNAlgorithm(EnumAlgorithmProjectType.NN_LOCAL)).toBe(true); + expect(isNNAlgorithm(EnumAlgorithmProjectType.UNSPECIFIED)).toBe(false); +}); +it('isOldAlgorithm', () => { + expect(isOldAlgorithm(AlgorithmType.TREE)).toBe(true); + expect(isOldAlgorithm(AlgorithmType.NN)).toBe(true); + expect(isOldAlgorithm(EnumAlgorithmProjectType.TREE_VERTICAL)).toBe(false); + expect(isOldAlgorithm(EnumAlgorithmProjectType.TREE_HORIZONTAL)).toBe(false); + expect(isOldAlgorithm(EnumAlgorithmProjectType.NN_VERTICAL)).toBe(false); + expect(isOldAlgorithm(EnumAlgorithmProjectType.NN_HORIZONTAL)).toBe(false); + expect(isOldAlgorithm(EnumAlgorithmProjectType.NN_LOCAL)).toBe(false); + expect(isOldAlgorithm(EnumAlgorithmProjectType.UNSPECIFIED)).toBe(false); +}); +it('isVerticalAlgorithm', () => { + expect(isVerticalAlgorithm(EnumAlgorithmProjectType.TREE_VERTICAL)).toBe(true); + expect(isVerticalAlgorithm(EnumAlgorithmProjectType.TREE_HORIZONTAL)).toBe(false); + expect(isVerticalAlgorithm(EnumAlgorithmProjectType.NN_VERTICAL)).toBe(true); + expect(isVerticalAlgorithm(EnumAlgorithmProjectType.NN_HORIZONTAL)).toBe(false); + expect(isVerticalAlgorithm(EnumAlgorithmProjectType.NN_LOCAL)).toBe(false); + expect(isVerticalAlgorithm(EnumAlgorithmProjectType.UNSPECIFIED)).toBe(false); +}); +it('isHorizontalAlgorithm', () => { + expect(isHorizontalAlgorithm(EnumAlgorithmProjectType.TREE_VERTICAL)).toBe(false); + expect(isHorizontalAlgorithm(EnumAlgorithmProjectType.TREE_HORIZONTAL)).toBe(true); + expect(isHorizontalAlgorithm(EnumAlgorithmProjectType.NN_VERTICAL)).toBe(false); + expect(isHorizontalAlgorithm(EnumAlgorithmProjectType.NN_HORIZONTAL)).toBe(true); + expect(isHorizontalAlgorithm(EnumAlgorithmProjectType.NN_LOCAL)).toBe(false); + expect(isHorizontalAlgorithm(EnumAlgorithmProjectType.UNSPECIFIED)).toBe(false); +}); diff --git a/web_console_v2/client/src/views/ModelCenter/shared.tsx b/web_console_v2/client/src/views/ModelCenter/shared.tsx new file mode 100644 index 000000000..eee9dba19 --- /dev/null +++ b/web_console_v2/client/src/views/ModelCenter/shared.tsx @@ -0,0 +1,976 @@ +import React from 'react'; +import { cloneDeep, flattenDeep } from 'lodash-es'; + +import { workflowStateFilterParamToStateTextMap } from 'shared/workflow'; + +import { Message, Modal, TableColumnProps } from '@arco-design/web-react'; +import { ActionItem, StateTypes } from 'components/StateIndicator'; +import { WorkflowConfig, WorkflowState, WorkflowStateFilterParam, Tag } from 'typings/workflow'; +import { Variable, VariableWidgetSchema } from 'typings/variable'; +import { ItemProps } from 'components/ConfigForm'; +import { PlusBold } from 'components/IconPark'; +import { FilterOp } from 'typings/filter'; + +import { + FederalType, + ModelJobGroup, + ModelJobRole, + TrainRoleType, + ModelJob, + AlgorithmType, + LossType, + ModelJobVariable, + ModelGroupStatus, + ModelJobStatus, +} from 'typings/modelCenter'; + +import { ModelEvaluationModuleType } from './routes'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { VariableComponent } from 'typings/variable'; +import { processVariableTypedValue, stringifyVariableValue } from 'shared/formSchema'; +import { Participant } from 'typings/participant'; +import { deleteJob_new } from 'services/modelCenter'; + +import styles from './shared.module.less'; + +enum FiltersFields { + ALGORITHM_TYPE = 'algorithm_type', + STATUS = 'status', + ROLE = 'role', +} + +const TIP_MAPPER: Record<string, string> = { + image_version: '镜像版本', + learning_rate: '使用损失函数的梯度调整网络权重的超参数,​ 推荐区间(0.01-1]', + enable_packing: '提高计算效率,true 为优化,false 为不优化。', + ignore_fields: '不参与训练的字段', + cat_fields: '类别变量字段,训练中会特别处理', + send_scores_to_follower: '是否将预测值发送至follower侧,fasle代表否,ture代表是', + send_metrics_to_follower: '是否将指标发送至follower侧,fasle代表否,ture代表是', + verify_example_ids: + '是否检验example_ids,一般情况下训练数据有example_ids,fasle代表否,ture代表是', + no_data: '针对标签方没有特征的预测场景,fasle代表有特征,ture代表无特征。', + label_field: '用于指定label', + load_model_name: '评估和预测时,根据用户选择的模型,确定该字段的值。', + shuffle_data_block: '打乱数据顺序,增加随机性,提高模型泛化能力', + save_checkpoint_secs: '模型多少秒保存一次', + save_checkpoint_steps: '模型多少step保存一次', + load_checkpoint_filename: '加载文件名,用于评估和预测时选择模型', + load_checkpoint_filename_with_path: '加载文件路径,用于更细粒度的控制到底选择哪个时间点的模型', + sparse_estimator: + '是否使用火山引擎的SparseEstimator,由火山引擎侧工程师判定,客户侧默认都为false', + steps_per_sync: '用于指定参数同步的频率,比如step间隔为10,也就是训练10个batch同步一次参数。', + feature_importance: '数值越高,表示该特征对模型的影响越大', + metric_is_publish: '开启后,将与合作伙伴共享本次训练指标', +}; + +export const LABEL_MAPPER: Record<string, string> = { + image: '镜像', + data_source: '数据源', + epoch_num: 'epoch_num', + verbosity: '日志输出等级', + shuffle_data_block: '是否打乱顺序', + save_checkpoint_steps: '保存备份间隔步数', + save_checkpoint_secs: '保存备份间隔秒数', + load_checkpoint_filename: '加载文件名', + load_checkpoint_filename_with_path: '加载文件路径', + sparse_estimator: 'sparse_estimator', + load_model_name: '加载模型名称', + data_path: '数据源', + steps_per_sync: '参数同步 step 间隔', + + learning_rate: '学习率', + max_iters: '迭代数', + max_depth: '最大深度', + l2_regularization: 'L2惩罚系数', + max_bins: '最大分箱数量', + num_parallel: ' 线程池大小', + file_ext: '文件扩展名', + file_type: '文件类型', + enable_packing: '是否优化', + ignore_fields: '忽略字段', + cat_fields: '类型变量字段', + send_metrics_to_follower: '是否将指标发送至 follower', + send_scores_to_follower: '是否将预测值发送至 follower', + verify_example_ids: '是否检验 example_ids', + no_data: '标签方是否无特征', + image_version: '镜像版本号', + num_partitions: 'num_partitions', + validation_data_path: '验证数据集地址', + label_field: '标签字段', + load_model_path: '加载模型路径', +}; + +export const MODEL_JOB_STATUE_TEXT_MAPPER: Record<ModelJobStatus, string> = { + [ModelJobStatus.PENDING]: '未配置', + [ModelJobStatus.CONFIGURED]: '配置成功', + [ModelJobStatus.ERROR]: '错误', + [ModelJobStatus.RUNNING]: '运行中', + [ModelJobStatus.SUCCEEDED]: '成功', + [ModelJobStatus.STOPPED]: '已停止', + [ModelJobStatus.FAILED]: '失败', + [ModelJobStatus.UNKNOWN]: '未知状态', +}; + +export type TableFiltersValue = Partial<Record<FiltersFields, string[]>>; + +export type ColumnsGetterOptions = { + onDeleteClick?: any; + onRestartClick?: any; + onStopClick?: any; + onLogClick?: any; + onReportNameClick?: any; + + module?: ModelEvaluationModuleType; + nameFieldText?: string; + withoutActions?: boolean; + isRestartLoading?: boolean; + isHideAllActionList?: boolean; + filterDropdownValues?: TableFiltersValue; +}; + +export function getModelJobState( + state: ModelJob['state'], + options?: ColumnsGetterOptions, +): { type: StateTypes; text: string; actionList?: ActionItem[] } { + switch (state) { + case WorkflowState.PARTICIPANT_CONFIGURING: + return { + text: + workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.PARTICIPANT_CONFIGURING], + type: 'gold', + }; + + case WorkflowState.PENDING_ACCEPT: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.PENDING_ACCEPT], + type: 'warning', + }; + + case WorkflowState.WARMUP_UNDERHOOD: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.WARMUP_UNDERHOOD], + type: 'warning', + }; + + case WorkflowState.PREPARE_RUN: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.PREPARE_RUN], + type: 'warning', + }; + + case WorkflowState.READY_TO_RUN: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.READY_TO_RUN], + type: 'lime', + }; + + case WorkflowState.RUNNING: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.RUNNING], + type: 'processing', + }; + + case WorkflowState.PREPARE_STOP: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.PREPARE_STOP], + type: 'error', + }; + + case WorkflowState.STOPPED: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.STOPPED], + type: 'error', + }; + + case WorkflowState.COMPLETED: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.COMPLETED], + type: 'success', + }; + + case WorkflowState.FAILED: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.FAILED], + type: 'error', + actionList: options?.isHideAllActionList + ? [] + : [ + { + label: '查看日志', + onClick: options?.onLogClick, + }, + { + label: '重新发起', + onClick: options?.onRestartClick, + isLoading: !!options?.isRestartLoading, + }, + ], + }; + + case WorkflowState.INVALID: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.INVALID], + type: 'default', + }; + case WorkflowState.UNKNOWN: + default: + return { + text: workflowStateFilterParamToStateTextMap[WorkflowStateFilterParam.UNKNOWN], + type: 'default', + }; + } +} + +export function getModelJobStatus( + status: ModelJobStatus, + options?: ColumnsGetterOptions, +): { type: StateTypes; text: string; actionList?: ActionItem[] } { + const modelJobStatusText = MODEL_JOB_STATUE_TEXT_MAPPER[status]; + switch (status) { + case ModelJobStatus.PENDING: + return { + text: modelJobStatusText, + type: 'default', + }; + case ModelJobStatus.CONFIGURED: + return { + text: modelJobStatusText, + type: 'success', + }; + case ModelJobStatus.ERROR: + return { + text: modelJobStatusText, + type: 'error', + }; + case ModelJobStatus.RUNNING: + return { + text: modelJobStatusText, + type: 'processing', + }; + case ModelJobStatus.STOPPED: + return { + text: modelJobStatusText, + type: 'error', + }; + case ModelJobStatus.SUCCEEDED: + return { + text: modelJobStatusText, + type: 'success', + }; + case ModelJobStatus.FAILED: + return { + text: modelJobStatusText, + type: 'error', + actionList: options?.isHideAllActionList + ? [] + : [ + { + label: '查看日志', + onClick: options?.onLogClick, + }, + { + label: '重新发起', + onClick: options?.onRestartClick, + isLoading: !!options?.isRestartLoading, + }, + ], + }; + case ModelJobStatus.UNKNOWN: + default: + return { + text: MODEL_JOB_STATUE_TEXT_MAPPER[ModelJobStatus.UNKNOWN], + type: 'default', + }; + } +} + +export function getAlgorithmTypeText(val: ModelJob['algorithm_type']) { + const [, type] = (val ?? '').split('_'); + if (!type) { + return; + } + + switch (type.toLowerCase()) { + case 'vertical': + return '纵向联邦'; + case 'horizontal': + return '横向联邦'; + default: + return val; + } +} + +export const Avatar: React.FC = () => { + return <div className={styles.avatar_container} />; +}; + +export async function dangerConfirmWrapper( + title: string, + content: string, + okText: string, + onConfirm: () => Promise<any>, + onCancel?: () => void, +) { + Modal.confirm({ + className: 'custom-modal', + title, + content, + okText, + okButtonProps: { + status: 'danger', + }, + cancelText: '取消', + onConfirm: onConfirm, + onCancel, + }); +} + +export const lossTypeOptions = [ + { + value: LossType.LOGISTIC, + label: 'logistic', + tip: '用于分类任务', + }, + { + value: LossType.MSE, + label: 'mse', + tip: '用于回归任务', + }, +]; + +export const algorithmTypeOptions = [ + { + label: '纵向联邦-树模型', + value: EnumAlgorithmProjectType.TREE_VERTICAL, + }, + { + label: '横向联邦-NN模型', + value: EnumAlgorithmProjectType.NN_HORIZONTAL, + }, + { + label: '纵向联邦-NN模型', + value: EnumAlgorithmProjectType.NN_VERTICAL, + }, +]; + +export const federalTypeOptions = [ + { + value: FederalType.VERTICAL, + label: '纵向联邦', + }, + { + value: FederalType.HORIZONTAL, + label: '横向联邦', + }, +]; + +export const trainRoleTypeOptions = [ + { + value: TrainRoleType.LABEL, + label: '标签方', + }, + { + value: TrainRoleType.FEATURE, + label: '特征方', + }, +]; +export const treeBaseConfigList: ItemProps[] = [ + { + field: 'learning_rate', + label: '学习率', + tip: '使用损失函数的梯度调整网络权重的超参数,​ 推荐区间(0.01-1]', + componentType: VariableComponent.NumberPicker, + initialValue: 0.3, + }, + { + field: 'max_iters', + label: '迭代数', + tip: '该模型包含树的数量,推荐区间(5-20)', + componentType: VariableComponent.NumberPicker, + initialValue: 10, + }, + { + field: 'max_depth', + label: '最大深度', + tip: '树模型的最大深度,用来控制过拟合,推荐区间(4-7)', + componentType: VariableComponent.NumberPicker, + initialValue: 5, + }, + { + field: 'l2_regularization', + label: 'L2惩罚系数', + tip: '对节点预测值的惩罚系数,推荐区间(0.01-10)', + componentType: VariableComponent.NumberPicker, + initialValue: 1, + }, + { + field: 'max_bins', + label: '最大分箱数量', + tip: '离散化连续变量,可以减少数据稀疏度,一般不需要调整', + componentType: VariableComponent.NumberPicker, + initialValue: 33, + }, + { + field: 'num_parallel', + label: '线程池大小', + tip: '建议与CPU核数接近', + componentType: VariableComponent.NumberPicker, + initialValue: 5, + }, +]; +export const nnBaseConfigList: ItemProps[] = [ + { + field: 'epoch_num', + label: 'epoch_num', + tip: '指一次完整模型训练需要多少次Epoch,一次Epoch是指将全部训练样本训练一遍', + componentType: VariableComponent.NumberPicker, + initialValue: 1, + }, + { + field: 'verbosity', + label: '日志输出等级', + tip: '有 0、1、2、3 四种等级,等级越大日志输出的信息越多', + componentType: VariableComponent.NumberPicker, + initialValue: 1, + }, +]; + +export const TREE_BASE_CONFIG_FIELD_LIST = treeBaseConfigList.map((item) => item.field) as string[]; +export const NN_BASE_CONFIG_FIELD_LIST = nnBaseConfigList.map((item) => item.field) as string[]; + +export const NOT_TREE_ADVANCE_CONFIG_FIELD_LIST = [ + ...TREE_BASE_CONFIG_FIELD_LIST, + 'loss_type', + 'role', + 'worker_cpu', + 'worker_mem', + 'mode', + 'algorithm', +]; +export const NOT_NN_ADVANCE_CONFIG_FIELD_LIST = [ + ...NN_BASE_CONFIG_FIELD_LIST, + 'role', + 'worker_cpu', + 'worker_mem', + 'worker_replicas', + 'master_cpu', + 'master_mem', + 'master_replicas', + 'ps_cpu', + 'ps_mem', + 'ps_replicas', + 'mode', + 'algorithm', +]; + +export function getAdvanceConfigListByDefinition(variables: Variable[], isNN = false): ItemProps[] { + const blockList = isNN ? NOT_NN_ADVANCE_CONFIG_FIELD_LIST : NOT_TREE_ADVANCE_CONFIG_FIELD_LIST; + + const advanceConfigList: ItemProps[] = []; + const variableList = flattenDeep(variables); + variableList.forEach((item) => { + if (!blockList.includes(item.name) && item.tag === Tag.INPUT_PARAM) { + let widget_schema: VariableWidgetSchema = {}; + + try { + widget_schema = JSON.parse(item.widget_schema as any); + } catch (error) {} + advanceConfigList.push({ + field: item.name, + label: LABEL_MAPPER?.[item.name] ?? item.name, + initialValue: item.value, + tip: widget_schema.tooltip ?? TIP_MAPPER?.[item.name], + }); + } + }); + + return advanceConfigList; +} +export function getAdvanceConfigList(config: WorkflowConfig, isNN = false) { + const blockList = isNN ? NOT_NN_ADVANCE_CONFIG_FIELD_LIST : NOT_TREE_ADVANCE_CONFIG_FIELD_LIST; + + const advanceConfigList: ItemProps[] = []; + + const variableList = flattenDeep( + [config.variables || []].concat((config.job_definitions || []).map((item) => item.variables)), + ); + + variableList.forEach((item) => { + if (!blockList.includes(item.name)) { + const labelI18nKey = LABEL_MAPPER[item.name]; + const tipI18nKey = TIP_MAPPER[item.name]; + let widget_schema: VariableWidgetSchema = {}; + + try { + widget_schema = JSON.parse(item.widget_schema as any); + } catch (error) {} + advanceConfigList.push({ + field: item.name, + label: labelI18nKey ?? item.name, + initialValue: item.value, + tip: widget_schema.tooltip ?? tipI18nKey, + }); + } + }); + + return advanceConfigList; +} +export function getConfigInitialValuesByDefinition( + variables: Variable[], + list: string[] = [], + isBlockList = false, + valuePreset: Record<string, any> = {}, +) { + const variableList = flattenDeep(variables); + const initialValues: { [key: string]: any } = {}; + + variableList.forEach((item) => { + if (isBlockList) { + if (!list.includes(item.name)) { + initialValues[item.name] = item.value ?? valuePreset[item.name]; + } + } else { + if (list.includes(item.name)) { + initialValues[item.name] = item.value ?? valuePreset[item.name]; + } + } + }); + + return initialValues; +} +export function getConfigInitialValues( + config: WorkflowConfig, + list: string[] = [], + isBlockList = false, + valuePreset: Record<string, any> = {}, +) { + const variableList = flattenDeep( + [config?.variables || []].concat((config?.job_definitions || []).map((item) => item.variables)), + ); + + const initialValues: { [key: string]: any } = {}; + + variableList.forEach((item) => { + if (isBlockList) { + if (!list.includes(item.name)) { + initialValues[item.name] = item.value ?? valuePreset[item.name]; + } + } else { + if (list.includes(item.name)) { + initialValues[item.name] = item.value ?? valuePreset[item.name]; + } + } + }); + + return initialValues; +} +export function getTreeBaseConfigInitialValuesByDefinition(variables: Variable[]) { + return getConfigInitialValuesByDefinition(variables, TREE_BASE_CONFIG_FIELD_LIST, false, { + learning_rate: 0.3, + max_iters: 5, + max_depth: 3, + l2_regularization: 1.0, + max_bins: 33, + num_parallel: 5, + }); +} +export function getNNBaseConfigInitialValuesByDefinition(variables: Variable[]) { + return getConfigInitialValuesByDefinition(variables, NN_BASE_CONFIG_FIELD_LIST, false, { + epoch_num: 1, + verbosity: 1, + }); +} +export function getTreeBaseConfigInitialValues(config: WorkflowConfig) { + return getConfigInitialValues(config, TREE_BASE_CONFIG_FIELD_LIST, false, { + learning_rate: 0.3, + max_iters: 5, + max_depth: 3, + l2_regularization: 1.0, + max_bins: 33, + num_parallel: 5, + }); +} +export function getTreeAdvanceConfigInitialValues(config: WorkflowConfig) { + return getConfigInitialValues(config, NOT_TREE_ADVANCE_CONFIG_FIELD_LIST, true, { + file_ext: '.data', + file_type: 'tfrecord', + enable_packing: true, + send_scores_to_follower: false, + send_metrics_to_follower: false, + verify_example_ids: true, + verbosity: 1, + no_data: false, + label_field: 'label', + }); +} + +export function getNNBaseConfigInitialValues(config: WorkflowConfig) { + return getConfigInitialValues(config, NN_BASE_CONFIG_FIELD_LIST, false, { + epoch_num: 1, + verbosity: 1, + }); +} +export function getNNAdvanceConfigInitialValues(config: WorkflowConfig) { + return getConfigInitialValues(config, NOT_NN_ADVANCE_CONFIG_FIELD_LIST, true, { + shuffle_data_block: true, + save_checkpoint_secs: 600, + save_checkpoint_steps: 1000, + sparse_estimator: false, + steps_per_sync: 10, + }); +} + +export function hydrateWorkflowConfig( + workflowConfig: WorkflowConfig, + values: { [key: string]: any }, +) { + const tempConfig = cloneDeep(workflowConfig); + + const keyList = Object.keys(values); + + for (let index = 0; index < keyList.length; index++) { + const key = keyList[index]; + + // send_metrics_to_follower,send_scores_to_follower + // only empty string will treat as false + const formValue = ['send_metrics_to_follower', 'send_scores_to_follower'].includes(key) + ? values[key] + ? true + : '' + : values[key]; + + let isBreak = false; + + // variables + for (let j = 0; j < tempConfig.variables.length; j++) { + if (tempConfig.variables[j].name === key) { + tempConfig.variables[j].value = formValue; + isBreak = true; + break; + } + } + if (isBreak) { + continue; + } + + // job_definitions + for (let i = 0; i < tempConfig.job_definitions.length; i++) { + for (let j = 0; j < tempConfig.job_definitions[i].variables.length; j++) { + if (tempConfig.job_definitions[i].variables[j].name === key) { + tempConfig.job_definitions[i].variables[j].value = formValue; + isBreak = true; + break; + } + } + if (isBreak) { + break; + } + } + } + return tempConfig; +} + +/** + * @param variable Variable defintions without any user input value + * @param values User inputs + */ +export function hydrateModalGlobalConfig( + variables: Array<ModelJobVariable | Variable>, + values: { [key: string]: any }, + hasAlgorithmUuid: boolean = true, +): Array<Variable> { + const tempVariables = cloneDeep(variables); + const resultVariables = []; + const keyList = Object.keys(values); + + for (let index = 0; index < keyList.length; index++) { + const key = keyList[index]; + const formValue = ['send_metrics_to_follower', 'send_scores_to_follower'].includes(key) + ? values[key] + ? true + : '' + : values[key]; + + for (let j = 0; j < tempVariables.length; j++) { + const newVariable = cloneDeep(tempVariables[j]); + if ( + newVariable.name === key && + (newVariable.tag === Tag.INPUT_PARAM || + newVariable.tag === Tag.RESOURCE_ALLOCATION || + (newVariable.name === 'algorithm' && !hasAlgorithmUuid)) + ) { + newVariable.value = formValue; + stringifyVariableValue(newVariable as Variable); + processVariableTypedValue(newVariable as Variable); + if (typeof newVariable.widget_schema === 'object') { + newVariable.widget_schema = JSON.stringify(newVariable.widget_schema); + } + resultVariables.push(newVariable); + break; + } + } + } + return resultVariables as Variable[]; +} + +type TableFilterConfig = Pick<TableColumnProps, 'filters' | 'onFilter'>; + +export const algorithmTypeFilters: TableFilterConfig = { + filters: algorithmTypeOptions.map((item) => ({ + text: item.label, + value: item.value, + })), + onFilter: (value: string, record: any) => { + return record?.algorithm_type === value; + }, +}; + +export const roleFilters: TableFilterConfig = { + filters: [ + { + text: '本方', + value: ModelJobRole.COORDINATOR, + }, + { + text: '合作伙伴', + value: ModelJobRole.PARTICIPANT, + }, + ], + onFilter: (value: string, record: any) => { + return record?.role === value; + }, +}; + +export const stateFilters: TableFilterConfig = { + filters: [ + WorkflowState.RUNNING, + WorkflowState.STOPPED, + WorkflowState.INVALID, + WorkflowState.COMPLETED, + WorkflowState.FAILED, + WorkflowState.PREPARE_RUN, + WorkflowState.PREPARE_STOP, + WorkflowState.WARMUP_UNDERHOOD, + WorkflowState.PENDING_ACCEPT, + WorkflowState.READY_TO_RUN, + WorkflowState.PARTICIPANT_CONFIGURING, + WorkflowState.UNKNOWN, + ].map((state) => { + const { text } = getModelJobState(state); + return { text, value: state }; + }), + onFilter: (value: string, record: ModelJobGroup | ModelJob) => { + return ( + (record as ModelJob)?.state === value || (record as ModelJobGroup)?.latest_job_state === value + ); + }, +}; + +export const statusFilters: TableFilterConfig = { + filters: [ + ModelJobStatus.PENDING, + ModelJobStatus.CONFIGURED, + ModelJobStatus.ERROR, + ModelJobStatus.RUNNING, + ModelJobStatus.SUCCEEDED, + ModelJobStatus.STOPPED, + ModelJobStatus.FAILED, + ModelJobStatus.UNKNOWN, + ].map((status) => { + return { text: MODEL_JOB_STATUE_TEXT_MAPPER[status], value: status }; + }), + onFilter: (value: string, record: ModelJob | ModelJobGroup) => { + return ( + (record as ModelJob)?.status === value || + (record as ModelJobGroup)?.latest_job_state === value + ); + }, +}; + +export const StyledPlusIcon: React.FC = () => { + return <PlusBold className={styles.plus_icon} />; +}; + +export function getDataSource(path: string) { + const regex = /\/data_source\/(.*)$/; + const matchList = path.match(regex); + return matchList?.[1] ?? ''; +} + +export function deleteEvaluationJob( + projectId: ID, + job: ModelJob, + module: ModelEvaluationModuleType, +): Promise<boolean> { + return new Promise((resolve, reject) => { + dangerConfirmWrapper( + `确认要删除「${job.name}」?`, + + module === ModelEvaluationModuleType.Evaluation + ? '删除后,该评估任务及信息将无法恢复,请谨慎操作' + : '删除后,该预测任务及信息将无法恢复,请谨慎操作', + '删除', + async () => { + try { + await deleteJob_new(projectId, job.id); + Message.success('删除成功'); + resolve(true); + } catch (e: any) { + Message.error(e.message); + reject(e); + } + }, + () => { + resolve(false); + }, + ); + }); +} + +export function isTreeAlgorithm(algorithmType: EnumAlgorithmProjectType | AlgorithmType) { + return [ + EnumAlgorithmProjectType.TREE_HORIZONTAL, + EnumAlgorithmProjectType.TREE_VERTICAL, + AlgorithmType.TREE, + ].includes(algorithmType); +} +export function isNNAlgorithm(algorithmType: EnumAlgorithmProjectType | AlgorithmType) { + return [ + EnumAlgorithmProjectType.NN_HORIZONTAL, + EnumAlgorithmProjectType.NN_VERTICAL, + EnumAlgorithmProjectType.NN_LOCAL, + AlgorithmType.NN, + ].includes(algorithmType); +} + +export function isOldAlgorithm(algorithmType: EnumAlgorithmProjectType | AlgorithmType) { + return [AlgorithmType.TREE, AlgorithmType.NN].includes(algorithmType as AlgorithmType); +} +export function isHorizontalAlgorithm(algorithmType: EnumAlgorithmProjectType) { + return [ + EnumAlgorithmProjectType.TREE_HORIZONTAL, + EnumAlgorithmProjectType.NN_HORIZONTAL, + ].includes(algorithmType); +} +export function isVerticalAlgorithm(algorithmType: EnumAlgorithmProjectType) { + return [EnumAlgorithmProjectType.TREE_VERTICAL, EnumAlgorithmProjectType.NN_VERTICAL].includes( + algorithmType, + ); +} + +export function isVerticalNNAlgorithm(algorithmType: EnumAlgorithmProjectType) { + return algorithmType === EnumAlgorithmProjectType.NN_VERTICAL; +} + +export const checkAlgorithmValueIsEmpty = ( + value: { algorithmProjectId: any; algorithmId: any } | undefined, + callback: (error?: string) => void, +) => { + if ( + value && + (value.algorithmProjectId || value.algorithmProjectId === 0) && + (value.algorithmId || value.algorithmId === 0 || value.algorithmId === null) + ) { + return callback(); + } + return callback('必填项'); +}; + +export enum TRAIN_ROLE { + LEADER = 'Leader', + FOLLOWER = 'Follower', +} + +export const FILTER_MODEL_TRAIN_OPERATOR_MAPPER = { + role: FilterOp.IN, + algorithm_type: FilterOp.IN, + name: FilterOp.CONTAIN, + configured: FilterOp.EQUAL, + //TODO: 'states' support BE filter +}; +export const FILTER_MODEL_JOB_OPERATOR_MAPPER = { + role: FilterOp.IN, + algorithm_type: FilterOp.IN, + name: FilterOp.CONTAIN, + model_job_type: FilterOp.IN, + status: FilterOp.IN, + configured: FilterOp.EQUAL, +}; + +export const MODEL_GROUP_STATUS_MAPPER: Record<ModelGroupStatus, any> = { + TICKET_PENDING: { + status: 'default', + percent: 30, + name: '待审批', + }, + CREATE_PENDING: { + status: 'default', + percent: 40, + name: '创建中', + }, + CREATE_FAILED: { + status: 'warning', + percent: 100, + name: '创建失败', + }, + TICKET_DECLINE: { + status: 'warning', + percent: 30, + name: '审批拒绝', + }, + SELF_AUTH_PENDING: { + status: 'default', + percent: 50, + name: '待我方授权', + }, + PART_AUTH_PENDING: { + status: 'default', + percent: 70, + name: '待合作伙伴授权', + }, + ALL_AUTHORIZED: { + status: 'success', + percent: 100, + name: '授权通过', + }, +}; + +export const AUTH_STATUS_TEXT_MAP: Record<string, string> = { + PENDING: '待授权', + AUTHORIZED: '已授权', + WITHDRAW: '待授权', +}; +export function resetAuthInfo( + participantsMap: Record<string, any> | undefined, + participantList: Participant[], + myPureDomainName: string, +) { + const resultList: any[] = []; + const keyList = Object.keys(participantsMap || {}); + keyList.forEach((key) => { + const curParticipant = participantList.find( + (participant: Participant) => participant?.pure_domain_name === key, + ); + key !== myPureDomainName && + curParticipant && + resultList.push({ + name: curParticipant?.name, + authStatus: participantsMap?.[key].auth_status, + }); + }); + resultList.sort((a: any, b: any) => { + return a.name > b.name ? 1 : -1; + }); + resultList.unshift({ + name: '我方', + authStatus: participantsMap?.[myPureDomainName]?.auth_status, + }); + return resultList; +} + +export const ALGORITHM_TYPE_LABEL_MAPPER: Record<string, string> = { + NN_HORIZONTAL: '横向联邦-NN模型', + NN_VERTICAL: '纵向联邦-NN模型', + TREE_VERTICAL: '纵向联邦-树模型', +}; diff --git a/web_console_v2/client/src/views/ModelServing/InstanceNumberInput.module.less b/web_console_v2/client/src/views/ModelServing/InstanceNumberInput.module.less new file mode 100644 index 000000000..8aa82fd16 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/InstanceNumberInput.module.less @@ -0,0 +1,6 @@ +.info_icon_container{ + margin-right: 5px; +} +.input_number_container{ + width: 120px; +} diff --git a/web_console_v2/client/src/views/ModelServing/InstanceNumberInput.tsx b/web_console_v2/client/src/views/ModelServing/InstanceNumberInput.tsx new file mode 100644 index 000000000..c959c9a16 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/InstanceNumberInput.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { InputNumber, InputNumberProps, Space } from '@arco-design/web-react'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; + +import styles from './InstanceNumberInput.module.less'; + +const InstanceNumberInput: React.FC<InputNumberProps> = (props) => { + return ( + <Space size="large"> + <InputNumber + className={styles.input_number_container} + mode="button" + precision={0} + {...props} + /> + <span> + <IconInfoCircle className={styles.info_icon_container} /> + 实例数范围1~100 + </span> + </Space> + ); +}; + +export default InstanceNumberInput; diff --git a/web_console_v2/client/src/views/ModelServing/InstanceTable.module.less b/web_console_v2/client/src/views/ModelServing/InstanceTable.module.less new file mode 100644 index 000000000..e75bcc162 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/InstanceTable.module.less @@ -0,0 +1,8 @@ + +.edit_text_container{ + display: inline-block; + margin-right: 18px; + font-size: 13px; + color: var(--primaryColor); + cursor: pointer; +} diff --git a/web_console_v2/client/src/views/ModelServing/InstanceTable.tsx b/web_console_v2/client/src/views/ModelServing/InstanceTable.tsx new file mode 100644 index 000000000..0f44fd360 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/InstanceTable.tsx @@ -0,0 +1,176 @@ +import React, { FC } from 'react'; + +import { formatTimestamp } from 'shared/date'; + +import Table from 'components/Table'; +import StateIndicator, { StateTypes, ActionItem } from 'components/StateIndicator'; + +import { TableProps, TableColumnProps } from '@arco-design/web-react'; +import { ModelServingInstance, ModelServingInstanceState } from 'typings/modelServing'; +import { SortDirection } from '@arco-design/web-react/es/Table/interface'; + +import styles from './InstanceTable.module.less'; + +type FilterValue = string[]; +type ColumnsGetterOptions = { + filter?: Record<string, FilterValue>; + sorter?: Record<string, SortDirection>; + onLogClick?: any; +}; + +function getDotState( + instance: ModelServingInstance, + options: ColumnsGetterOptions, +): { type: StateTypes; text: string; tip?: string; actionList?: ActionItem[] } { + if (instance.status === ModelServingInstanceState.AVAILABLE) { + return { + text: '运行中', + type: 'success', + }; + } + if (instance.status === ModelServingInstanceState.UNAVAILABLE) { + return { + text: '异常', + type: 'error', + // TODO: error tips + }; + } + + return { + text: '异常', + type: 'error', + }; +} + +const getTableColumns = (options: ColumnsGetterOptions) => { + const cols: TableColumnProps[] = [ + { + key: 'name', + dataIndex: 'name', + title: '实例ID', + width: 320, + }, + { + key: 'instances_status', + dataIndex: 'status', + filterMultiple: false, + filteredValue: options?.filter?.instances_status, + filters: [ + { text: '运行中', value: ModelServingInstanceState.AVAILABLE }, + { + text: '异常', + value: ModelServingInstanceState.UNAVAILABLE, + }, + ], + onFilter: (value, record) => record.status === value, + title: '状态', + render: (_: any, record: any) => { + return ( + <StateIndicator + {...getDotState(record, { + ...options, + })} + /> + ); + }, + }, + // Because BE can't get cpu/memory info, so hide temporarily + // { + // title: i18n.t('model_serving.col_cpu'), + // dataIndex: 'cpu', + // key: 'cpu', + // render: (value, record) => { + // const percent = parseFloat(value) || 0; + // return <Progress percent={percent} size="small" format={(percent) => `${percent || 0}%`} />; + // }, + // }, + // { + // title: i18n.t('model_serving.col_men'), + // dataIndex: 'memory', + // key: 'memory', + // render: (value, record) => { + // const percent = parseFloat(value) || 0; + // return <Progress percent={percent} size="small" format={(percent) => `${percent || 0}%`} />; + // }, + // }, + { + key: 'created_at', + dataIndex: 'created_at', + title: '创建时间', + sorter: true, + sortOrder: options.sorter?.created_at, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + { + key: 'operation', + dataIndex: 'operation', + title: '操作', + fixed: 'right', + render: (_, record) => { + const isDisabled = false; + + return ( + <> + <span + className={styles.edit_text_container} + data-is-disabled={isDisabled} + onClick={(event) => { + event.stopPropagation(); + options?.onLogClick(record); + }} + > + 查看日志 + </span> + </> + ); + }, + }, + ]; + + return cols; +}; + +interface Props extends TableProps<ModelServingInstance> { + loading: boolean; + filter?: Record<string, FilterValue>; + sorter?: Record<string, SortDirection>; + dataSource: any[]; + total?: number; + onLogClick?: (record: ModelServingInstance) => void; + onShowSizeChange?: (current: number, size: number) => void; + onPageChange?: (page: number, pageSize: number) => void; +} + +const InstanceTable: FC<Props> = ({ + loading, + dataSource, + total, + filter, + sorter, + onLogClick, + onShowSizeChange, + onPageChange, + ...restProps +}) => { + return ( + <Table + className="customFilterIconTable" + rowKey="name" + scroll={{ x: '100%' }} + loading={loading} + total={total} + data={dataSource} + columns={getTableColumns({ + filter, + sorter, + onLogClick, + })} + onShowSizeChange={onShowSizeChange} + onPageChange={onPageChange} + pagination={{ hideOnSinglePage: true }} + {...restProps} + /> + ); +}; + +export default InstanceTable; diff --git a/web_console_v2/client/src/views/ModelServing/ModelServingDetail/index.module.less b/web_console_v2/client/src/views/ModelServing/ModelServingDetail/index.module.less new file mode 100644 index 000000000..ea303a04c --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ModelServingDetail/index.module.less @@ -0,0 +1,52 @@ +@import '~styles/mixins.less'; +.avatar_container{ + .MixinSquare(44px); + background-color: #5360d8; + color: white; + border-radius: 4px; + font-size: 18px; + text-align: center; + + &::before { + display: inline-block; + width: 100%; + height: 100%; + content: ''; + background: url(../../../assets/icons/atom.svg) no-repeat; + background-size: contain; + } +} +.padding_container{ + padding: 20px 20px 0; +} +.name{ + margin-bottom: 0; + font-size: 16px; + height: 24px; + font-weight: 600; +} +.comment{ + .MixinEllipsis(400px); + display: block; + font-size: 12px; + line-height: 18px; + color: var(--textColorSecondary); +} +.change_button{ + margin-right: 20px ; +} + +.title{ + margin-top: 20px; + margin-bottom: 10px; + font-weight: bold; + color: rgb(var(--gray-10)); +} +.inference_hidden_info{ + height: 36px; + border-radius: 2px; + color: rgb(var(--gray-7)); + text-align: center; + line-height: 36px; + background: rgb(var(--gray-1)); +} diff --git a/web_console_v2/client/src/views/ModelServing/ModelServingDetail/index.tsx b/web_console_v2/client/src/views/ModelServing/ModelServingDetail/index.tsx new file mode 100644 index 000000000..18b8eff36 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ModelServingDetail/index.tsx @@ -0,0 +1,324 @@ +import React, { FC, useMemo } from 'react'; +import { cloneDeep } from 'lodash-es'; +import { useHistory, useParams } from 'react-router'; +import { useQuery } from 'react-query'; +import { useUrlState, useGetCurrentProjectId } from 'hooks'; + +import { fetchModelServingDetail_new, deleteModelServing_new } from 'services/modelServing'; +import { formatTimestamp } from 'shared/date'; +import { forceToRefreshQuery } from 'shared/queryClient'; +import { updateServiceInstanceNum } from '../shared'; +import { modelDirectionTypeToTextMap, getDotState, getTableFilterValue } from '../shared'; +import { CONSTANTS } from 'shared/constants'; + +import { Spin, Button, Message, Grid, Tooltip } from '@arco-design/web-react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import StateIndicator from 'components/StateIndicator'; +import BackButton from 'components/BackButton'; +import MoreActions from 'components/MoreActions'; +import PropertyList from 'components/PropertyList'; +import GridRow from 'components/_base/GridRow'; +import Modal from 'components/Modal'; +import UserGuideTab from '../UserGuideTab'; +import InstanceTable from '../InstanceTable'; +import WhichModel from 'components/WhichModel'; + +import { + ModelServing, + ModelDirectionType, + ModelServingInstance, + ModelServingState, +} from 'typings/modelServing'; +import { SortDirection, SorterResult } from '@arco-design/web-react/es/Table/interface'; + +import styles from './index.module.less'; + +type Props = {}; + +const ModelServingDetail: FC<Props> = () => { + const { id } = useParams<{ + id: string; + tabType: string; + }>(); + + const history = useHistory(); + const [urlState, setUrlState] = useUrlState<Record<string, string | undefined>>({ + order_by: undefined, + instances_status: undefined, + }); + + const projectId = useGetCurrentProjectId(); + + const modelServingDetailQuery = useQuery( + ['fetchModelServingDetail', id, urlState.order_by], + + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchModelServingDetail_new(projectId!, id, { + order_by: urlState.order_by || 'created_at desc', + }); + }, + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const modelServingDetail = useMemo<ModelServing>(() => { + const emptyData = {} as ModelServing; + + if (!modelServingDetailQuery.data || !modelServingDetailQuery.data.data) { + return emptyData; + } + return modelServingDetailQuery.data.data; + }, [modelServingDetailQuery.data]); + + const displayedProps = useMemo(() => { + const modelDirectionText = + modelDirectionTypeToTextMap[ + modelServingDetail.is_local ? ModelDirectionType.HORIZONTAL : ModelDirectionType.VERTICAL + ]; + + const { instance_num_status: instanceAmount } = modelServingDetail; + let payload; + try { + payload = JSON.parse( + modelServingDetail.remote_platform?.payload || JSON.stringify({ target_psm: '-' }), + ); + } catch (error) {} + const thirdServingDisplayedList = [ + { + value: + <StateIndicator {...getDotState(modelServingDetail)} /> || CONSTANTS.EMPTY_PLACEHOLDER, + label: '状态', + }, + { + value: modelDirectionText || CONSTANTS.EMPTY_PLACEHOLDER, + label: '模型类型', + }, + { + value: ( + <WhichModel + id={modelServingDetail.model_id} + isModelGroup={Boolean(modelServingDetail.model_group_id)} + /> + ), + label: '模型', + }, + { + value: + instanceAmount !== 'UNKNOWN' + ? instanceAmount || CONSTANTS.EMPTY_PLACEHOLDER + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '实例数量', + }, + { + value: + ( + <a href={modelServingDetail.endpoint} target="_blank" rel="noreferrer"> + {payload?.target_psm} + </a> + ) || CONSTANTS.EMPTY_PLACEHOLDER, + label: 'psm', + }, + + { + value: modelServingDetail.created_at + ? formatTimestamp(modelServingDetail.created_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '创建时间', + }, + { + value: modelServingDetail.updated_at + ? formatTimestamp(modelServingDetail.updated_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '更新时间', + }, + ]; + const innerServingDisplayedList = cloneDeep(thirdServingDisplayedList); + innerServingDisplayedList.splice(4, 1); + return modelServingDetail?.remote_platform + ? thirdServingDisplayedList + : innerServingDisplayedList; + }, [modelServingDetail]); + + const sorterProps = useMemo<Record<string, SortDirection>>(() => { + if (urlState.order_by) { + const order = urlState.order_by?.split(' ') || []; + return { + [order[0]]: order?.[1] === 'asc' ? 'ascend' : 'descend', + }; + } + + return {}; + }, [urlState.order_by]); + + const isLoading = modelServingDetailQuery.isFetching; + const tableDataList = modelServingDetail?.instances ?? []; + + return ( + <SharedPageLayout + title={<BackButton onClick={goBack}>{'在线服务'}</BackButton>} + cardPadding={0} + isNestSpinFlexContainer={true} + > + <Spin loading={isLoading}> + <div className={styles.padding_container}> + <Grid.Row align="center" justify="space-between"> + <GridRow gap="12" style={{ maxWidth: '75%' }}> + <div + className={styles.avatar_container} + data-name={ + modelServingDetail.name + ? modelServingDetail.name.slice(0, 1) + : CONSTANTS.EMPTY_PLACEHOLDER + } + /> + <div> + <h3 className={styles.name}>{modelServingDetail.name ?? '...'}</h3> + <Tooltip content={modelServingDetail?.comment}> + <small className={styles.comment}> + {modelServingDetail?.comment || CONSTANTS.EMPTY_PLACEHOLDER} + </small> + </Tooltip> + </div> + </GridRow> + + <GridRow> + <Button + className={styles.change_button} + type="primary" + disabled={ + modelServingDetail.status !== ModelServingState.AVAILABLE || + modelServingDetail.resource === undefined + } + onClick={onScaleClick} + > + 扩缩容 + </Button> + <MoreActions + actionList={[ + { + label: '编辑', + onClick: onChangeClick, + }, + { + label: '删除', + onClick: onDeleteClick, + danger: true, + }, + ]} + /> + </GridRow> + </Grid.Row> + <PropertyList cols={4} colProportions={[1, 1, 2, 1]} properties={displayedProps} /> + + <p className={styles.title}>调用指南</p> + {!modelServingDetail.is_local && + !modelServingDetail.support_inference && + !modelServingDetail.remote_platform ? ( + <div className={styles.inference_hidden_info}> + 纵向模型服务仅发起方可查看调用地址和 Signature + </div> + ) : ( + <UserGuideTab + isShowLabel={false} + isShowSignature={!modelServingDetail.remote_platform} + data={modelServingDetail} + /> + )} + {modelServingDetail.resource !== undefined && ( + <> + <p className={styles.title}>实例列表</p> + <InstanceTable + sorter={sorterProps} + filter={{ + instances_status: getTableFilterValue(urlState.instances_status), + }} + total={tableDataList.length} + dataSource={tableDataList} + loading={modelServingDetailQuery.isFetching} + onLogClick={onLogClick} + onChange={onTableChange} + /> + </> + )} + </div> + </Spin> + </SharedPageLayout> + ); + + function goBack() { + history.goBack(); + } + + function onLogClick(record: ModelServingInstance) { + window.open(`/v2/logs/model-serving/${id}/${record.name}`, '_blank noopener'); + } + + async function onChangeClick() { + if (modelServingDetail.id) { + history.push(`/model-serving/edit/${modelServingDetail.id}`); + } + } + + async function onScaleClick() { + updateServiceInstanceNum(modelServingDetail, () => { + forceToRefreshQuery(['fetchModelServingDetail', id]); + }); + } + + function onDeleteClick() { + if (!projectId) { + Message.info('请选择工作区!'); + return; + } + Modal.delete({ + title: `确认要删除「${modelServingDetail.name}」?`, + content: '一旦删除,在线服务相关数据将无法复原,请谨慎操作', + onOk() { + deleteModelServing_new(projectId!, modelServingDetail.id) + .then(() => { + Message.success('删除成功'); + history.replace('/model-serving'); + }) + .catch((error) => { + Message.error(error.message); + }); + }, + }); + } + + function onTableChange( + _: any, + sorter: SorterResult | SorterResult[], + filters: Record<string, any>, + extra: { action: string }, + ) { + const { action } = extra; + const latestSorter = Array.isArray(sorter) ? sorter[0] : sorter; + + if (action === 'sort' && latestSorter.field && latestSorter.direction) { + setUrlState({ + order_by: `${latestSorter.field as string} ${ + latestSorter.direction === 'ascend' ? 'asc' : 'desc' + }`, + }); + } else { + setUrlState({ + order_by: undefined, + }); + } + + if (action === 'filter') { + setUrlState({ + instances_status: filters.status?.[0] || undefined, + }); + } + } +}; + +export default ModelServingDetail; diff --git a/web_console_v2/client/src/views/ModelServing/ModelServingForm/index.module.less b/web_console_v2/client/src/views/ModelServing/ModelServingForm/index.module.less new file mode 100644 index 000000000..8b85d9485 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ModelServingForm/index.module.less @@ -0,0 +1,63 @@ +@path:'../../../assets/images'; +.spin_container{ + min-height: 500px; +} + +.container{ + flex: 1; +} + +.styled_form{ + --form-width: 600px; + margin-top: 20px; + > .form-title { + margin-bottom: 24px; + font-size: 27px; + line-height: 36px; + } + > .ant-space { + display: flex; + } + + > .ant-form-item { + &:last-child { + margin-bottom: 0; + } + } +} + +.button_group{ + display: flex; + align-items: center; + + &:not(:last-child) { + margin-bottom: 12px; + } + button:not(:last-child) { + margin-right: 12px; + } +} + +.styled_submit_button{ + min-width: 84px; +} + +.styled_info_alert{ + margin-top: 20px; +} + +.empty_model_tip_container{ + position: absolute; + left: 50%; + top: 40%; + width: 400px; + padding-top: 164px; + background-image: url('@{path}/empty.png'); + background-size: 140px auto; + background-repeat: no-repeat; + background-position: top center; + font-size: 12px; + color: rgb(var(--gray-6)); + text-align: center; + transform: translate(-50%, -50%); +} diff --git a/web_console_v2/client/src/views/ModelServing/ModelServingForm/index.tsx b/web_console_v2/client/src/views/ModelServing/ModelServingForm/index.tsx new file mode 100644 index 000000000..49adee326 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ModelServingForm/index.tsx @@ -0,0 +1,751 @@ +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { useQuery } from 'react-query'; + +import { + createModelServing_new, + updateModelServing_new, + fetchModelServingList_new, + fetchModelServingDetail_new, + fetchUserTypeInfo, +} from 'services/modelServing'; +import { + fetchModelList, + fetchModelJobGroupList, + fetchModelJobGroupDetail, +} from 'services/modelCenter'; +import { validNamePattern, MAX_COMMENT_LENGTH } from 'shared/validator'; +import { convertCpuMToCore } from 'shared/helpers'; +import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { FILTER_SERVING_OPERATOR_MAPPER, cpuIsCpuM, memoryIsMemoryGi } from '../shared'; + +import { useIsFormValueChange, useGetCurrentProjectId } from 'hooks'; + +import { + Spin, + Form, + Input, + Button, + Message, + Grid, + Select, + Alert, + Switch, + Space, + Typography, +} from '@arco-design/web-react'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BackButton from 'components/BackButton'; +import { FormHeader } from 'components/SharedPageLayout'; +import BlockRadio from 'components/_base/BlockRadio'; +import InputGroup, { TColumn } from 'components/InputGroup'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import TitleWithIcon from 'components/TitleWithIcon'; + +import debounce from 'debounce-promise'; +import i18n from 'i18n'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { FilterOp } from 'typings/filter'; + +import styles from './index.module.less'; + +const { Row, Col } = Grid; + +type FormValues = { + name: string; + comment: string; + is_local: boolean; + model_set: { + model_set_id: number; + model_id: number; + }; + model_id: number; + resource: [ + { + cpu: string; + memory: string; + replicas: number; + }, + ]; + instance_num: number; + auto_update: boolean; + model_group_id: boolean; + third_serving: boolean; + psm: string; +}; + +const FILTER_MODEL_TRAIN_OPERATOR_MAPPER = { + role: FilterOp.EQUAL, + algorithm_type: FilterOp.IN, + name: FilterOp.CONTAIN, + configured: FilterOp.EQUAL, +}; + +const initialValues: any = { + is_local: false, + name: undefined, + comment: undefined, + model_set: undefined, + resource: [ + { + cpu: 1, + memory: 1, + replicas: 1, + }, + ], + auto_update: false, + third_serving: false, + psm: undefined, +}; + +const getResourceFormColumns = (readonly?: boolean): TColumn[] => [ + { + type: 'INPUT_NUMBER', + span: 8, + dataIndex: 'replicas', + title: i18n.t('model_serving.label_instance_amount'), + precision: 0, + rules: [ + { required: true, message: i18n.t('model_serving.msg_required') }, + { min: 1, type: 'number' }, + { max: 100, type: 'number' }, + ], + tooltip: i18n.t('tip_replicas_range'), + mode: 'button', + min: 1, + max: 100, + disabled: readonly, + }, + { + type: 'INPUT_NUMBER', + unitLabel: 'Core', + span: 8, + dataIndex: 'cpu', + title: i18n.t('cpu'), + precision: 1, + rules: [{ required: true, message: i18n.t('model_serving.msg_required') }], + tooltip: i18n.t('tip_please_input_positive_number'), + placeholder: i18n.t('placeholder_cpu'), + disabled: readonly, + }, + { + type: 'INPUT_NUMBER', + unitLabel: 'Gi', + span: 8, + dataIndex: 'memory', + title: i18n.t('mem'), + precision: 0, + rules: [{ required: true, message: i18n.t('model_serving.msg_required') }], + tooltip: i18n.t('tip_please_input_positive_integer'), + placeholder: i18n.t('placeholder_mem'), + disabled: readonly, + }, +]; + +const modelGroupIsEmptyRegx = /model\s*in\s*group\s*[0-9]*\s*is\s*not\s*found/i; + +const ModelServingForm: FC = () => { + const history = useHistory(); + const { action, role, id } = useParams<{ + action: string; + role: string; + id: string; + }>(); + const isEdit = action === 'edit'; + const isReceiver = role === 'receiver'; + + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [modelChange, setModelChange] = useState(false); + const [formConfig, setFormConfig] = useState({ + isHorizontalModel: false, + autoUpdate: false, + thirdServing: false, + }); + + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(); + const projectId = useGetCurrentProjectId(); + + const userTypeQuery = useQuery( + ['fetchUserType', projectId], + () => fetchUserTypeInfo(projectId!), + { + retry: 2, + enabled: Boolean(projectId), + }, + ); + const modelServingDetailQuery = useQuery( + ['fetchModelServingDetail', id], + () => fetchModelServingDetail_new(projectId!, id), + { + cacheTime: 1, + refetchOnWindowFocus: false, + enabled: Boolean(id) && Boolean(projectId), + }, + ); + + const modelListQuery = useQuery( + ['fetchModelList', projectId, formConfig.isHorizontalModel], + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchModelList(projectId, { + algorithm_type: formConfig.isHorizontalModel + ? EnumAlgorithmProjectType.NN_HORIZONTAL + : EnumAlgorithmProjectType.NN_VERTICAL, + }); + }, + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const modelJobGroupListQuery = useQuery( + ['fetchModelJobGroupList', projectId, formConfig.isHorizontalModel], + () => { + if (!projectId) { + Message.error('请选择工作区'); + return; + } + return fetchModelJobGroupList(projectId, { + filter: filterExpressionGenerator( + { + configured: true, + algorithm_type: formConfig.isHorizontalModel + ? [EnumAlgorithmProjectType.NN_HORIZONTAL] + : [EnumAlgorithmProjectType.NN_VERTICAL], + }, + FILTER_MODEL_TRAIN_OPERATOR_MAPPER, + ), + }); + }, + { + refetchOnWindowFocus: false, + }, + ); + + const userType = useMemo(() => { + return userTypeQuery.data?.data || []; + }, [userTypeQuery]); + + const modelServingDetail = useMemo(() => { + return modelServingDetailQuery.data?.data; + }, [modelServingDetailQuery]); + + const modelList = useMemo(() => { + return modelListQuery.data?.data ?? []; + }, [modelListQuery]); + + const modelJobGroupList = useMemo(() => { + return modelJobGroupListQuery.data?.data ?? []; + }, [modelJobGroupListQuery]); + + const payload = useMemo(() => { + let resultPayload = { target_psm: '-' }; + try { + resultPayload = JSON.parse( + modelServingDetail?.remote_platform?.payload ?? JSON.stringify({ target_psm: '-' }), + ); + } catch (error) {} + return resultPayload; + }, [modelServingDetail]); + + const disabled: Partial<Record<keyof FormValues, boolean>> = { + name: isEdit || isReceiver, + comment: false, + is_local: isEdit || isReceiver, + instance_num: false, + model_id: isReceiver, + model_group_id: isReceiver, + auto_update: isReceiver, + third_serving: isEdit || isReceiver, + psm: isEdit || isReceiver, + }; + const readonlyField: Partial<Record<keyof FormValues, boolean>> = { + name: isReceiver || isEdit, + is_local: isReceiver || isEdit, + model_id: + isReceiver || + (isEdit && !modelServingDetail?.is_local && !modelServingDetail?.remote_platform), + model_group_id: + isReceiver || + (isEdit && !modelServingDetail?.is_local && !modelServingDetail?.remote_platform), + auto_update: + isReceiver || + (isEdit && !modelServingDetail?.is_local && !modelServingDetail?.remote_platform), + psm: isEdit, + third_serving: isEdit || isReceiver, + }; + + const selectedModelGroupQuery = useQuery( + ['fetchModelJobGroupDetail', projectId, modelServingDetail?.model_group_id], + () => fetchModelJobGroupDetail(projectId!, modelServingDetail?.model_group_id!), + { + enabled: Boolean(projectId && modelServingDetail?.model_group_id), + refetchOnWindowFocus: false, + }, + ); + + const selectedModel = useMemo(() => { + const curModelId = modelServingDetail?.model_id; + return modelList.find((item) => item.id === curModelId); + }, [modelList, modelServingDetail?.model_id]); + + const selectedModelGroup = useMemo(() => { + return selectedModelGroupQuery.data?.data; + }, [selectedModelGroupQuery.data?.data]); + + const isReceiverModelEmpty = useMemo(() => { + if (!isReceiver || modelListQuery.isLoading) { + return false; + } + + return selectedModel == null; + }, [isReceiver, modelListQuery.isLoading, selectedModel]); + + const handleOnChangeFederalType = (checked: boolean) => { + form.setFieldValue('model_id', undefined); + form.setFieldValue('model_group_id', undefined); + setFormConfig((prevState) => ({ + ...prevState, + isHorizontalModel: checked, + })); + }; + + useEffect(() => { + let isUnmount = false; + const data = modelServingDetail; + if (!data) { + return; + } + setFormConfig({ + isHorizontalModel: data.is_local, + autoUpdate: Boolean(data.model_group_id), + thirdServing: Boolean(data.remote_platform), + }); + form.setFieldsValue({ + name: data.name, + comment: data.comment, + is_local: data.is_local, + model_id: data.model_id, + model_group_id: data.model_group_id, + psm: payload.target_psm, + resource: [ + { + cpu: cpuIsCpuM(data.resource?.cpu ?? '1') + ? convertCpuMToCore(data.resource?.cpu, false) + : data.resource?.cpu, + memory: memoryIsMemoryGi(data.resource?.memory ?? '1Gi') + ? data.resource?.memory.slice(0, -2) + : data.resource?.memory, + replicas: data.resource?.replicas || 1, + }, + ], + }); + return () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isUnmount = true; + }; + }, [modelServingDetail, form, payload]); + + return ( + <Spin className={styles.spin_container} loading={modelServingDetailQuery.isLoading}> + <SharedPageLayout + title={ + <BackButton + onClick={() => history.replace('/model-serving')} + isShowConfirmModal={isFormValueChanged} + > + 在线服务 + </BackButton> + } + > + {isReceiverModelEmpty ? ( + <div className={styles.empty_model_tip_container}> + <span>因对应模型不存在,请选择两侧均存在的纵向联邦模型进行部署</span> + </div> + ) : ( + <div className={styles.container}> + <FormHeader>{isEdit ? '编辑服务' : '创建服务'}</FormHeader> + {isReceiver ? ( + <Alert + className={styles.styled_info_alert} + content={'纵向模型服务仅发起方可查看调用地址和 Signature'} + type="info" + showIcon + /> + ) : null} + <Form + className={styles.styled_form} + layout="horizontal" + initialValues={initialValues} + form={form} + labelCol={{ span: 3 }} + wrapperCol={{ span: 12 }} + onSubmit={onFinish} + onSubmitFailed={onFinishFailed} + onValuesChange={onFormValueChange} + scrollToFirstError + > + <Form.Item + hasFeedback + field="name" + label={'在线服务名称'} + rules={[ + { required: true, message: '必填项' }, + { + match: validNamePattern, + message: + '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + { + validator: debounce(async function (value: any, cb) { + if (isEdit || isReceiver || !value) { + return; + } + const isDuplicate = await checkNameIsDuplicate(value); + cb(isDuplicate ? '在线服务名称已存在' : undefined); + }, 300), + }, + ]} + > + {readonlyField.name ? ( + <PlainText /> + ) : ( + <Input placeholder={'请输入在线服务名称'} disabled={disabled.name} allowClear /> + )} + </Form.Item> + <Form.Item + field="comment" + label={'在线服务描述'} + rules={[ + { + maxLength: MAX_COMMENT_LENGTH, + message: '最多为 200 个字符', + }, + ]} + > + <Input.TextArea + rows={4} + name="comment" + placeholder={'最多为 200 个字符'} + disabled={disabled.comment} + showWordLimit + /> + </Form.Item> + {Boolean(userType.length) && ( + <Form.Item field="third_serving" label="部署到第三方"> + {readonlyField.third_serving ? ( + <Typography.Text bold={true}> + {formConfig.thirdServing ? '开启' : '关闭'} + </Typography.Text> + ) : ( + <Space> + <Switch + disabled={disabled.third_serving} + onChange={(value) => { + setFormConfig((prevState) => ({ + ...prevState, + thirdServing: value, + })); + }} + /> + <TitleWithIcon + title="开启后服务将部署到reckon" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </Space> + )} + </Form.Item> + )} + {formConfig.thirdServing && ( + <Form.Item field="psm" label="psm" rules={[{ required: true }]}> + {readonlyField.psm ? ( + <a href={modelServingDetail?.endpoint} target="_blank" rel="noreferrer"> + {payload.target_psm} + </a> + ) : ( + <Input disabled={disabled.psm} placeholder="请输入psm" /> + )} + </Form.Item> + )} + <Form.Item + field="is_local" + label={'联邦类型'} + wrapperCol={{ span: 6 }} + rules={[{ required: true }]} + > + {readonlyField.is_local ? ( + <PlainText valueFormat={(is_local) => (is_local ? '横向联邦' : '纵向联邦')} /> + ) : ( + <BlockRadio + onChange={handleOnChangeFederalType} + gap={8} + isCenter={true} + disabled={disabled.is_local} + options={[ + { + label: '纵向联邦', + value: false, // note: 先写死,改动 ModelDirectionType 需要动到的地方较多 + }, + { + label: '横向联邦', + value: true, + }, + ]} + /> + )} + </Form.Item> + <Form.Item field="auto_update" label="自动更新模型" rules={[{ required: true }]}> + {readonlyField.auto_update ? ( + <Typography.Text bold={true}> + {formConfig.autoUpdate ? '开启' : '关闭'} + </Typography.Text> + ) : ( + <Space> + <Switch + disabled={disabled.auto_update} + checked={formConfig.autoUpdate} + onChange={(value) => { + setFormConfig((prevState) => ({ + ...prevState, + autoUpdate: value, + })); + form.setFieldValue('model_id', undefined); + form.setFieldValue('model_group_id', undefined); + }} + /> + <TitleWithIcon + title="开启后,当所选择的模型训练作业产生新模型时,将自动更新到本服务" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </Space> + )} + </Form.Item> + {formConfig.autoUpdate ? ( + <Form.Item + field="model_group_id" + label="模型训练作业" + rules={[{ required: true, message: '必填项' }]} + > + {readonlyField.model_group_id ? ( + <Spin loading={selectedModelGroupQuery.isFetching}> + <Typography.Text bold={true}>{selectedModelGroup?.name} </Typography.Text> + </Spin> + ) : ( + <Select + disabled={disabled.model_group_id} + placeholder="请选择模型训练作业" + loading={modelJobGroupListQuery.isFetching} + showSearch={true} + filterOption={(inputValue, option) => + option.props.children.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0 + } + options={(modelJobGroupList ?? []).map((item) => ({ + label: item.name, + value: item.id, + }))} + onChange={(value) => { + setModelChange(value !== selectedModelGroup?.id); + }} + /> + )} + </Form.Item> + ) : ( + <Form.Item + field="model_id" + label={'模型'} + rules={[{ required: true, message: '必填项' }]} + > + {readonlyField.model_id ? ( + <Typography.Text bold={true}>{selectedModel?.name} </Typography.Text> + ) : ( + <Select + disabled={disabled.model_id} + placeholder={'请选择模型'} + loading={modelListQuery.isFetching} + showSearch={true} + filterOption={(inputValue, option) => + option.props.children.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0 + } + options={(modelListQuery.data?.data ?? []).map((item) => ({ + label: item.name, + value: item.id, + }))} + onChange={(value) => { + setModelChange(value !== selectedModel?.id); + }} + /> + )} + </Form.Item> + )} + + {!formConfig.thirdServing && ( + <Form.Item label="实例规格" required={true} field="resource"> + <InputGroup columns={getResourceFormColumns()} disableAddAndDelete={true} /> + </Form.Item> + )} + <Row> + <Col offset={2} span={12}> + <div className={styles.button_group}> + <Button + className={styles.styled_submit_button} + type="primary" + loading={loading} + htmlType="submit" + > + {!formConfig.isHorizontalModel && + !isReceiver && + !formConfig.thirdServing && + (!isEdit || modelChange) + ? '发送至对侧' + : '确认'} + </Button> + <ButtonWithModalConfirm + onClick={onCancelClick} + isShowConfirmModal={isFormValueChanged} + > + 取消 + </ButtonWithModalConfirm> + </div> + </Col> + </Row> + </Form> + </div> + )} + </SharedPageLayout> + </Spin> + ); + + function onCancelClick() { + history.goBack(); + } + async function onFinish(values: FormValues) { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + const { name, comment, model_id, model_group_id } = values; + setLoading(true); + const { cpu, memory, replicas } = values.resource?.[0] ?? { + cpu: '1', + memory: '1', + replicas: 1, + }; + const payload_new = { target_psm: values.psm }; + + // note: creating service at receiver side is also use the patch method + if (isEdit || isReceiver) { + const payload = { + comment: comment, + resource: formConfig.thirdServing + ? undefined + : { + cpu: cpu.toString(), + memory: `${memory}Gi`, + replicas, + }, + model_id: modelChange ? model_id : undefined, + model_group_id: modelChange ? model_group_id : undefined, + }; + + try { + await updateModelServing_new(projectId, id, payload); + Message.success(isReceiver ? '创建成功' : '修改成功'); + history.push('/model-serving'); + } catch (error: any) { + let msg = error.message; + if (modelGroupIsEmptyRegx.test(msg)) { + msg = '该模型训练作业暂无训练成功的模型'; + } + Message.error(msg); + } + } else { + const payload = { + name: name, + comment: comment, + resource: formConfig.thirdServing + ? undefined + : { + cpu: cpu.toString(), + memory: `${memory}Gi`, + replicas, + }, + is_local: formConfig.isHorizontalModel, + model_id: model_id, + model_group_id: model_group_id, + remote_platform: formConfig.thirdServing + ? { + platform: userType?.[0].platform, + payload: JSON.stringify(payload_new), + } + : undefined, + }; + try { + await createModelServing_new(projectId!, payload); + Message.success('创建成功'); + history.push('/model-serving'); + } catch (error: any) { + const { message: errMsg } = error; + let msg = error.message; + + if (/participant.+code\s*=\s*3/i.test(errMsg)) { + msg = '合作伙伴侧在线服务名称已存在'; + } else if (/duplicate\s*entry/i.test(msg)) { + msg = '在线服务名称已存在'; + } else if (modelGroupIsEmptyRegx.test(msg)) { + msg = '该模型训练作业暂无训练成功的模型'; + } + Message.error(msg); + } + } + setLoading(false); + } + + async function checkNameIsDuplicate(name: string) { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + try { + const res = await fetchModelServingList_new(projectId!, { + filter: filterExpressionGenerator( + { + name, + }, + FILTER_SERVING_OPERATOR_MAPPER, + ), + }); + + return res.data?.length > 0; + } catch (e) { + // 如果网络无法请求,就先当没有重名处理 + return false; + } + } + + function onFinishFailed() {} +}; + +type TPlainTextProps = { + value?: any; + valueFormat?: (val: any) => string; +}; +function PlainText(props: TPlainTextProps) { + const { value, valueFormat } = props; + return ( + <Typography.Text bold={true}> + {typeof valueFormat === 'function' ? valueFormat(value) : value} + </Typography.Text> + ); +} + +export default ModelServingForm; diff --git a/web_console_v2/client/src/views/ModelServing/ModelServingList/index.module.less b/web_console_v2/client/src/views/ModelServing/ModelServingList/index.module.less new file mode 100644 index 000000000..878e3f6c5 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ModelServingList/index.module.less @@ -0,0 +1,4 @@ +.search_content{ + width: 280px; + margin-right: 12px; +} diff --git a/web_console_v2/client/src/views/ModelServing/ModelServingList/index.tsx b/web_console_v2/client/src/views/ModelServing/ModelServingList/index.tsx new file mode 100644 index 000000000..98401b934 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ModelServingList/index.tsx @@ -0,0 +1,228 @@ +import React, { FC, useMemo } from 'react'; +import { useHistory } from 'react-router'; + +import { useQuery } from 'react-query'; +import { fetchModelServingList_new, deleteModelServing_new } from 'services/modelServing'; + +import { forceToRefreshQuery } from 'shared/queryClient'; +import { useGetCurrentProjectId } from 'hooks'; + +import { useUrlState, useTablePaginationWithUrlState } from 'hooks'; +import { TIME_INTERVAL } from 'shared/constants'; +import { expression2Filter } from 'shared/filter'; + +import { Button, Input, Message, Space } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import SharedPageLayout from 'components/SharedPageLayout'; +import Modal from 'components/Modal'; +import TodoPopover from 'components/TodoPopover'; +import { debounce } from 'lodash-es'; + +import ModelServingTable from '../ModelServingTable'; + +import { + updateServiceInstanceNum, + getTableFilterValue, + FILTER_SERVING_OPERATOR_MAPPER, +} from '../shared'; + +import { ModelServing, ModelServingState } from 'typings/modelServing'; +import { SortDirection, SorterResult } from '@arco-design/web-react/es/Table/interface'; + +import { filterExpressionGenerator } from 'views/Datasets/shared'; + +import styles from './index.module.less'; + +const { Search } = Input; + +const ModelServingList: FC = () => { + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + const [urlState, setUrlState] = useUrlState<Record<string, string | undefined>>({ + filter: '', + order_by: '', + }); + const { paginationProps } = useTablePaginationWithUrlState(); + + const queryKey = ['fetchModelServingList', urlState.filter, urlState.order_by, projectId]; + + const listQuery = useQuery( + queryKey, + () => { + if (!projectId) { + Message.info('请选择工作区'); + return; + } + return fetchModelServingList_new(projectId!, { + filter: urlState.filter, + order_by: urlState.order_by || 'created_at desc', + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, // auto refresh every 1.5 min + }, + ); + + const tableDataSource = useMemo(() => { + if (!listQuery.data) { + return []; + } + return (listQuery.data.data || []).filter( + (item) => + item.status !== ModelServingState.WAITING_CONFIG && + (!urlState.status || urlState.status.includes(item.status)) && + (!urlState.is_local || urlState.is_local.includes(item.is_local)), + ); + }, [listQuery.data, urlState]); + + const sorterProps = useMemo<Record<string, SortDirection>>(() => { + if (urlState.order_by) { + const order = urlState.order_by?.split(' ') || []; + return { + [order[0]]: order?.[1] === 'asc' ? 'ascend' : 'descend', + }; + } + + return {}; + }, [urlState.order_by]); + + const pagination = useMemo(() => { + return tableDataSource.length <= paginationProps.pageSize + ? false + : { + ...paginationProps, + total: tableDataSource.length, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableDataSource]); + + return ( + <SharedPageLayout title="在线服务"> + <GridRow justify="space-between" align="center"> + <Button className={'custom-operation-button'} type="primary" onClick={onCreateClick}> + 创建服务 + </Button> + + <Space> + <Search + className={`custom-input ${styles.search_content}`} + allowClear + defaultValue={expression2Filter(urlState.filter).keyword} + onChange={debounce(onSearch, 300)} + placeholder="请输入名称查询" + /> + <TodoPopover.ModelServing /> + </Space> + </GridRow> + <ModelServingTable + filter={{ + is_local: getTableFilterValue(urlState.is_local), + status: getTableFilterValue(urlState.status), + }} + sorter={sorterProps} + loading={listQuery.isFetching} + dataSource={tableDataSource} + total={listQuery.data?.page_meta?.total_items ?? undefined} + onRowClick={onRowClick} + onEditClick={onEditClick} + onScaleClick={onScaleClick} + onDeleteClick={onDeleteClick} + onFilterChange={onFilter} + onSortChange={onSort} + pagination={pagination} + /> + </SharedPageLayout> + ); + + function onSearch( + value: string, + event?: + | React.ChangeEvent<HTMLInputElement> + | React.MouseEvent<HTMLElement> + | React.KeyboardEvent<HTMLInputElement>, + ) { + const filters = expression2Filter(urlState.filter); + filters.keyword = value; + if (!value) { + setUrlState((prevState) => ({ + ...prevState, + filter: filterExpressionGenerator(filters, FILTER_SERVING_OPERATOR_MAPPER), + })); + return; + } + setUrlState((prevState) => ({ + ...prevState, + filter: filterExpressionGenerator(filters, FILTER_SERVING_OPERATOR_MAPPER), + page: 1, + })); + } + + function onCreateClick() { + history.push('/model-serving/create'); + } + function onRowClick(record: ModelServing) { + history.push(`/model-serving/detail/${record.id}`); + } + async function onEditClick(record: ModelServing) { + history.push(`/model-serving/edit/${record.id}`); + } + async function onScaleClick(record: ModelServing) { + updateServiceInstanceNum(record, () => { + forceToRefreshQuery([...queryKey]); + }); + } + function onDeleteClick(record: ModelServing) { + if (!projectId) { + Message.info('请选择工作区!'); + return; + } + Modal.delete({ + title: `确认要删除「${record.name}}」?`, + content: '一旦删除,在线服务相关数据将无法复原,请谨慎操作', + onOk() { + deleteModelServing_new(projectId!, record.id) + .then(() => { + Message.success('删除成功'); + listQuery.refetch(); + }) + .catch((error) => { + Message.error(error.message); + }); + }, + }); + } + function onFilter(filter: Record<string, Array<number | string>>) { + const filterParams: Record<string, any> = {}; + + for (const key in filter) { + filterParams[key] = booleanToString(filter[key]?.[0]); + } + + setUrlState({ + is_local: filterParams.is_local, + status: filterParams.status, + }); + } + function onSort(sorter: SorterResult) { + const { field, direction: order } = sorter; + if (field && order) { + setUrlState({ + order_by: `${field as string} ${order === 'ascend' ? 'asc' : 'desc'}`, + }); + } else { + setUrlState({ + order_by: undefined, + }); + } + } +}; + +function booleanToString(val: any) { + if (typeof val !== 'boolean') { + return val; + } + return val ? 'true' : 'false'; +} + +export default ModelServingList; diff --git a/web_console_v2/client/src/views/ModelServing/ModelServingTable.module.less b/web_console_v2/client/src/views/ModelServing/ModelServingTable.module.less new file mode 100644 index 000000000..0b7c9b26b --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ModelServingTable.module.less @@ -0,0 +1,23 @@ +.edit_text_container{ + display: inline-block; + max-width: 100%; + line-height: inherit; + font-size: 13px; + color: var(--primaryColor); + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: top; + &[data-is-disabled='true'] { + color: rgb(var(--gray-6)); + cursor: not-allowed; + } +} +.tag_container{ + &.arco-tag { + border: none; + color: rgb(var(--gray-10)); + } +} + diff --git a/web_console_v2/client/src/views/ModelServing/ModelServingTable.tsx b/web_console_v2/client/src/views/ModelServing/ModelServingTable.tsx new file mode 100644 index 000000000..398bd36b8 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ModelServingTable.tsx @@ -0,0 +1,269 @@ +import React, { FC } from 'react'; + +import { formatTimestamp } from 'shared/date'; +import { modelDirectionTypeToTextMap, getDotState, modelServingStateToTextMap } from './shared'; + +import Table from 'components/Table'; +import MoreActions from 'components/MoreActions'; +import StateIndicator from 'components/StateIndicator'; + +import { Tag } from '@arco-design/web-react'; +import { ModelServing, ModelDirectionType, ModelServingState } from 'typings/modelServing'; + +import { TableColumnProps, TableProps, Space } from '@arco-design/web-react'; +import { SorterResult, SortDirection } from '@arco-design/web-react/es/Table/interface'; + +import styles from './ModelServingTable.module.less'; + +type FilterValue = string[]; + +type ColumnsGetterOptions = { + filter?: Record<string, FilterValue>; + sorter?: Record<string, SortDirection>; + onScaleClick?: any; + onDeleteClick?: any; + onEditClick?: any; +}; + +const getTableColumns = (options: ColumnsGetterOptions) => { + const cols: TableColumnProps[] = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + width: 200, + ellipsis: true, + render: (name) => { + return <span className={styles.edit_text_container}>{name}</span>; + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 130, + filterMultiple: false, + filteredValue: options?.filter?.status || [], + filters: [ + { + text: modelServingStateToTextMap.AVAILABLE, + value: ModelServingState.AVAILABLE, + }, + { + text: modelServingStateToTextMap.LOADING, + value: ModelServingState.LOADING, + }, + { + text: modelServingStateToTextMap.UNKNOWN, + value: ModelServingState.UNKNOWN, + }, + { + text: modelServingStateToTextMap.UNLOADING, + value: ModelServingState.UNLOADING, + }, + { + text: modelServingStateToTextMap.PENDING_ACCEPT, + value: ModelServingState.PENDING_ACCEPT, + }, + ], + render: (_, record) => { + return <StateIndicator {...getDotState(record)} />; + }, + }, + { + title: '模型类型', + dataIndex: 'is_local', + key: 'is_local', + width: 150, + filteredValue: options?.filter?.is_local || [], + filters: [ + { + text: modelDirectionTypeToTextMap.horizontal, + value: 'true', + }, + { + text: modelDirectionTypeToTextMap.vertical, + value: 'false', + }, + ], + filterMultiple: false, + render: (type: boolean) => { + const modelType = type ? ModelDirectionType.HORIZONTAL : ModelDirectionType.VERTICAL; + return ( + <Tag + className={styles.tag_container} + style={{ + background: + modelType === ModelDirectionType.HORIZONTAL + ? 'rgb(var(--orange-1))' + : 'rgb(var(--blue-1))', + }} + > + {modelDirectionTypeToTextMap[modelType]} + </Tag> + ); + }, + }, + { + title: '实例数量', + dataIndex: 'instance_num_status', + key: 'instance_num_status', + width: 100, + render: (value) => value || '-', + }, + { + title: '调用权限', + dataIndex: 'support_inference', + key: 'support_inference', + width: 100, + render(val: boolean) { + return ( + <Tag style={{ background: val ? 'rgb(var(--green-1))' : 'rgb(var(--gray-2))' }}> + {val ? '可调用' : '不可调用'} + </Tag> + ); + }, + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 200, + sorter: true, + sortOrder: options.sorter?.created_at || undefined, + render: (date: number) => formatTimestamp(date), + }, + { + title: '操作', + dataIndex: 'operation', + key: 'operation', + fixed: 'right', + width: 120, + render: (_, record) => { + const isDisabled = + record.status !== ModelServingState.AVAILABLE || record.resource === undefined; + //TODO:等后端支持手动更新模型 + // const editIsDisabled = ![ModelServingState.AVAILABLE, ModelServingState.LOADING].includes( + // record.status, + // ); + + return ( + <Space> + <span + className={styles.edit_text_container} + data-is-disabled={isDisabled} + onClick={(event) => { + event.stopPropagation(); + if (!isDisabled) { + options?.onScaleClick(record); + } + }} + > + 扩缩容 + </span> + <MoreActions + actionList={[ + { + label: '编辑', + //TODO:等后端支持手动更新模型 + //disabled: editIsDisabled, + onClick: () => { + options?.onEditClick(record); + }, + }, + { + label: '删除', + onClick: () => { + options?.onDeleteClick(record); + }, + danger: true, + }, + ]} + /> + </Space> + ); + }, + }, + ]; + + return cols; +}; + +interface Props extends TableProps<ModelServing> { + total?: number; + loading: boolean; + dataSource: any[]; + filter?: Record<string, FilterValue>; + sorter?: Record<string, SortDirection>; + onRowClick?: (record: ModelServing) => void; + onEditClick?: (record: ModelServing) => void; + onScaleClick?: (record: ModelServing) => void; + onDeleteClick?: (record: ModelServing) => void; + onShowSizeChange?: (current: number, size: number) => void; + onPageChange?: (page: number, pageSize: number) => void; + onSortChange?: (sorter: SorterResult) => void; + onFilterChange?: (filter: Record<string, any>) => void; +} + +const ModelServingTable: FC<Props> = ({ + total, + loading, + filter, + sorter, + dataSource, + onRowClick, + onEditClick, + onScaleClick, + onDeleteClick, + onShowSizeChange, + onPageChange, + onSortChange, + onFilterChange, + ...restProps +}) => { + return ( + <> + <Table<ModelServing> + className="customFilterIconTable" + rowKey="id" + scroll={{ x: '100%' }} + loading={loading} + total={total} + data={dataSource} + columns={getTableColumns({ + filter, + sorter, + onEditClick, + onScaleClick, + onDeleteClick, + })} + onChange={(pagination, sorter, filters, extra) => { + const { action } = extra; + + switch (action) { + case 'paginate': + onShowSizeChange?.(pagination.current as number, pagination.pageSize as number); + break; + case 'sort': + onSortChange?.(sorter as SorterResult); + break; + case 'filter': + onFilterChange?.(filters); + break; + default: + } + }} + onShowSizeChange={onShowSizeChange} + onPageChange={onPageChange} + onRow={(record) => ({ + onClick: () => { + onRowClick?.(record); + }, + })} + {...restProps} + /> + </> + ); +}; + +export default ModelServingTable; diff --git a/web_console_v2/client/src/views/ModelServing/ServiceEditModal.module.less b/web_console_v2/client/src/views/ModelServing/ServiceEditModal.module.less new file mode 100644 index 000000000..43c34f485 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ServiceEditModal.module.less @@ -0,0 +1,17 @@ +.key_container{ + color: rgb(var(--gray-10)); +} +.value_container{ + color: rgb(var(--gray-10)); +} +.row_container{ + margin-bottom: 20px; + text-align: left; +} + +.label_col_container{ + text-align: right; +} +.text_area_container{ + font-size: 12px; +} diff --git a/web_console_v2/client/src/views/ModelServing/ServiceEditModal.tsx b/web_console_v2/client/src/views/ModelServing/ServiceEditModal.tsx new file mode 100644 index 000000000..860ea6399 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ServiceEditModal.tsx @@ -0,0 +1,88 @@ +import React, { FC } from 'react'; +import { Input, Grid, Button } from '@arco-design/web-react'; +import { ModelServing } from 'typings/modelServing'; +import Modal from 'components/Modal'; +import ButtonWithPopconfirm from 'components/ButtonWithPopconfirm'; + +import styles from './ServiceEditModal.module.less'; + +export interface Props { + service: ModelServing; + onChange: (params: Partial<ModelServing>) => void; +} + +const { Row, Col } = Grid; +const { TextArea } = Input; + +const ServiceEditModal: FC<Props> = ({ service, onChange }) => { + return ( + <div> + <Row className={styles.row_container} gutter={16}> + <Col className={styles.label_col_container} span={6}> + <span className={styles.key_container}>在线服务名称</span> + </Col> + <Col span={18}> + <span className={styles.value_container}>{service.name}</span> + </Col> + </Row> + <Row gutter={16}> + <Col className={styles.label_col_container} span={6}> + <span className={styles.key_container}>在线服务描述</span> + </Col> + <Col span={18}> + <TextArea + className={styles.text_area_container} + placeholder={'请输入'} + defaultValue={service.comment} + rows={3} + onChange={handleChange} + /> + </Col> + </Row> + </div> + ); + + function handleChange(comment: string) { + onChange({ + comment, + }); + } +}; + +export default ServiceEditModal; +export function editService(service: ModelServing, onOk: (params: Partial<ModelServing>) => void) { + let serviceParams: Partial<ModelServing> = {}; + serviceParams = { resource: service.resource, comment: service.comment }; + const modal = Modal.confirm({ + icon: null, + title: '在线服务信息', + content: ( + <ServiceEditModal + service={service} + onChange={(params) => { + serviceParams = { ...serviceParams, ...params }; + }} + /> + ), + footer: [ + <ButtonWithPopconfirm + key="back" + buttonText={'取消'} + onConfirm={() => { + modal.close(); + }} + />, + <Button + style={{ marginLeft: 12 }} + key="submit" + type="primary" + onClick={async () => { + await onOk(serviceParams); + modal.close(); + }} + > + 提交 + </Button>, + ], + }); +} diff --git a/web_console_v2/client/src/views/ModelServing/ServiceInstanceScaleDrawer.module.less b/web_console_v2/client/src/views/ModelServing/ServiceInstanceScaleDrawer.module.less new file mode 100644 index 000000000..1e36a2244 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ServiceInstanceScaleDrawer.module.less @@ -0,0 +1,10 @@ +.div_container{ + font-size: 12px; + color: rgb(var(--gray-8)); +} +.title_container{ + margin-bottom: 5px; +} +.text_container{ + color: rgb(var(--gray-10)); +} diff --git a/web_console_v2/client/src/views/ModelServing/ServiceInstanceScaleDrawer.tsx b/web_console_v2/client/src/views/ModelServing/ServiceInstanceScaleDrawer.tsx new file mode 100644 index 000000000..3dac7ea3f --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/ServiceInstanceScaleDrawer.tsx @@ -0,0 +1,60 @@ +import React, { FC } from 'react'; +import { Spin } from '@arco-design/web-react'; +import { ModelServing } from 'typings/modelServing'; +import InstanceNumberInput from './InstanceNumberInput'; +import drawerConfirm from 'components/DrawerConfirm'; + +import styles from './ServiceInstanceScaleDrawer.module.less'; + +export type TProps = { + service?: ModelServing; + onChange?: (instanceNum: number) => void; +}; + +const ModelServingScaleDrawer: FC<TProps> = ({ service, onChange }) => { + return ( + <div> + {!service ? ( + <Spin loading={true} /> + ) : ( + <> + <div className={styles.div_container}> + <p className={styles.title_container}>实例规格</p> + <p className={styles.text_container}> + {service?.resource?.cpu} + {service?.resource?.memory} + </p> + </div> + <div className={styles.div_container} style={{ marginTop: 20 }}> + <p className={styles.title_container}>实例数</p> + <InstanceNumberInput + min={1} + max={100} + precision={0} + defaultValue={service?.resource?.replicas} + onChange={onChange} + /> + </div> + </> + )} + </div> + ); +}; + +export function handleScaleEdit( + service: ModelServing, + onUpdate: (instanceNum: number) => Promise<any>, +) { + drawerConfirm({ + title: '扩缩容', + okText: '确认', + cancelText: '取消', + onOk: (instanceNum: number) => { + return onUpdate(instanceNum); + }, + renderContent(setOkParams) { + return <ModelServingScaleDrawer service={service} onChange={setOkParams} />; + }, + }); +} + +export default ModelServingScaleDrawer; diff --git a/web_console_v2/client/src/views/ModelServing/UserGuideTab/index.module.less b/web_console_v2/client/src/views/ModelServing/UserGuideTab/index.module.less new file mode 100644 index 000000000..8f5043b8c --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/UserGuideTab/index.module.less @@ -0,0 +1,37 @@ + +.div_container{ + flex: 1; + margin-bottom: 29px; +} + +.content_block{ + margin-right: 4px; +} + +.label_container{ + margin-bottom: 5px; + color: rgb(var(--gray-8)); +} + +.copy_icon_container{ + font-size: 14px; + &:hover { + color: #1664ff; + } +} + +.open_signature_btn{ + color: rgb(var(--arcoblue-6)) !important; +} + +.info_item_container{ + --height: 32px; + box-sizing: border-box; + padding: 0 10px; + border-radius: 2px; + border: 1px solid var(--lineColor); + height: var(--height); + font-size: 12px; + line-height: var(--height); + background: rgb(var(--gray-2)); +} diff --git a/web_console_v2/client/src/views/ModelServing/UserGuideTab/index.tsx b/web_console_v2/client/src/views/ModelServing/UserGuideTab/index.tsx new file mode 100644 index 000000000..5575dc0fa --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/UserGuideTab/index.tsx @@ -0,0 +1,77 @@ +import React, { FC, useState } from 'react'; +import { Button, Drawer, Space } from '@arco-design/web-react'; +import ClickToCopy from 'components/ClickToCopy'; +import { Copy } from 'components/IconPark'; +import CodeEditor from 'components/CodeEditor'; +import { formatJSONValue } from 'shared/helpers'; +import { CONSTANTS } from 'shared/constants'; + +import { ModelServing } from 'typings/modelServing'; + +import styles from './index.module.less'; + +type Props = { + data?: ModelServing; + isShowLabel?: boolean; + isShowSignature?: boolean; +}; + +const UserGuideTab: FC<Props> = ({ data, isShowLabel, isShowSignature = true }) => { + const [drawerVisible, setDrawerVisible] = useState<boolean>(false); + + // TODO: user guide field + const feature = CONSTANTS.EMPTY_PLACEHOLDER; + const url = data?.endpoint ?? CONSTANTS.EMPTY_PLACEHOLDER; + + return ( + <div className={styles.div_container}> + <Space size="medium"> + <div className={styles.content_block}> + <p className={styles.label_container}>访问地址</p> + <div className={styles.info_item_container}> + <ClickToCopy text={String(url)}> + <Space size="medium"> + <span>{url}</span> + <Copy className={styles.copy_icon_container} /> + </Space> + </ClickToCopy> + </div> + </div> + {isShowSignature && ( + <div className={styles.content_block}> + <p className={styles.label_container}>Signature</p> + <Button className={styles.open_signature_btn} onClick={toggleDrawerVisible}> + 查看 + </Button> + </div> + )} + {isShowLabel && ( + <div className={styles.content_block}> + <p className={styles.label_container}>本侧特征</p> + <p className={styles.label_container}>{feature}</p> + </div> + )} + </Space> + <Drawer + width={720} + visible={drawerVisible} + title={'Signature'} + closable={true} + onCancel={toggleDrawerVisible} + > + <CodeEditor + language="json" + isReadOnly={true} + theme="grey" + value={data?.signature ? formatJSONValue(data.signature) : ''} + /> + </Drawer> + </div> + ); + + function toggleDrawerVisible() { + setDrawerVisible(!drawerVisible); + } +}; + +export default UserGuideTab; diff --git a/web_console_v2/client/src/views/ModelServing/index.tsx b/web_console_v2/client/src/views/ModelServing/index.tsx new file mode 100644 index 000000000..65ad2ce35 --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/index.tsx @@ -0,0 +1,25 @@ +import ErrorBoundary from 'components/ErrorBoundary'; +import React, { FC } from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import ModelServingList from 'views/ModelServing/ModelServingList'; +import ModelServingForm from 'views/ModelServing/ModelServingForm'; +import ModelServingDetail from 'views/ModelServing/ModelServingDetail'; + +const ModelServing: FC = () => { + return ( + <ErrorBoundary> + <Switch> + <Route path="/model-serving" exact component={ModelServingList} /> + <Route + path="/model-serving/:action(create|edit)/:role(sender|receiver)?/:id?" + exact + component={ModelServingForm} + /> + <Route path="/model-serving/detail/:id/:tabType?" exact component={ModelServingDetail} /> + </Switch> + </ErrorBoundary> + ); +}; + +export default ModelServing; diff --git a/web_console_v2/client/src/views/ModelServing/shared.ts b/web_console_v2/client/src/views/ModelServing/shared.ts new file mode 100644 index 000000000..907c6602b --- /dev/null +++ b/web_console_v2/client/src/views/ModelServing/shared.ts @@ -0,0 +1,141 @@ +/* istanbul ignore file */ + +import { Message } from '@arco-design/web-react'; +import { updateModelServing_new } from 'services/modelServing'; + +import { ModelDirectionType, ModelServing, ModelServingState } from 'typings/modelServing'; +import { handleScaleEdit } from './ServiceInstanceScaleDrawer'; +import { ActionItem, StateTypes } from 'components/StateIndicator'; +import { editService } from './ServiceEditModal'; +import { FilterOp } from 'typings/filter'; + +export const modelDirectionTypeToTextMap = { + [ModelDirectionType.HORIZONTAL]: '横向联邦', + [ModelDirectionType.VERTICAL]: '纵向联邦', +}; + +export const modelServingStateToTextMap = { + [ModelServingState.AVAILABLE]: '运行中', + [ModelServingState.LOADING]: '部署中', + [ModelServingState.UNLOADING]: '删除中', + [ModelServingState.UNKNOWN]: '异常', + [ModelServingState.PENDING_ACCEPT]: '待合作伙伴配置', + [ModelServingState.WAITING_CONFIG]: '待合作伙伴配置', + [ModelServingState.DELETED]: '异常', +}; + +// 打开修改服务实例数的 drawer,并处理 UI 和业务逻辑 +export async function updateServiceInstanceNum(service: ModelServing, onUpdate: () => void) { + handleScaleEdit(service, async (instanceNum: number) => { + try { + await updateModelServing_new(service.project_id, service.id, { + model_type: undefined, // 后端暂时不支持 model_type 字段 + resource: { + ...service.resource, + replicas: instanceNum, + }, + }); + onUpdate(); + } catch (e) { + Message.error(e.message); + throw e; + } + }); +} + +export function getDotState( + modelServing: ModelServing, +): { type: StateTypes; text: string; tip?: string; actionList?: ActionItem[] } { + const text = modelServingStateToTextMap[modelServing.status] || '异常'; + + if (modelServing.status === ModelServingState.AVAILABLE) { + return { + text, + type: 'success', + }; + } + if (modelServing.status === ModelServingState.LOADING) { + return { + text, + type: 'processing', + }; + } + + if (modelServing.status === ModelServingState.UNLOADING) { + return { + text, + type: 'gold', + }; + } + + if (modelServing.status === ModelServingState.UNKNOWN) { + return { + text, + type: 'error', + }; + } + + if (modelServing.status === ModelServingState.PENDING_ACCEPT) { + return { + text, + type: 'pending_accept', + }; + } + + if (modelServing.status === ModelServingState.WAITING_CONFIG) { + return { + text, + type: 'pending_accept', + }; + } + + if (modelServing.status === ModelServingState.DELETED) { + return { + text, + type: 'deleted', + tip: '对侧已经删除', + }; + } + + return { + text, + type: 'error', + }; +} + +export function handleServiceEdit(record: ModelServing) { + return new Promise((resolve, reject) => { + editService(record, async (params: any) => { + try { + await updateModelServing_new(record.project_id, record.id, params); + Message.success('修改成功'); + resolve(params); + } catch (error) { + Message.error(error.message); + reject(error); + } + }); + }); +} + +export function getTableFilterValue(val: string): string[] { + if (typeof val === 'undefined') { + return []; + } + return [val]; +} + +export function cpuIsCpuM(cpu: string): boolean { + const regx = new RegExp('[0-9]+m$'); + return regx.test(cpu); +} + +export function memoryIsMemoryGi(memory: string): boolean { + const regx = new RegExp('[0-9]+Gi$'); + return regx.test(memory); +} + +export const FILTER_SERVING_OPERATOR_MAPPER = { + name: FilterOp.EQUAL, + keyword: FilterOp.CONTAIN, +}; diff --git a/web_console_v2/client/src/views/OperationMaintenance/OperationList/JobDetailDrawer.module.less b/web_console_v2/client/src/views/OperationMaintenance/OperationList/JobDetailDrawer.module.less new file mode 100644 index 000000000..f21264222 --- /dev/null +++ b/web_console_v2/client/src/views/OperationMaintenance/OperationList/JobDetailDrawer.module.less @@ -0,0 +1,11 @@ +.drawer_content{ + flex: 1; +} +.header_container{ + display: flex; + justify-content: space-between; + align-items: center; +} +.copy_button{ + color: var(--textColor); +} diff --git a/web_console_v2/client/src/views/OperationMaintenance/OperationList/JobDetailDrawer.tsx b/web_console_v2/client/src/views/OperationMaintenance/OperationList/JobDetailDrawer.tsx new file mode 100644 index 000000000..211b7173c --- /dev/null +++ b/web_console_v2/client/src/views/OperationMaintenance/OperationList/JobDetailDrawer.tsx @@ -0,0 +1,89 @@ +import React, { useMemo } from 'react'; +import { Drawer, DrawerProps, Button, Message } from '@arco-design/web-react'; +import { useQuery } from 'react-query'; +import { fetchOperationDetail } from 'services/operation'; +import CodeEditor from 'components/CodeEditor'; +import { IconCopy } from '@arco-design/web-react/icon'; +import { copyToClipboard, formatJSONValue } from 'shared/helpers'; + +import styles from './JobDetailDrawer.module.less'; + +const ContentHeight = '119px'; // 55(drawer header height) + 16*2(content padding) + 32(header height) + +export interface Props { + data?: string; +} + +export interface Props extends DrawerProps { + data?: string; +} + +function JobDetailDrawer({ visible, data, title = '工作详情', ...restProps }: Props) { + const jobInfo = useQuery( + ['fetchOperationDetail', data], + () => { + if (data === '') return; + return fetchOperationDetail({ + job_name: data || '', + }); + }, + { + retry: 0, + cacheTime: 0, + refetchOnWindowFocus: false, + }, + ); + const job = useMemo(() => { + if (!jobInfo.data) return 'this job_name is not exits'; + return JSON.stringify(jobInfo.data); + }, [jobInfo]); + + function renderCodeEditorLayout() { + return ( + <> + <div className={styles.header_container}> + <Button + className={styles.copy_button} + icon={<IconCopy />} + onClick={onCopyClick} + type="text" + > + 复制 + </Button> + </div> + <CodeEditor + language="json" + isReadOnly={true} + theme="grey" + height={`calc(100vh - ${ContentHeight})`} + value={formatJSONValue(job ?? '')} + /> + </> + ); + } + + return ( + <Drawer + placement="right" + title={title} + closable={true} + width="50%" + visible={visible} + unmountOnExit + {...restProps} + > + <div className={styles.drawer_content}>{renderCodeEditorLayout()}</div> + </Drawer> + ); + + function onCopyClick() { + const isOK = copyToClipboard(formatJSONValue(job ?? '')); + if (isOK) { + Message.success('复制成功'); + } else { + Message.error('复制失败'); + } + } +} + +export default JobDetailDrawer; diff --git a/web_console_v2/client/src/views/OperationMaintenance/OperationList/index.module.less b/web_console_v2/client/src/views/OperationMaintenance/OperationList/index.module.less new file mode 100644 index 000000000..fed4f562b --- /dev/null +++ b/web_console_v2/client/src/views/OperationMaintenance/OperationList/index.module.less @@ -0,0 +1,14 @@ +.div_container{ + display: flex; + justify-content: space-between; + flex-wrap: wrap; + width: 100%; + padding: 20px 20px 0px 20px; +} +.form_container{ + width: 35%; + padding: 20px; +} +.table_container{ + width: 60%; +} diff --git a/web_console_v2/client/src/views/OperationMaintenance/OperationList/index.tsx b/web_console_v2/client/src/views/OperationMaintenance/OperationList/index.tsx new file mode 100644 index 000000000..9b22838b6 --- /dev/null +++ b/web_console_v2/client/src/views/OperationMaintenance/OperationList/index.tsx @@ -0,0 +1,271 @@ +import React, { FC, useMemo, useState } from 'react'; +import { Form, Input, Select, Button, Table, Message, Space } from '@arco-design/web-react'; +import { Role, JobGroupFetchPayload, JobItem } from 'typings/operation'; +import { useQuery } from 'react-query'; +import { fetchOperationList } from 'services/operation'; +import { fetchProjectList } from 'services/project'; +import SharedPageLayout from 'components/SharedPageLayout'; +import JobDetailDrawer from './JobDetailDrawer'; + +import styles from './index.module.less'; + +const FormItem = Form.Item; + +const initialFormValues: Partial<JobGroupFetchPayload> = { + role: undefined, + name_prefix: '', + project_name: undefined, + e2e_image_url: '', + fedlearner_image_uri: '', + platform_endpoint: undefined, +}; + +function equals(x: any, y: any) { + const f1 = x instanceof Object; + const f2 = y instanceof Object; + if (!f1 || !f2) { + return x === y; + } + if (Object.keys(x).length !== Object.keys(y).length) { + return false; + } + const newX = Object.keys(x); + for (let p in newX) { + p = newX[p]; + const a = x[p] instanceof Object; + const b = y[p] instanceof Object; + if (a && b) { + equals(x[p], y[p]); + } else if (x[p] !== y[p]) { + return false; + } + } + return true; +} + +const OperationList: FC = () => { + const [formInstance] = Form.useForm<JobGroupFetchPayload>(); + const [formParams, setFormParams] = useState<Partial<JobGroupFetchPayload>>(initialFormValues); + const [isShowJobDetailDrawer, setIsShowJobDetailDrawer] = useState(false); + const [jobName, setJobName] = useState(''); + const columns = [ + { + title: 'K8s Job名称', + dataIndex: 'job_name', + key: 'job_name', + }, + { + title: '测试类型', + dataIndex: 'job_type', + key: 'job_type', + }, + { + title: '测试状态', + key: 'operation', + render: (col: any, record: JobItem) => { + return ( + <Space> + <Button type="text" onClick={() => onCheck(record)}> + 查看 + </Button> + </Space> + ); + }, + }, + ]; + const roleOptions = [ + { + label: Role.COORDINATOR, + value: Role.COORDINATOR, + }, + { + label: Role.PARTICIPANT, + value: Role.PARTICIPANT, + }, + ]; + + const listQuery = useQuery( + ['fetchOperationList', formParams], + () => { + const flag = equals(formParams, initialFormValues); + if (flag) return; + return fetchOperationList(formParams); + }, + { + retry: 0, + cacheTime: 0, + refetchOnWindowFocus: false, + onError(e: any) { + if (e.code === 400 || /already\s*exist/.test(e.message)) { + Message.error('工作已存在,请更改name_prefix字段'); + } else { + Message.error(e.message); + } + }, + }, + ); + const projectList = useQuery(['fetchProjectList'], () => { + return fetchProjectList(); + }); + const projectOptions = useMemo(() => { + if (!projectList.data?.data) return []; + const tempData: Array<{ label: string; value: string }> = []; + projectList.data.data.forEach((item) => { + const temp = { + label: item.name, + value: item.name, + }; + tempData.push(temp); + }); + return tempData; + }, [projectList]); + + const list = useMemo(() => { + if (!listQuery.data?.data) { + return []; + } + const list = listQuery.data.data; + return list; + }, [listQuery.data]); + + async function submitForm() { + const value = formInstance.getFieldsValue(); + const flag = equals(value, formParams); + try { + await formInstance.validate(); + if (flag) { + Message.error('工作已存在,请更改name_prefix字段'); + } else { + setFormParams(value); + } + } catch (e) { + Message.error('校验不通过'); + } + } + + function onCheck(record: JobItem) { + setJobName(record.job_name); + setIsShowJobDetailDrawer(true); + } + + return ( + <SharedPageLayout title={'基础功能测试'}> + <div className={styles.div_container}> + <div className={styles.form_container}> + <Form + initialValues={initialFormValues} + labelCol={{ span: 10 }} + wrapperCol={{ span: 14 }} + form={formInstance} + > + <FormItem + label="role" + field="role" + required + rules={[ + { + type: 'string', + required: true, + message: '请选择role', + }, + ]} + > + <Select placeholder="please select" options={roleOptions} allowClear /> + </FormItem> + <FormItem + label="name_prefix" + field="name_prefix" + required + rules={[ + { + type: 'string', + required: true, + min: 5, + message: '请输入name_prefix,最少5个字符', + }, + ]} + > + <Input placeholder="please enter name_prefix, minimun 5 characters" /> + </FormItem> + <FormItem + label="project_name" + field="project_name" + required + rules={[ + { + type: 'string', + required: true, + message: '请选择project_name', + }, + ]} + > + <Select placeholder="please select" options={projectOptions} allowClear /> + </FormItem> + <FormItem + label="e2e_image_uri" + field="e2e_image_uri" + required + rules={[ + { + type: 'string', + required: true, + message: '请输入e2e_image_uri', + }, + ]} + > + <Input placeholder="please enter e2e_image_uri" /> + </FormItem> + <FormItem + label="fedlearner_image_uri" + field="fedlearner_image_uri" + required + rules={[ + { + type: 'string', + required: true, + message: '请输入fedlearner_image_uri', + }, + ]} + > + <Input placeholder="please enter fedlearner_image_uri" /> + </FormItem> + <FormItem label="platform_endpoint" field="platform_endpoint"> + <Input placeholder="please enter platform_endpoint" /> + </FormItem> + </Form> + <FormItem + wrapperCol={{ + offset: 10, + }} + > + <Button type="primary" style={{ marginRight: 24 }} onClick={submitForm}> + 提交 + </Button> + <Button + style={{ marginRight: 24 }} + onClick={() => { + formInstance.resetFields(); + }} + > + 重置 + </Button> + </FormItem> + </div> + <div className={styles.table_container}> + <Table columns={columns} data={list} style={{ height: '400px' }} rowKey="job_name" /> + </div> + <JobDetailDrawer + visible={isShowJobDetailDrawer} + data={jobName} + onCancel={onJobDetailDrawerClose} + /> + </div> + </SharedPageLayout> + ); + + function onJobDetailDrawerClose() { + setIsShowJobDetailDrawer(false); + } +}; + +export default OperationList; diff --git a/web_console_v2/client/src/views/OperationMaintenance/index.tsx b/web_console_v2/client/src/views/OperationMaintenance/index.tsx new file mode 100644 index 000000000..b3c82c1db --- /dev/null +++ b/web_console_v2/client/src/views/OperationMaintenance/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import OperationList from './OperationList'; + +function OperationMaintenancePage() { + return ( + <> + <Route path="/operation" exact component={OperationList} /> + </> + ); +} + +export default OperationMaintenancePage; diff --git a/web_console_v2/client/src/views/Partner/AddPartnerForm/index.module.less b/web_console_v2/client/src/views/Partner/AddPartnerForm/index.module.less new file mode 100644 index 000000000..7173d10cb --- /dev/null +++ b/web_console_v2/client/src/views/Partner/AddPartnerForm/index.module.less @@ -0,0 +1,39 @@ +.form_list{ + padding: 0px 20px; +} +.form_container{ + margin-top: 20px; +} + +.think_divider{ + && { + margin-bottom: 0; + } +} +.delete_button{ + && { + color: #ffffff; + background-color: rgb(var(--red-6)); + } + + &&&:hover { + color: #ffffff; + background-color: rgb(var(--red-6)); + } +} + +.row_container{ + color: var(--primaryColor); + cursor: pointer; + + &:hover { + color: rgb(var(--arcoblue-5)); + } +} + +.title_container{ + font-size: 14px; + font-weight: 500; + color: #000000; + margin-top: 40px; +} diff --git a/web_console_v2/client/src/views/Partner/AddPartnerForm/index.tsx b/web_console_v2/client/src/views/Partner/AddPartnerForm/index.tsx new file mode 100644 index 000000000..8a2e17484 --- /dev/null +++ b/web_console_v2/client/src/views/Partner/AddPartnerForm/index.tsx @@ -0,0 +1,323 @@ +import React, { FC, useState } from 'react'; +import { + Form, + Input, + Divider, + Button, + Popconfirm, + Switch, + Select, + Grid, +} from '@arco-design/web-react'; +import { MAX_COMMENT_LENGTH, validParticipantNamePattern } from 'shared/validator'; +import GridRow from 'components/_base/GridRow'; +import { Plus } from 'components/IconPark'; +import { IconDelete } from '@arco-design/web-react/icon'; +import { Participant } from 'typings/participant'; +import FormLabel from 'components/FormLabel'; +import { DOMAIN_PREFIX, DOMAIN_SUFFIX } from 'shared/project'; +import { fetchDomainNameList } from 'services/participant'; +import { useQuery } from 'react-query'; +import { ParticipantType } from 'typings/participant'; + +import styles from './index.module.less'; + +const { Row } = Grid; + +interface Props { + onFinish: (value: any) => void; + data?: Participant; + isEdit: boolean; + /** + * @deprecated + * + * multi-add mode + */ + needAdd: boolean; +} + +const AddPartnerForm: FC<Props> = ({ onFinish, isEdit, needAdd, data }) => { + const [form] = Form.useForm(); + const [isManual, setIsManual] = useState(data?.extra?.is_manual_configured); + const [isLightClient, setIsLightClient] = useState(data?.type === ParticipantType.LIGHT_CLIENT); + + const domainNameListQuery = useQuery(['fetchDomainNameList'], () => fetchDomainNameList(), { + retry: 2, + refetchOnWindowFocus: false, + }); + + const dataInitial = + isEdit && data + ? [ + { + ...data, + domain_name: data.domain_name.slice(3, -4), + type: + (data.type ?? ParticipantType.PLATFORM) === ParticipantType.LIGHT_CLIENT + ? true + : false, + }, + ] + : [ + { + extra: { + is_manual_configured: false, + grpc_ssl_server_host: 'x-host', + }, + type: false, + }, + ]; + + const isShowManualInfo = isManual || isLightClient; + + return ( + <Form + form={form} + layout="vertical" + onSubmit={(value: any) => { + onFinish( + value.participants.map((item: any) => { + return isShowManualInfo + ? { + ...item, + domain_name: `fl-${item.domain_name}.com`, + type: item.type ? ParticipantType.LIGHT_CLIENT : ParticipantType.PLATFORM, + } + : { + ...item, + type: item.type ? ParticipantType.LIGHT_CLIENT : ParticipantType.PLATFORM, + }; + }), + ); + }} + > + <Form.List field="participants" initialValue={dataInitial as any}> + {(fields, { add, remove }) => { + return ( + <> + <div className={styles.form_list} id="add-modal"> + {fields.map((field, index) => ( + <div className={styles.form_container} key={field.field + index}> + {needAdd && <p className={styles.title_container}>合作伙伴{index + 1}</p>} + <Form.Item + label="企业名称" + field={field.field + '.name'} + rules={[ + { + required: true, + message: '请输入企业名称', + }, + { + match: validParticipantNamePattern, + message: + '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ]} + > + <Input placeholder="请输入企业名称" /> + </Form.Item> + + <Form.Item + hidden={isEdit} + label="是否轻量级客户端" + field={field.field + '.type'} + triggerPropName="checked" + > + <Switch + onChange={(checked: boolean) => { + setIsLightClient(checked); + setIsManual(false); + // Reset field + const newData = [...form.getFieldValue('participants')]; + newData[field.key] = { + ...newData[field.key], + domain_name: undefined, + host: undefined, + port: undefined, + extra: { + ...newData[field.key].extra, + is_manual_configured: false, + }, + }; + form.setFieldsValue({ + participants: newData, + }); + }} + /> + </Form.Item> + + {!isEdit && !isLightClient && ( + <Form.Item + label={ + <FormLabel + label="是否手动配置" + tooltip="默认将使用平台配置,若您有手动配置需求且对配置内容了解,可点击进行手动配置" + /> + } + field={field.field + '.extra.is_manual_configured'} + triggerPropName="checked" + > + <Switch + onChange={(checked: boolean) => { + setIsManual(checked); + // Reset domain_name field + const newData = [...form.getFieldValue('participants')]; + newData[field.key] = { + ...newData[field.key], + domain_name: undefined, + }; + form.setFieldsValue({ + participants: newData, + }); + }} + /> + </Form.Item> + )} + + <Form.Item + label="泛域名" + field={field.field + '.domain_name'} + rules={ + isShowManualInfo + ? [ + { + required: true, + message: '请输入泛域名', + }, + { + match: /^[0-9a-z-]+$/g, + message: '只允许小写英文字母/中划线/数字,请检查', + }, + ] + : [ + { + required: true, + message: '请选择泛域名', + }, + ] + } + > + {isShowManualInfo ? ( + <Input + placeholder="请输入泛域名" + addBefore={DOMAIN_PREFIX} + addAfter={DOMAIN_SUFFIX} + /> + ) : ( + <Select + loading={domainNameListQuery.isFetching} + placeholder="请选择泛域名" + showSearch + allowClear + > + {(domainNameListQuery.data?.data ?? []).map((item) => { + return ( + <Select.Option key={item.domain_name} value={item.domain_name}> + {item.domain_name} + </Select.Option> + ); + })} + </Select> + )} + </Form.Item> + + {isShowManualInfo && ( + <> + {!isLightClient && ( + <> + <Form.Item + label="主机号" + field={field.field + '.host'} + style={{ + width: '50%', + display: 'inline-block', + verticalAlign: 'top', + }} + rules={[ + { + required: true, + message: '请输入主机号', + }, + ]} + > + <Input placeholder="请输入主机号" /> + </Form.Item> + <Form.Item + label="端口号" + field={field.field + '.port'} + style={{ + marginLeft: '2%', + width: '48%', + display: 'inline-block', + verticalAlign: 'top', + }} + rules={[ + { + required: isManual, + message: '请输入端口号', + }, + { + match: /^[0-9]*$/g, + message: '端口号不合法,请检查', + }, + ]} + > + <Input placeholder="请输入端口号" /> + </Form.Item> + </> + )} + </> + )} + <Form.Item label="合作伙伴描述" field={field.field + '.comment'}> + <Input.TextArea + showWordLimit + maxLength={MAX_COMMENT_LENGTH} + placeholder="请为合作伙伴添加描述" + /> + </Form.Item> + + {needAdd && <Divider className={styles.think_divider} />} + {index !== 0 && ( + <Row justify="end"> + <Popconfirm + title="是否确定删除上面这个表单?" + onConfirm={() => remove(index)} + > + <Button className={styles.delete_button} icon={<IconDelete />}> + 删除 + </Button> + </Popconfirm> + </Row> + )} + </div> + ))} + </div> + <div style={{ padding: '0px 20px' }}> + <GridRow justify={needAdd ? 'space-between' : 'end'} align="center"> + {needAdd && ( + <GridRow + className={styles.row_container} + gap={5} + style={{ fontWeight: 500 }} + onClick={() => add()} + > + <Plus /> + <span>继续添加</span> + </GridRow> + )} + </GridRow> + <GridRow justify="center" style={{ marginTop: 48 }}> + <Button type="primary" htmlType="submit" style={{ padding: '0 74px' }}> + {isLightClient ? '提交' : '发送请求'} + </Button> + </GridRow> + </div> + </> + ); + }} + </Form.List> + </Form> + ); +}; + +export default AddPartnerForm; diff --git a/web_console_v2/client/src/views/Partner/CreatePartner/index.module.less b/web_console_v2/client/src/views/Partner/CreatePartner/index.module.less new file mode 100644 index 000000000..fb849a170 --- /dev/null +++ b/web_console_v2/client/src/views/Partner/CreatePartner/index.module.less @@ -0,0 +1,5 @@ + +.div_container{ + overflow-x: hidden; + overflow-y: scroll; +} diff --git a/web_console_v2/client/src/views/Partner/CreatePartner/index.tsx b/web_console_v2/client/src/views/Partner/CreatePartner/index.tsx new file mode 100644 index 000000000..5644a5c8e --- /dev/null +++ b/web_console_v2/client/src/views/Partner/CreatePartner/index.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import PartnerForm from '../PartnerForm'; +import { Message } from '@arco-design/web-react'; +import { createParticipant } from 'services/participant'; +import { useHistory } from 'react-router-dom'; +import BackButton from 'components/BackButton'; +import SharedPageLayout from 'components/SharedPageLayout'; + +import styles from './index.module.less'; + +const CreatePartner: FC = () => { + const history = useHistory(); + + return ( + <div className={styles.div_container}> + <SharedPageLayout + title={<BackButton onClick={() => history.goBack()}>合作伙伴列表</BackButton>} + centerTitle="添加合作伙伴" + > + <PartnerForm isEdit={false} onSubmit={onSubmit} /> + </SharedPageLayout> + </div> + ); + + async function onSubmit(payload: any) { + try { + await createParticipant(payload); + Message.success('添加成功'); + history.push('/partners'); + } catch (error: any) { + Message.error(error.message); + } + } +}; + +export default CreatePartner; diff --git a/web_console_v2/client/src/views/Partner/EditPartner/index.module.less b/web_console_v2/client/src/views/Partner/EditPartner/index.module.less new file mode 100644 index 000000000..96018684c --- /dev/null +++ b/web_console_v2/client/src/views/Partner/EditPartner/index.module.less @@ -0,0 +1,4 @@ +.div_container{ + overflow-x: hidden; + overflow-y: scroll; +} diff --git a/web_console_v2/client/src/views/Partner/EditPartner/index.tsx b/web_console_v2/client/src/views/Partner/EditPartner/index.tsx new file mode 100644 index 000000000..e7ec05b3e --- /dev/null +++ b/web_console_v2/client/src/views/Partner/EditPartner/index.tsx @@ -0,0 +1,63 @@ +import React, { FC, useMemo } from 'react'; +import { useParams, useHistory } from 'react-router-dom'; +import { Message } from '@arco-design/web-react'; +import PartnerForm from '../PartnerForm'; +import { getParticipantDetailById, updateParticipant } from 'services/participant'; +import { useQuery } from 'react-query'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BackButton from 'components/BackButton'; + +import styles from './index.module.less'; + +const EditPartner: FC = () => { + const history = useHistory(); + const { id: partnerId } = useParams<{ + id: string; + }>(); + + const { data: participantQuery, isLoading, isError, error } = useQuery( + ['getParticipantDetail', partnerId], + () => getParticipantDetailById(partnerId), + { + cacheTime: 0, + }, + ); + + if (isError && error) { + Message.error((error as Error).message); + history.push('/partners'); + } + + const dataProps = useMemo(() => { + if (!isLoading && participantQuery?.data) { + return { data: participantQuery?.data }; + } + return {}; + }, [isLoading, participantQuery?.data]); + + return ( + <div className={styles.div_container}> + <SharedPageLayout + title={<BackButton onClick={() => history.goBack()}>合作伙伴列表</BackButton>} + centerTitle="变更合作伙伴" + > + <PartnerForm isEdit={true} {...dataProps} onSubmit={onSubmit} /> + </SharedPageLayout> + </div> + ); + + async function onSubmit(payload: any) { + try { + // If there is no modification, no request will be sent + if (Object.keys(payload).length) { + await updateParticipant(partnerId, payload); + } + Message.success('变更合作伙伴成功!'); + history.push('/partners'); + } catch (error: any) { + Message.error(error.message); + } + } +}; + +export default EditPartner; diff --git a/web_console_v2/client/src/views/Partner/PartnerForm.tsx b/web_console_v2/client/src/views/Partner/PartnerForm.tsx new file mode 100644 index 000000000..61eaf296f --- /dev/null +++ b/web_console_v2/client/src/views/Partner/PartnerForm.tsx @@ -0,0 +1,84 @@ +import React, { FC } from 'react'; +import { Spin } from '@arco-design/web-react'; +import { CreateParticipantPayload, UpdateParticipantPayload } from 'typings/participant'; +import GridRow from 'components/_base/GridRow'; +import AddPartnerForm from './AddPartnerForm'; + +interface Props { + isEdit: boolean; + data?: any; + onSubmit: (payload: any) => Promise<void>; +} + +const PartnerForm: FC<Props> = ({ isEdit, data, onSubmit }) => { + const dataProps = isEdit && data ? { data } : {}; + + return ( + <GridRow justify="center" style={{ minHeight: '100%' }} align="start"> + <Spin loading={isEdit && !data}> + <div style={{ width: 600 }}> + {(!isEdit || data) && ( + <AddPartnerForm onFinish={onFinish} {...dataProps} isEdit={isEdit} needAdd={false} /> + )} + </div> + </Spin> + </GridRow> + ); + function onFinish(value: any) { + const valueOnly = value[0]; + let payload = {} as UpdateParticipantPayload | CreateParticipantPayload; + if (!isEdit) { + if (valueOnly?.extra?.is_manual_configured) { + Object.keys(valueOnly).forEach((key: any) => { + const _key = key as keyof CreateParticipantPayload; + if (key === 'extra') { + payload = { + ...payload, + is_manual_configured: valueOnly?.extra?.is_manual_configured ?? true, + grpc_ssl_server_host: valueOnly?.extra?.grpc_ssl_server_host ?? 'x-host', + }; + } else { + payload = { + ...payload, + [key]: valueOnly[_key], + }; + } + }); + } else { + payload = { + name: valueOnly.name, + domain_name: valueOnly.domain_name, + is_manual_configured: valueOnly?.extra?.is_manual_configured ?? false, + comment: valueOnly.comment, + type: valueOnly.type, + }; + } + } else { + data && + Object.keys(valueOnly).forEach((key: any) => { + const _key = key as keyof UpdateParticipantPayload; + if (key === 'extra') { + if (valueOnly?.extra?.is_manual_configured) { + const grpc_ssl_server_host = valueOnly?.extra?.grpc_ssl_server_host; + if (grpc_ssl_server_host !== data?.extra?.grpc_ssl_server_host) { + payload = { + ...payload, + grpc_ssl_server_host: valueOnly?.extra?.grpc_ssl_server_host, + }; + } + } + } else { + if (valueOnly[_key] !== data[_key]) { + payload = { + ...payload, + [key]: valueOnly[_key], + }; + } + } + }); + } + payload && onSubmit(payload); + } +}; + +export default PartnerForm; diff --git a/web_console_v2/client/src/views/Partner/PartnerList/ConnectionStatus.tsx b/web_console_v2/client/src/views/Partner/PartnerList/ConnectionStatus.tsx new file mode 100644 index 000000000..f26469b12 --- /dev/null +++ b/web_console_v2/client/src/views/Partner/PartnerList/ConnectionStatus.tsx @@ -0,0 +1,82 @@ +import React, { FC, useEffect, useMemo } from 'react'; +import { useSetRecoilState } from 'recoil'; + +import { useCheckConnection } from 'hooks/participant'; +import { participantConnectionState } from 'stores/participant'; +import { TIME_INTERVAL } from 'shared/constants'; + +import GridRow from 'components/_base/GridRow'; +import StateIndicator from 'components/StateIndicator'; + +import { ConnectionStatusType } from 'typings/participant'; + +export interface Props { + id: ID; + isNeedTip?: boolean; + isNeedReCheck?: boolean; +} + +export const globalParticipantIdToConnectionStateMap: { + [key: number]: ConnectionStatusType; +} = {}; + +const PaticipantConnectionStatus: FC<Props> = ({ + id, + isNeedTip = false, + isNeedReCheck = false, +}) => { + const setConnectionStatus = useSetRecoilState(participantConnectionState(id)); + + const [checkStatus, reCheck] = useCheckConnection(id, { + refetchOnWindowFocus: false, + refetchInterval: TIME_INTERVAL.CONNECTION_CHECK, + }); + + const tipProps = useMemo(() => { + if (isNeedTip) { + return checkStatus.success === ConnectionStatusType.Fail ? { tip: checkStatus.message } : {}; + } + return false; + }, [checkStatus.message, checkStatus.success, isNeedTip]); + + useEffect(() => { + // Store all connection state to sort table col data + globalParticipantIdToConnectionStateMap[Number(id)] = checkStatus.success; + setConnectionStatus(checkStatus); + }, [id, checkStatus, setConnectionStatus]); + + return ( + <GridRow align="center" gap={5}> + <StateIndicator + type={checkStatus.success} + text={getTextByType(checkStatus.success)} + {...tipProps} + afterText={ + isNeedReCheck && + checkStatus.success === ConnectionStatusType.Fail && ( + <button + type="button" + className="custom-text-button" + onClick={() => reCheck(id)} + style={{ marginLeft: 10 }} + > + 重试 + </button> + ) + } + /> + </GridRow> + ); + function getTextByType(type: ConnectionStatusType) { + switch (type) { + case ConnectionStatusType.Success: + return '连接成功'; + case ConnectionStatusType.Processing: + return '连接中'; + case ConnectionStatusType.Fail: + return '连接失败'; + } + } +}; + +export default PaticipantConnectionStatus; diff --git a/web_console_v2/client/src/views/Partner/PartnerList/PartnerTable.module.less b/web_console_v2/client/src/views/Partner/PartnerList/PartnerTable.module.less new file mode 100644 index 000000000..b976cc7da --- /dev/null +++ b/web_console_v2/client/src/views/Partner/PartnerList/PartnerTable.module.less @@ -0,0 +1,3 @@ +.table_container{ + margin-top: 20px; +} diff --git a/web_console_v2/client/src/views/Partner/PartnerList/PartnerTable.tsx b/web_console_v2/client/src/views/Partner/PartnerList/PartnerTable.tsx new file mode 100644 index 000000000..76e5bda85 --- /dev/null +++ b/web_console_v2/client/src/views/Partner/PartnerList/PartnerTable.tsx @@ -0,0 +1,260 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useMount } from 'react-use'; + +import { useRecoilQuery } from 'hooks/recoil'; +import { userInfoQuery } from 'stores/user'; +import { useReloadParticipantList } from 'hooks/participant'; +import { useUrlState } from 'hooks'; + +import { participantListQuery } from 'stores/participant'; +import { deleteParticipant } from 'services/participant'; +import { transformRegexSpecChar } from 'shared/helpers'; +import { formatTimestamp } from 'shared/date'; +import { CONSTANTS } from 'shared/constants'; + +import { Button, Input, Message, Table } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import { Plus } from 'components/IconPark'; +import Modal from 'components/Modal'; +import StateIndicator from 'components/StateIndicator'; +import PaticipantConnectionStatus, { + globalParticipantIdToConnectionStateMap, +} from './ConnectionStatus'; +import { ActionItem, VersionItem } from './TableItem'; + +import { FedRoles } from 'typings/auth'; +import { Participant, ParticipantType } from 'typings/participant'; + +import styles from './PartnerTable.module.less'; + +export const getParticipant_columns = (showNumOfWorkspace: boolean) => { + const columns = [ + { + title: '企业名称', + dataIndex: 'name', + ellipsis: true, + sorter: (a: Participant, b: Participant) => a.name.localeCompare(b.name), + }, + { + title: '类型', + dataIndex: 'type', + filters: [ + { + text: '轻量级', + value: ParticipantType.LIGHT_CLIENT, + }, + { + text: '标准', + value: ParticipantType.PLATFORM, + }, + ], + onFilter: (value: any, record: Participant) => { + if (value === ParticipantType.LIGHT_CLIENT) { + return record.type === ParticipantType.LIGHT_CLIENT; + } + if (value === ParticipantType.PLATFORM) { + return record.type === ParticipantType.PLATFORM || record.type == null; + } + return false; + }, + render: (value: ParticipantType) => ( + <StateIndicator.LigthClientType isLightClient={value === ParticipantType.LIGHT_CLIENT} /> + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 150, + sorter: (a: Participant, b: Participant) => { + if ( + globalParticipantIdToConnectionStateMap[b.id] && + globalParticipantIdToConnectionStateMap[a.id] + ) { + return globalParticipantIdToConnectionStateMap[b.id].localeCompare( + globalParticipantIdToConnectionStateMap[a.id], + ); + } + // Keep default order + return 1; + }, + render: (_: any, record: Participant) => { + const isLightClient = record.type === ParticipantType.LIGHT_CLIENT; + + if (isLightClient) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + + return <PaticipantConnectionStatus id={record.id} isNeedTip={true} isNeedReCheck={true} />; + }, + }, + { + title: '泛域名', + dataIndex: 'domain_name', + sorter: (a: Participant, b: Participant) => a.domain_name.localeCompare(b.domain_name), + }, + { + title: '主机号', + dataIndex: 'host', + }, + { + title: '端口号', + dataIndex: 'port', + }, + { + title: '版本号', + dataIndex: 'version', + render: (_: any, record: Participant) => { + return <VersionItem partnerId={record.id} />; + }, + }, + { + title: '合作伙伴描述', + dataIndex: 'comment', + ellipsis: true, + render: (value: any) => { + return value || CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + title: '最近活跃时间', + dataIndex: 'last_connected_at', + render: (value: any) => { + return value ? formatTimestamp(value) : CONSTANTS.EMPTY_PLACEHOLDER; + }, + sorter: (a: Participant, b: Participant) => + (a.last_connected_at || 0) - (b.last_connected_at || 0), + }, + ]; + showNumOfWorkspace && + columns.push({ + title: '已关联的工作区数量', + dataIndex: 'num_project', + render: (value: any) => { + return value || 0; + }, + }); + return columns; +}; + +const PartnerTable: FC = () => { + const history = useHistory(); + const userInfo = useRecoilQuery(userInfoQuery); + const reloadParticipants = useReloadParticipantList(); + const { isLoading, data: participantList } = useRecoilQuery(participantListQuery); + const reloadList = useReloadParticipantList(); + const [isDeleting, setIsDeleting] = useState(false); + + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 20, + keyword: '', + }); + + useMount(() => { + if (!isLoading || participantList) { + reloadList(); + } + }); + + const showList = useMemo(() => { + if (participantList && urlState.keyword) { + const regx = new RegExp(`^.*${transformRegexSpecChar(urlState.keyword)}.*$`); + return participantList.filter((item: Participant) => regx.test(item.name)); + } + return participantList || []; + }, [participantList, urlState.keyword]); + + const isAdmin = useMemo(() => { + if (userInfo.data) { + const _isAdmin = userInfo.data.role === FedRoles.Admin; + return _isAdmin; + } + return false; + }, [userInfo.data]); + + const columns = getParticipant_columns(true); + + isAdmin && + columns.push({ + title: '操作', + dataIndex: 'action', + render: (_: any, record: any) => { + return ( + <ActionItem + data={record} + onDelete={() => { + Modal.delete({ + title: '确认要删除合作伙伴?', + content: '删除后,该合作伙伴将退出当前所有运行中的工作流', + onOk() { + handleDelete(record.id); + }, + }); + }} + /> + ); + }, + }); + + return ( + <> + <GridRow justify={isAdmin ? 'space-between' : 'end'}> + {isAdmin && ( + <Button icon={<Plus />} type="primary" onClick={() => history.push('/partners/create')}> + 添加合作伙伴 + </Button> + )} + <Input.Search + allowClear + onSearch={onSearch} + onClear={() => onSearch('')} + placeholder="输入合作伙伴名称搜索" + defaultValue={urlState.keyword} + /> + </GridRow> + <Table<Participant> + className={`custom-table custom-table-left-side-filter ${styles.table_container}`} + rowKey="id" + data={showList} + columns={columns} + pagination={{ + pageSize: Number(urlState.pageSize), + current: Number(urlState.page), + onChange: onPageChange, + }} + loading={isLoading || isDeleting} + /> + </> + ); + + function onSearch(value: string) { + setUrlState((prevState) => ({ + ...prevState, + page: 1, + keyword: value, + })); + } + function onPageChange(page: number, pageSize: number | undefined) { + setUrlState((prevState) => ({ + ...prevState, + page, + pageSize, + })); + } + + async function handleDelete(id: ID) { + try { + setIsDeleting(true); + await deleteParticipant(id); + setIsDeleting(false); + Message.success('删除成功'); + reloadParticipants(); + } catch (error: any) { + setIsDeleting(false); + Message.error(error.message); + } + } +}; + +export default PartnerTable; diff --git a/web_console_v2/client/src/views/Partner/PartnerList/TableItem.tsx b/web_console_v2/client/src/views/Partner/PartnerList/TableItem.tsx new file mode 100644 index 000000000..4bc434a65 --- /dev/null +++ b/web_console_v2/client/src/views/Partner/PartnerList/TableItem.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { ConnectionStatusType, Participant } from 'typings/participant'; +import { useHistory } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; +import { participantConnectionState } from 'stores/participant'; +import MoreActions from 'components/MoreActions'; +import { CONSTANTS } from 'shared/constants'; + +export const VersionItem: FC<{ partnerId: ID }> = ({ partnerId: id }) => { + const connectionStatus = useRecoilValue(participantConnectionState(id)); + return ( + <span> + {connectionStatus.application_version?.version || + connectionStatus.application_version?.revision?.slice(-6) || + CONSTANTS.EMPTY_PLACEHOLDER} + </span> + ); +}; + +export const ActionItem: FC<{ data: Participant; onDelete: () => void }> = ({ data, onDelete }) => { + const history = useHistory(); + const connectionStatus = useRecoilValue(participantConnectionState(data.id)); + + const isProcessing = connectionStatus.success === ConnectionStatusType.Processing; + const isHaveLinkedProject = !!data.num_project; + + return ( + <MoreActions + actionList={[ + { + label: '删除', + onClick: onDelete, + disabled: isProcessing || isHaveLinkedProject, + disabledTip: isProcessing ? '连接中不可删除' : '需解除合作伙伴所有关联工作区才可删除', + danger: true, + }, + { + label: '变更', + onClick: () => { + history.push(`/partners/edit/${data.id}`); + }, + disabled: isProcessing, + disabledTip: isProcessing + ? '连接中不可变更' + : '需解除合作伙伴所有关联工作区且连接失败时才可变更', + }, + ]} + /> + ); +}; diff --git a/web_console_v2/client/src/views/Partner/PartnerList/index.module.less b/web_console_v2/client/src/views/Partner/PartnerList/index.module.less new file mode 100644 index 000000000..f0a512cc1 --- /dev/null +++ b/web_console_v2/client/src/views/Partner/PartnerList/index.module.less @@ -0,0 +1,26 @@ +@import '~styles/mixins.less'; +.avatar_container{ + .MixinSquare(44px); + background-color: #5360d8; + color: white; + border-radius: 4px; + font-size: 18px; + text-align: center; + + &::before { + content: attr(data-name); + line-height: 44px; + font-weight: bold; + } +} + +.user_message{ + padding-bottom: 20px; +} +.tag_container{ + background-color: '#F6F7FB'; + border: 0; + color: '#1A2233'; + border-radius: 40px; + padding: 0px 8px; +} diff --git a/web_console_v2/client/src/views/Partner/PartnerList/index.tsx b/web_console_v2/client/src/views/Partner/PartnerList/index.tsx new file mode 100644 index 000000000..a8f4a2735 --- /dev/null +++ b/web_console_v2/client/src/views/Partner/PartnerList/index.tsx @@ -0,0 +1,52 @@ +import React, { FC, useState } from 'react'; +import { useQuery } from 'react-query'; + +import { fetchSysInfo } from 'services/settings'; +import { CONSTANTS } from 'shared/constants'; + +import { Tag, Tabs, Spin } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import SharedPageLayout, { RemovePadding } from 'components/SharedPageLayout'; +import PartnerTable from './PartnerTable'; + +import styles from './index.module.less'; + +const PartnerList: FC = () => { + const sysInfoQuery = useQuery(['fetchSysInfo'], () => fetchSysInfo(), { + retry: 2, + refetchOnWindowFocus: false, + }); + + const [activeKey] = useState<string>('partners'); + + return ( + <SharedPageLayout title={'合作伙伴'}> + <Spin loading={sysInfoQuery.isFetching}> + <div className={styles.user_message}> + <GridRow gap="12" style={{ maxWidth: '75%' }}> + <div + className={styles.avatar_container} + data-name={sysInfoQuery.data?.data?.name?.slice(0, 1) ?? CONSTANTS.EMPTY_PLACEHOLDER} + /> + <div> + <h3>{sysInfoQuery.data?.data.name ?? CONSTANTS.EMPTY_PLACEHOLDER}</h3> + <div> + <Tag className={styles.tag_container}> + 泛域名:{sysInfoQuery.data?.data?.domain_name ?? CONSTANTS.EMPTY_PLACEHOLDER} + </Tag> + </div> + </div> + </GridRow> + </div> + </Spin> + <RemovePadding style={{ height: 48 }}> + <Tabs defaultActiveTab={activeKey}> + <Tabs.TabPane title="合作伙伴" key="partners" /> + </Tabs> + </RemovePadding> + <div>{activeKey === 'partners' && <PartnerTable />}</div> + </SharedPageLayout> + ); +}; + +export default PartnerList; diff --git a/web_console_v2/client/src/views/Partner/index.tsx b/web_console_v2/client/src/views/Partner/index.tsx new file mode 100644 index 000000000..f8f3af5f1 --- /dev/null +++ b/web_console_v2/client/src/views/Partner/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import PartnerList from './PartnerList'; +import CreatePartner from './CreatePartner'; +import EditPartner from './EditPartner'; + +function PartnerPage() { + return ( + <> + <Route path="/partners" exact component={PartnerList} /> + <Route path="/partners/create" exact component={CreatePartner} /> + <Route path="/partners/edit/:id" exact component={EditPartner} /> + </> + ); +} + +export default PartnerPage; diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/EnvVariablesForm/index.module.less b/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/EnvVariablesForm/index.module.less new file mode 100644 index 000000000..f6702fe14 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/EnvVariablesForm/index.module.less @@ -0,0 +1,35 @@ +.header{ + margin-bottom: 20px; +} +.toggler { + display: inline-flex; + align-items: center; + font-size: 14px; + line-height: 1; + color: var(--primaryColor); + cursor: pointer; + user-select: none; + &:hover { + color: var(--newPrimaryHover); + } +} +.no_variables{ + color: var(--textColorSecondary); +} +.add_button{ + color: var(--primaryColor) !important; + font-weight: 500; +} +.list_container{ + transition: 0.4s var(--commonTiming); + overflow: hidden; + .hidden_variables { + opacity: 0; + overflow: hidden; + } +} +.remove_button{ + position: absolute; + right: 0; +} + diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/EnvVariablesForm/index.tsx b/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/EnvVariablesForm/index.tsx new file mode 100644 index 000000000..2716d6766 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/EnvVariablesForm/index.tsx @@ -0,0 +1,168 @@ +import React, { + useCallback, + useLayoutEffect, + useRef, + useImperativeHandle, + ForwardRefRenderFunction, + forwardRef, +} from 'react'; +import { Form, Input, Button } from '@arco-design/web-react'; +import { useToggle } from 'react-use'; +import { IconDelete, IconPlus, IconCaretDown, IconCaretUp } from '@arco-design/web-react/icon'; +import { FormInstance } from '@arco-design/web-react'; +import { convertToUnit } from 'shared/helpers'; +import { useSubscribe } from 'hooks'; +import GridRow from 'components/_base/GridRow'; + +import styles from './index.module.less'; + +export const VARIABLES_FIELD_NAME = 'variables'; +export const VARIABLES_ERROR_CHANNEL = 'project.field_variables_error'; + +type Props = { + formInstance?: FormInstance; + disabled?: boolean; + isEdit?: boolean; +}; + +export type ExposedRef = { + toggleFolded: (params: boolean) => void; +}; + +const EnvVariablesForm: ForwardRefRenderFunction<ExposedRef | undefined, Props> = ( + { disabled, isEdit = true }, + parentRef, +) => { + const [isFolded, toggleFolded] = useToggle(isEdit); + const listInnerRef = useRef<HTMLDivElement>(); + const listContainerRef = useRef<HTMLDivElement>(); + + useSubscribe(VARIABLES_ERROR_CHANNEL, () => { + toggleFolded(false); + }); + + useImperativeHandle(parentRef, () => { + return { + toggleFolded, + }; + }); + + const setListContainerMaxHeight = useCallback( + (nextHeight: any) => { + listContainerRef.current!.style.maxHeight = convertToUnit(nextHeight); + }, + [listContainerRef], + ); + const getListInnerHeight = useCallback(() => { + return listInnerRef.current!.offsetHeight!; + }, [listInnerRef]); + + useLayoutEffect(() => { + const innerHeight = getListInnerHeight() + 30; + + if (isFolded) { + setListContainerMaxHeight(innerHeight); + // Q: Why read inner's height one time before set maxHeight to 0 for folding + // A: Since we set maxHeight to 'initial' everytime unfold-transition ended, it's important + // to re-set maxHeight to innerHeight before folding, we need a ${specific value} → 0 transition + // not the `initial` → 0 in which case animation would lost + getListInnerHeight(); + setListContainerMaxHeight(0); + } else { + setListContainerMaxHeight(innerHeight); + } + }, [isFolded, getListInnerHeight, setListContainerMaxHeight]); + + return ( + <div> + {isEdit && ( + <div className={styles.header}> + <div className={styles.toggler} onClick={toggleFolded} data-folded={String(isFolded)}> + {isFolded ? ( + <> + 展开环境变量配置 <IconCaretDown /> + </> + ) : ( + <> + 收起环境变量配置 + <IconCaretUp /> + </> + )} + {/* <CaretDown /> */} + </div> + </div> + )} + + <div + className={styles.list_container} + ref={listContainerRef as any} + data-folded={String(isFolded)} + onTransitionEnd={onFoldAnimationEnd} + > + <Form.List field={`config.${VARIABLES_FIELD_NAME}`}> + {(fields, { add, remove }) => ( + <div ref={listInnerRef as any}> + {fields.map((field, index) => ( + <GridRow + style={{ position: 'relative', gridTemplateColumns: '1fr 1fr' }} + gap={10} + key={field.key} + align="start" + > + <Form.Item + field={`${field.field}.name`} + rules={[{ required: true, message: '请输入变量名' }]} + > + <Input.TextArea placeholder="name" disabled={disabled} /> + </Form.Item> + + <Form.Item + field={`${field.field}.value`} + rules={[{ required: true, message: '请输入变量值' }]} + > + <Input.TextArea placeholder="value" disabled={disabled} /> + </Form.Item> + <Button + className={styles.remove_button} + size="small" + icon={<IconDelete />} + shape="circle" + type="text" + onClick={() => remove(index)} + /> + </GridRow> + ))} + {/* Empty placeholder */} + {isEdit && fields.length === 0 && ( + <Form.Item className={styles.no_variables}>当前没有环境变量参数</Form.Item> + )} + + <Form.Item> + {/* DO NOT simplify `() => add()` to `add`, it will pollute form value with $event */} + <Button + className={styles.add_button} + size="small" + icon={<IconPlus />} + type="default" + onClick={() => add()} + > + 新增参数 + </Button> + </Form.Item> + </div> + )} + </Form.List> + </div> + </div> + ); + + function onFoldAnimationEnd(_: React.TransitionEvent) { + if (!isFolded) { + // Because of user can adjust list inner's height by resize value-textarea or add/remove variable + // we MUST set container's maxHeight to 'initial' after unfolded (after which user can interact) + listContainerRef.current!.style.maxHeight = 'initial'; + } + } +}; + +export default forwardRef(EnvVariablesForm); diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/index.module.less b/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/index.module.less new file mode 100644 index 000000000..481acc76a --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/index.module.less @@ -0,0 +1,18 @@ +.container { + width: 480px; + margin: 40px auto 0; +} +.title { + font-size: 14px; + font-weight: 500; + color: #000000; +} +.participant_name { + margin-right: 8px; +} +.btn_container{ + margin-top: 40px; +} +.btn_content{ + padding: 0px 60px; +} diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/index.tsx b/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/index.tsx new file mode 100644 index 000000000..b9acaa74b --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepOneConfig/index.tsx @@ -0,0 +1,166 @@ +import React, { FC } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useRecoilState } from 'recoil'; + +import { useIsFormValueChange } from 'hooks'; +import { projectCreateForm, ProjectCreateForm } from 'stores/project'; +import { MAX_COMMENT_LENGTH, validNamePattern } from 'shared/validator'; + +import { Form, Input, Button, Radio } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import EnvVariablesForm from './EnvVariablesForm'; +import ActionRules from '../StepTwoPartner/ActionRules'; +import { ProjectFormInitialValues, ProjectTaskType } from 'typings/project'; + +import styles from './index.module.less'; + +const radioOptions = [ + { + value: ProjectTaskType.ALIGN, + label: 'ID对齐(隐私集合求交,求出共有交集,不泄漏交集之外原始数据)', + }, + { + value: ProjectTaskType.HORIZONTAL, + label: '横向联邦学习(包含特征对齐、横向联邦训练、评估、预测能力)', + }, + { + value: ProjectTaskType.VERTICAL, + label: '纵向联邦学习(包含ID对齐、纵向联邦训练、评估、预测能力)', + }, + { + value: ProjectTaskType.TRUSTED, + label: '可信分析服务(包含可信计算分析能力)', + }, +]; + +const StepOneConfig: FC<{ + isEdit?: boolean; + onEditFinish?: (payload: any) => Promise<void>; + initialValues?: ProjectFormInitialValues; + isLeftLayout?: boolean; + onFormValueChange?: () => void; +}> = ({ + isEdit = false, + onEditFinish, + initialValues, + isLeftLayout = false, + onFormValueChange: onFormValueChangeFromProps, +}) => { + const [form] = Form.useForm(); + const history = useHistory(); + const [projectForm, setProjectForm] = useRecoilState<ProjectCreateForm>(projectCreateForm); + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(onValuesChange); + + const defaultValues: any = initialValues?.name + ? initialValues + : { + name: '', + comment: '', + variables: [], + }; + + return ( + <div className={styles.container}> + <Form + form={form} + layout="vertical" + onSubmit={isEdit ? editFinish : goStepTwo} + initialValues={isEdit ? defaultValues : projectForm} + onValuesChange={onFormValueChange} + > + <div style={{ marginBottom: 30 }}> + <p className={styles.title}>基本信息</p> + <Form.Item + label="工作区名称" + field="name" + rules={[ + { required: true, message: '请输入工作区名称' }, + { + match: validNamePattern, + message: '只支持大小写字母,数字,中文开头或结尾,可包含“_”和“-”,不超过 63 个字符', + }, + ]} + > + <Input disabled={isEdit} placeholder="请输入工作区名称" /> + </Form.Item> + <Form.Item label="工作区描述" field="comment"> + <Input.TextArea maxLength={MAX_COMMENT_LENGTH} placeholder="请为工作区添加描述" /> + </Form.Item> + <p className={styles.title}>环境变量</p> + <EnvVariablesForm formInstance={form} isEdit={isEdit} /> + <p className={styles.title}>能力规格</p> + + {isEdit ? ( + <span> + {radioOptions.find((item) => item.value === initialValues?.config?.abilities?.[0]) + ?.label || '旧版工作区'} + </span> + ) : ( + <Form.Item + field="config.abilities" + rules={[{ required: true, message: '请选择能力规格' }]} + normalize={(value) => [value]} + formatter={(value) => value?.[0]} + > + <Radio.Group> + {radioOptions.map((item) => ( + <Radio value={item.value} key={item.value}> + {item.label} + </Radio> + ))} + </Radio.Group> + </Form.Item> + )} + + {isEdit && initialValues?.config?.abilities?.[0] && ( + <ActionRules taskType={initialValues?.config?.abilities?.[0] as ProjectTaskType} /> + )} + </div> + + <Form.Item> + <GridRow + className={styles.btn_container} + gap={10} + justify={isLeftLayout ? 'start' : 'center'} + > + <Button className={styles.btn_content} type="primary" htmlType="submit"> + {isEdit ? '提交' : '下一步'} + </Button> + <ButtonWithModalConfirm + onClick={backToList} + isShowConfirmModal={isFormValueChanged || Boolean(projectForm.name)} + > + 取消 + </ButtonWithModalConfirm> + </GridRow> + </Form.Item> + </Form> + </div> + ); + + function backToList() { + history.push(`/projects`); + } + function goStepTwo(values: any) { + setProjectForm({ + ...projectForm, + ...values, + config: { + ...projectForm.config, + ...values.config, + }, + }); + + history.push(`/projects/create/authorize`); + } + function editFinish(values: any) { + const editPayload = { ...values, config: { ...defaultValues.config, ...values.config } }; + onEditFinish?.(editPayload); + } + function onValuesChange() { + onFormValueChangeFromProps?.(); + } +}; + +export default StepOneConfig; diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepThreeAuthorize/index.module.less b/web_console_v2/client/src/views/Projects/CreateProject/StepThreeAuthorize/index.module.less new file mode 100644 index 000000000..1566f2376 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepThreeAuthorize/index.module.less @@ -0,0 +1,30 @@ +.container{ + display: flex; + align-items: center; + flex-direction: column; +} +.title_container{ + margin-top: 40px; + margin-bottom: 20px; +} +.title_content{ + font-size: 14px; + font-weight: 500; + color: #000000; + margin: 10px 0px; +} +.popover_content{ + font-size: 12px; +} +.btn_container{ + margin-top: 40px; +} +.btn_content{ + padding: 0px 60px; +} +.block_chain_container{ + margin-bottom: 0px; +} + + + diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepThreeAuthorize/index.tsx b/web_console_v2/client/src/views/Projects/CreateProject/StepThreeAuthorize/index.tsx new file mode 100644 index 000000000..e87df8aed --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepThreeAuthorize/index.tsx @@ -0,0 +1,161 @@ +import React, { FC, useState, useEffect } from 'react'; +import { useHistory } from 'react-router'; +import { NewCreateProjectPayload, ProjectTaskType } from 'typings/project'; +import { ParticipantType, Participant } from 'typings/participant'; +import { useRecoilState } from 'recoil'; +import { initialActionRules, projectCreateForm, ProjectCreateForm } from 'stores/project'; +import { Button, Form, Popover, Switch, Space } from '@arco-design/web-react'; +import { IconInfoCircle, IconQuestionCircle } from '@arco-design/web-react/icon'; +import GridRow from 'components/_base/GridRow'; +import InvitionTable from 'components/InvitionTable'; +import BlockRadio from 'components/_base/BlockRadio'; +import TitleWithIcon from 'components/TitleWithIcon'; + +import styles from './index.module.less'; + +const options = [ + { + value: ParticipantType.PLATFORM, + label: '标准合作伙伴', + }, + { + value: ParticipantType.LIGHT_CLIENT, + label: '轻量合作伙伴', + }, +]; + +const StepThreeAuthorize: FC<{ onSubmit: (payload: NewCreateProjectPayload) => Promise<void> }> = ({ + onSubmit, +}) => { + const history = useHistory(); + const [form] = Form.useForm(); + const [projectForm, setProjectForm] = useRecoilState<ProjectCreateForm>(projectCreateForm); + const [isSubmitDisable, setIsSubmitDisable] = useState(true); + const [supportBlockChain, setSupportBlockChain] = useState(true); + const [participantsType, setParticipantsType] = useState(ParticipantType.PLATFORM); + + useEffect(() => { + if (!projectForm.config.abilities || !projectForm.name) { + history.push('/projects/create/config'); + } + }); + return ( + <div className={styles.container}> + <div> + <Form form={form} initialValues={projectForm} onSubmit={onFinish} layout="vertical"> + <p className={styles.title_content}>邀请合作伙伴</p> + <Form.Item + label={ + <span> + 合作伙伴类型 + <Popover + title="合作伙伴类型说明" + content={ + <span className={styles.popover_content}> + <p>标准合作伙伴:拥有可视化Web平台的合作伙伴;</p> + <p>轻量合作伙伴:合作伙伴的客户端形式为容器或可执行文本。</p> + </span> + } + > + <IconQuestionCircle /> + </Popover> + </span> + } + field="participant_type" + initialValue={ParticipantType.PLATFORM} + rules={[{ required: true }]} + > + <BlockRadio + options={options} + onChange={(value: any) => { + setParticipantsType(value); + }} + /> + </Form.Item> + <Form.Item label="合作伙伴" field="participant_ids"> + <InvitionTable + participantsType={participantsType} + onChange={(selectedParticipants: Participant[]) => { + setIsSubmitDisable(!selectedParticipants.length); + const hasNoBlockChain = selectedParticipants.find((item) => { + return !item?.support_blockchain; + }); + setSupportBlockChain(!hasNoBlockChain); + hasNoBlockChain && form.setFieldValue('config.support_blockchain', false); + }} + isSupportCheckbox={[ProjectTaskType.HORIZONTAL, ProjectTaskType.TRUSTED].includes( + projectForm?.config?.abilities?.[0], + )} + /> + </Form.Item> + <p className={styles.title_content}>区块链存证</p> + <Space> + <Form.Item + className={styles.block_chain_container} + field="config.support_blockchain" + initialValue={true} + > + {supportBlockChain ? ( + <Switch /> + ) : ( + <TitleWithIcon + title="你选择的部分合作伙伴无区块链服务,不可启用区块链存证" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + )} + </Form.Item> + {supportBlockChain && ( + <TitleWithIcon + title="在工作区创建提交后不可更改此设置" + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + )} + </Space> + + <Form.Item> + <GridRow className={styles.btn_container} gap={10} justify="center"> + <Button + className={styles.btn_content} + type="primary" + htmlType="submit" + disabled={isSubmitDisable} + > + 提交并发送 + </Button> + <Button onClick={goToStepTwo}>上一步</Button> + </GridRow> + </Form.Item> + </Form> + </div> + </div> + ); + function onFinish(value: any) { + onSubmit({ + ...projectForm, + participant_ids: value.participant_ids.map((item: any) => item.id) || [], + config: { + ...projectForm.config, + ...value.config, + }, + }); + } + function goToStepTwo() { + setProjectForm({ + ...projectForm, + config: { + ...projectForm.config, + action_rules: { + ...initialActionRules, + ...projectForm.config.action_rules, + }, + }, + }); + history.goBack(); + } +}; + +export default StepThreeAuthorize; diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/ActionRules/index.module.less b/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/ActionRules/index.module.less new file mode 100644 index 000000000..7c04a71a8 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/ActionRules/index.module.less @@ -0,0 +1,15 @@ +.title_container{ + margin-top: 40px; + margin-bottom: 20px; +} +.title_content{ + font-size: 14px; + font-weight: 500; + color: #000000; + margin: 10px 0px; +} +.popover_content{ + font-size: 12px; +} + + diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/ActionRules/index.tsx b/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/ActionRules/index.tsx new file mode 100644 index 000000000..3e72826fb --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/ActionRules/index.tsx @@ -0,0 +1,248 @@ +import React, { useState } from 'react'; +import { Form, Grid, Popover, Select, Typography } from '@arco-design/web-react'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import TitleWithIcon from 'components/TitleWithIcon'; +import { ProjectAbilityType, ProjectActionType, ProjectTaskType } from 'typings/project'; + +import styles from './index.module.less'; + +const { Row, Col } = Grid; +const { Option } = Select; + +const options = [ + { + value: ProjectAbilityType.ALWAYS_ALLOW, + label: '始终允许', + }, + { + value: ProjectAbilityType.ONCE, + label: '允许一次', + }, + { + value: ProjectAbilityType.MANUAL, + label: '发起时询问', + }, + { + value: ProjectAbilityType.ALWAYS_REFUSE, + label: '拒绝', + }, +]; + +interface Props { + taskType: ProjectTaskType; + value?: any; +} +function ActionRules({ taskType, value }: Props) { + const [visible, setVisible] = useState(false); + return ( + <> + <div className={styles.title_container}> + <p className={styles.title_content}>本方授权策略</p> + <TitleWithIcon + title={ + <> + <span>配置任务时修改其授权策略,此初始授权策略将不再对任务生效。 </span> + <Popover + title="选项说明" + popupVisible={visible} + onVisibleChange={setVisible} + content={ + <span className={styles.popover_content}> + <p>1.始终允许:同类任务始终允许自动授权通过;</p> + + <p> + 2.允许一次:同类任务允许一次自动授权通过;一次执行后,具体任务的权限更新为发起时询问; + </p> + <p>3.发起时询问:同类任务发起时需要询问是否授权通过;</p> + <p>4.拒绝授权:同类任务始终授权拒绝。</p> + </span> + } + > + <Typography.Text type="primary">选项说明</Typography.Text> + </Popover> + </> + } + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </div> + {renderFormItem(taskType)} + </> + ); +} + +function resetFiled(filedValue: string) { + return `config.action_rules.${filedValue}`; +} +function renderFormItem(taskType: ProjectTaskType) { + let formItem; + switch (taskType) { + case ProjectTaskType.ALIGN: + formItem = renderAlignTask(); + break; + case ProjectTaskType.HORIZONTAL: + formItem = renderHorizontalTask(); + break; + case ProjectTaskType.VERTICAL: + formItem = renderVerticalTask(); + break; + case ProjectTaskType.TRUSTED: + formItem = renderTrustedTask(); + break; + default: + formItem = renderAlignTask(); + break; + } + return formItem; +} +function renderAlignTask() { + return ( + <Form.Item + field={resetFiled(ProjectActionType.ID_ALIGNMENT)} + label="ID对齐任务" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + ); +} +function renderHorizontalTask() { + return ( + <> + <Row gutter={24}> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.DATA_ALIGNMENT)} + label="横向数据对齐任务" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.HORIZONTAL_TRAIN)} + label="横向联邦模型训练" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + </Row> + <Row gutter={24}> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.WORKFLOW)} + label="工作流任务" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + </Row> + </> + ); +} +function renderVerticalTask() { + return ( + <> + <Row gutter={24}> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.ID_ALIGNMENT)} + label="ID对齐任务" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.VERTICAL_TRAIN)} + label="纵向联邦模型训练" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + </Row> + <Row gutter={24}> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.VERTICAL_EVAL)} + label="纵向联邦模型评估" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.VERTICAL_PRED)} + label="纵向联邦模型离线预测" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + </Row> + <Row gutter={24}> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.VERTICAL_SERVING)} + label="纵向联邦模型在线服务" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.WORKFLOW)} + label="工作流任务" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + </Row> + </> + ); +} +function renderTrustedTask() { + return ( + <Row gutter={24}> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.TEE_SERVICE)} + label="可信分析服务" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + <Col span={12}> + <Form.Item + field={resetFiled(ProjectActionType.TEE_RESULT_EXPORT)} + label="可信分析服务结果导出" + rules={[{ required: true }]} + > + {taskAuthorization()} + </Form.Item> + </Col> + </Row> + ); +} +function taskAuthorization() { + return ( + <Select options={options}> + {options.map((option) => ( + <Option value={option.value} key={option.value}> + {option.label} + </Option> + ))} + </Select> + ); +} +export default ActionRules; diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/index.module.less b/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/index.module.less new file mode 100644 index 000000000..389a25129 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/index.module.less @@ -0,0 +1,26 @@ +.container{ + display: flex; + align-items: center; + flex-direction: column; +} +.title_container{ + margin-top: 40px; + margin-bottom: 20px; +} +.title_content{ + font-size: 14px; + font-weight: 500; + color: #000000; + margin: 10px 0px; +} +.popover_content{ + font-size: 12px; +} +.btn_container{ + margin-top: 40px; +} +.btn_content{ + padding: 0px 60px; +} + + diff --git a/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/index.tsx b/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/index.tsx new file mode 100644 index 000000000..28798ccaf --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/StepTwoPartner/index.tsx @@ -0,0 +1,55 @@ +import React, { FC, useEffect } from 'react'; +import { useHistory } from 'react-router'; +import { ProjectTaskType } from 'typings/project'; +import { useRecoilState } from 'recoil'; +import { projectCreateForm, ProjectCreateForm } from 'stores/project'; +import { Button, Form } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import ActionRules from './ActionRules'; + +import styles from './index.module.less'; + +const StepTwoPartner: FC<{ + onFormValuesChange?: () => void; +}> = () => { + const history = useHistory(); + const [form] = Form.useForm(); + const [projectForm, setProjectForm] = useRecoilState<ProjectCreateForm>(projectCreateForm); + + useEffect(() => { + if (!projectForm.config.abilities || !projectForm.name) { + history.push('/projects/create/config'); + } + }); + + return ( + <div className={styles.container}> + <div> + <Form form={form} initialValues={projectForm} onSubmit={goStepThree} layout="vertical"> + <ActionRules taskType={projectForm.config.abilities?.[0] as ProjectTaskType} /> + <Form.Item> + <GridRow className={styles.btn_container} gap={10} justify="center"> + <Button className={styles.btn_content} type="primary" htmlType="submit"> + 下一步 + </Button> + + <Button onClick={() => history.goBack()}>上一步</Button> + </GridRow> + </Form.Item> + </Form> + </div> + </div> + ); + function goStepThree(values: any) { + setProjectForm({ + ...projectForm, + config: { + ...projectForm.config, + ...values.config, + }, + }); + history.push(`/projects/create/partner`); + } +}; + +export default StepTwoPartner; diff --git a/web_console_v2/client/src/views/Projects/CreateProject/index.module.less b/web_console_v2/client/src/views/Projects/CreateProject/index.module.less new file mode 100644 index 000000000..a116f3575 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/CreateProject/index.module.less @@ -0,0 +1,11 @@ +.container{ + overflow-x: hidden; +} +.step_container{ + width: 500px; +} +.form_area{ + flex: 1; + margin-top: 12px; + background-color: white; +} diff --git a/web_console_v2/client/src/views/Projects/EditProject/index.module.less b/web_console_v2/client/src/views/Projects/EditProject/index.module.less new file mode 100644 index 000000000..c70d64c39 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/EditProject/index.module.less @@ -0,0 +1,7 @@ +.container { + overflow-x: hidden; +} + +.spin_container { + min-height: 500px; +} diff --git a/web_console_v2/client/src/views/Projects/ProjectDetail/DetailHeader/index.module.less b/web_console_v2/client/src/views/Projects/ProjectDetail/DetailHeader/index.module.less new file mode 100644 index 000000000..d273c6bd4 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectDetail/DetailHeader/index.module.less @@ -0,0 +1,42 @@ +@import '~styles/mixins.less'; +.avatar_container{ + .MixinSquare(44px); + background-color: var(--primaryColor); + color: white; + border-radius: 4px; + font-size: 18px; + text-align: center; + + &::before { + content: attr(data-name); + line-height: 44px; + font-weight: bold; + } +} +.project_name{ + margin-bottom: 0; + font-size: 16px; + height: 24px; + font-weight: 600; +} +.comment{ + display: block; + font-size: 12px; + line-height: 18px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 400px; + color: var(--textColorSecondary); +} +.popover_table { + margin: -12px -16px !important; +} +.project_progress{ + width: 60px; +} +.variables_color{ + color: rgb(var(--primary-6)); + cursor: pointer; +} + diff --git a/web_console_v2/client/src/views/Projects/ProjectDetail/DetailHeader/index.tsx b/web_console_v2/client/src/views/Projects/ProjectDetail/DetailHeader/index.tsx new file mode 100644 index 000000000..1307ecced --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectDetail/DetailHeader/index.tsx @@ -0,0 +1,229 @@ +import React, { ReactElement, useRef } from 'react'; +import { useGetCurrentPureDomainName } from 'hooks'; +import ProjectConnectionStatus, { ExposedRef } from '../../ConnectionStatus'; +import GridRow from 'components/_base/GridRow'; +import { Button, Grid, Popover, Space, Table, Tag, Tooltip } from '@arco-design/web-react'; +import ProjectMoreActions from 'views/Projects/ProjectMoreActions'; +import PropertyList from 'components/PropertyList'; +import { formatTimestamp } from 'shared/date'; +import { ParticipantType } from 'typings/participant'; +import { + Project, + ProjectListType, + ProjectStateType, + ProjectTicketStatus, + RoleType, + ProjectBlockChainType, + ProjectTaskType, + ProjectActionType, + ProjectAbilityType, +} from 'typings/project'; +import { CONSTANTS } from 'shared/constants'; +import { + PARTICIPANT_TYPE_TAG_MAPPER, + ProjectProgress, + PROJECT_ABILITY_LABEL_MAPPER, + PROJECT_TASK_LABEL_MAPPER, + resetAbilitiesTableData, +} from '../../shard'; + +import styles from './index.module.less'; + +interface Props { + project: Project; + projectListType: ProjectListType; + onDeleteProject: (projectId: ID, projectListType: ProjectListType) => void; +} + +const { Row } = Grid; +const variableColumns = [ + { + title: 'name', + dataIndex: 'name', + }, + { + title: 'value', + dataIndex: 'value', + }, +]; +const abilitiesColumns = [ + { + title: '能力', + dataIndex: 'ability', + render: (value: ProjectActionType) => PROJECT_TASK_LABEL_MAPPER?.[value], + }, + { + title: '授权策略', + dataIndex: 'rule', + render: (value: ProjectAbilityType) => PROJECT_ABILITY_LABEL_MAPPER?.[value], + }, +]; +const ABILITY_LABEL_MAPPER = { + [ProjectTaskType.ALIGN]: 'ID对齐', + [ProjectTaskType.HORIZONTAL]: '横向联邦学习', + [ProjectTaskType.VERTICAL]: '纵向联邦学习', + [ProjectTaskType.TRUSTED]: '可信分析服务', +}; + +function DetailHeader({ project, projectListType, onDeleteProject }: Props): ReactElement { + const myPureDomainName = useGetCurrentPureDomainName(); + + const projectConnectionStatusRef = useRef<ExposedRef>(null); + + const isLightClient = project?.participant_type === ParticipantType.LIGHT_CLIENT; + const isPendingProject = projectListType === ProjectListType.PENDING; + + const properties = [ + { label: '工作区ID', value: project?.id ?? CONSTANTS.EMPTY_PLACEHOLDER }, + { + label: '区块链存证 ', + value: project?.config?.support_blockchain + ? ProjectBlockChainType.OPEN + : ProjectBlockChainType.CLOSED, + }, + { + label: '合作伙伴类型', + value: ( + <Tag + color={ + PARTICIPANT_TYPE_TAG_MAPPER?.[project?.participant_type || ParticipantType.PLATFORM] + .color + } + size="small" + > + { + PARTICIPANT_TYPE_TAG_MAPPER?.[project?.participant_type || ParticipantType.PLATFORM] + .label + } + </Tag> + ), + }, + { + label: '连接状态', + value: + !isLightClient && project && !isPendingProject ? ( + <ProjectConnectionStatus ref={projectConnectionStatusRef} project={project} /> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + }, + { + label: '创建人', + value: project?.creator || project?.creator_username || CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + label: '创建时间', + value: project?.created_at + ? formatTimestamp(project.created_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + }, + { + label: '环境变量', + value: project?.config?.variables?.length ? ( + <Popover + className={styles.popover_container} + content={ + <Table + className={styles.popover_table} + columns={variableColumns} + rowKey="name" + data={project.config.variables} + pagination={false} + /> + } + position="bottom" + > + <span className={styles.variables_color}>查看</span> + </Popover> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + }, + { + label: '能力规格', + value: project?.config?.abilities?.length ? ( + <Popover + className={styles.popover_container} + content={ + <Table + className={styles.popover_table} + columns={abilitiesColumns} + rowKey="ability" + data={resetAbilitiesTableData(project.config.action_rules)} + pagination={false} + /> + } + position="bottom" + > + <span className={styles.variables_color}> + {ABILITY_LABEL_MAPPER?.[project.config.abilities?.[0]]} + </span> + </Popover> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + ), + }, + ]; + + return ( + <> + <Row justify="space-between" align="center"> + <Row align="center" justify="space-between"> + <GridRow gap="12"> + <div + className={styles.avatar_container} + data-name={project?.name ? project.name.slice(0, 1) : CONSTANTS.EMPTY_PLACEHOLDER} + /> + <div> + <Space> + <h3 className={styles.project_name}>{project?.name || '....'}</h3> + <ProjectProgress + className={styles.project_progress} + ticketStatus={ + project?.state === ProjectStateType.FAILED + ? ProjectTicketStatus.FAILED + : project?.ticket_status + } + /> + </Space> + <Tooltip content={project?.comment}> + <small className={styles.comment}> + {project?.comment || CONSTANTS.EMPTY_PLACEHOLDER} + </small> + </Tooltip> + </div> + </GridRow> + </Row> + <GridRow gap="10" style={{ flexBasis: 'auto' }}> + <Button + size="small" + onClick={onCheckConnectionClick} + disabled={isLightClient || isPendingProject} + > + 检查连接 + </Button> + <GridRow> + <ProjectMoreActions + project={project} + projectListType={projectListType} + role={ + project?.participants_info?.participants_map?.[myPureDomainName]?.role ?? + RoleType.COORDINATOR + } + onDeleteProject={onDeleteProject} + /> + </GridRow> + </GridRow> + </Row> + <PropertyList properties={properties} cols={4} /> + </> + ); + + function onCheckConnectionClick() { + if (projectConnectionStatusRef.current?.checkConnection) { + projectConnectionStatusRef.current.checkConnection(); + } + } +} + +export default DetailHeader; diff --git a/web_console_v2/client/src/views/Projects/ProjectDetail/ParticipantDetail/index.tsx b/web_console_v2/client/src/views/Projects/ProjectDetail/ParticipantDetail/index.tsx new file mode 100644 index 000000000..52662c444 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectDetail/ParticipantDetail/index.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; + +import { useRecoilQuery } from 'hooks/recoil'; +import { participantListQuery } from 'stores/participant'; +import { Spin } from '@arco-design/web-react'; + +type Props = { + pureDomainName?: string; + loading?: boolean; + contentField: string; +}; + +const ParticipantDetail: FC<Props> = ({ pureDomainName, loading, contentField }) => { + const { isLoading, data } = useRecoilQuery(participantListQuery); + const participant = data?.find((item) => item.pure_domain_name === pureDomainName) as Record< + string, + any + >; + + return ( + <Spin loading={isLoading || loading}> + <span>{participant?.[contentField] ?? '-'}</span>; + </Spin> + ); +}; + +export default ParticipantDetail; diff --git a/web_console_v2/client/src/views/Projects/ProjectDetail/index.module.less b/web_console_v2/client/src/views/Projects/ProjectDetail/index.module.less new file mode 100644 index 000000000..049f33dd2 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectDetail/index.module.less @@ -0,0 +1,12 @@ +.participant_name_container{ + display: flex; + width: 100%; +} +.participant_name_content{ + display: inline-block; + flex: 1; + width: 80%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/web_console_v2/client/src/views/Projects/ProjectDetail/index.tsx b/web_console_v2/client/src/views/Projects/ProjectDetail/index.tsx new file mode 100644 index 000000000..cc884691a --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectDetail/index.tsx @@ -0,0 +1,251 @@ +import React, { FC, useState, useMemo } from 'react'; +import { useParams, useHistory } from 'react-router'; +import { useQuery } from 'react-query'; +import { useGetCurrentPureDomainName } from 'hooks'; +import { useRecoilQuery } from 'hooks/recoil'; +import { + deletePendingProject, + deleteProject, + fetchPendingProjectList, + getProjectDetailById, +} from 'services/project'; +import { fetchWorkflowList } from 'services/workflow'; +import { Message, Spin, Table, Tabs, Tag } from '@arco-design/web-react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import DetailHeader from './DetailHeader'; +import BackButton from 'components/BackButton'; +import { VersionItem } from 'views/Partner/PartnerList/TableItem'; +import { getWorkflowTableColumns } from 'views/Workflows/WorkflowList/List'; +import PaticipantConnectionStatus, { + globalParticipantIdToConnectionStateMap, +} from 'views/Partner/PartnerList/ConnectionStatus'; +import { resetParticipantsInfo, PARTICIPANT_STATE_MAPPER } from '../shard'; +import { formatTimestamp } from 'shared/date'; +import { participantListQuery } from 'stores/participant'; +import { ProjectListType, ProjectStateType } from 'typings/project'; +import { ParticipantType, Participant } from 'typings/participant'; + +import styles from './index.module.less'; +import Modal from 'components/Modal'; + +const ProjectDetail: FC = () => { + const history = useHistory(); + const myPureDomainName = useGetCurrentPureDomainName(); + const { id, projectListType } = useParams<{ + id: string; + projectListType: ProjectListType; + }>(); + + const [activeKey, setActiveKey] = useState('participant'); + const { data, isLoading } = useQuery( + ['getProjectDetail', id, projectListType], + () => getProjectDetailById(id), + { + enabled: Boolean(id) && projectListType === ProjectListType.COMPLETE, + cacheTime: 1, + refetchOnWindowFocus: false, + }, + ); + const project = data?.data; + + const { data: pendingProjectList, isLoading: pendingProjectListLoading } = useQuery( + ['fetchPendingProjectList', projectListType], + () => fetchPendingProjectList({ page: 1, page_size: 0 }), + { enabled: Boolean(id) && projectListType === ProjectListType.PENDING }, + ); + + const workflowsQuery = useQuery(['fetchWorkflowList', id], () => + fetchWorkflowList({ project: id }), + ); + + const { isLoading: participantListLoading, data: participantList } = useRecoilQuery( + participantListQuery, + ); + + const pendingProjectDetail = useMemo(() => { + return pendingProjectList?.data.find((item) => item.id.toString() === id); + }, [pendingProjectList, id]); + + const completeParticipantList = useMemo(() => { + const participantListFromMap = resetParticipantsInfo( + project?.participants_info?.participants_map ?? + pendingProjectDetail?.participants_info?.participants_map ?? + {}, + participantList ?? [], + myPureDomainName, + ); + return participantListFromMap.length ? participantListFromMap : project?.participants; + }, [project, participantList, pendingProjectDetail, myPureDomainName]); + + const columns = [ + { + title: '合作伙伴名称', + dataIndex: 'name', + width: 160, + render: (value: any, record: any) => + record.pure_domain_name === myPureDomainName ? ( + <div className={styles.participant_name_container}> + <span className={styles.participant_name_content}>{value}</span> + <Tag>我方</Tag> + </div> + ) : ( + <span>{value}</span> + ), + sorter: (a: Participant, b: Participant) => a.name.localeCompare(b.name), + }, + { + title: '受邀状态', + dataIndex: 'state', + render: (val: ProjectStateType) => { + const { color, value } = PARTICIPANT_STATE_MAPPER?.[val ?? ProjectStateType.ACCEPTED]; + return <Tag color={color}>{value}</Tag>; + }, + }, + { + title: '连接状态', + dataIndex: 'status', + width: 150, + sorter: (a: Participant, b: Participant) => { + if ( + globalParticipantIdToConnectionStateMap[b.id] && + globalParticipantIdToConnectionStateMap[a.id] + ) { + return globalParticipantIdToConnectionStateMap[b.id].localeCompare( + globalParticipantIdToConnectionStateMap[a.id], + ); + } + return 1; + }, + render: (_: any, record: Participant) => { + const isLightClient = record.type === ParticipantType.LIGHT_CLIENT; + const isMy = myPureDomainName === record.pure_domain_name; + + if (isLightClient || isMy) { + return '-'; + } + + return <PaticipantConnectionStatus id={record.id} isNeedTip={true} isNeedReCheck={true} />; + }, + }, + { + title: '泛域名', + dataIndex: 'domain_name', + sorter: (a: Participant, b: Participant) => + a.domain_name ?? ''.localeCompare(b.domain_name ?? ''), + render: (value: any) => value || '-', + }, + { + title: '主机号', + dataIndex: 'host', + render: (value: any) => value || '-', + }, + { + title: '端口号', + dataIndex: 'port', + render: (value: any) => value || '-', + }, + { + title: '版本号', + dataIndex: 'version', + render: (_: any, record: Participant) => { + return <VersionItem partnerId={record.id} />; + }, + }, + { + title: '合作伙伴描述', + dataIndex: 'comment', + ellipsis: true, + render: (value: any) => { + return value || '-'; + }, + }, + { + title: '最近活跃时间', + dataIndex: 'last_connected_at', + render: (value: any) => { + return value ? formatTimestamp(value) : '-'; + }, + sorter: (a: Participant, b: Participant) => + (a.last_connected_at || 0) - (b.last_connected_at || 0), + }, + ]; + return ( + <SharedPageLayout title={<BackButton onClick={() => history.goBack()}>工作区管理</BackButton>}> + <Spin loading={isLoading || pendingProjectListLoading || participantListLoading}> + <DetailHeader + project={projectListType === ProjectListType.COMPLETE ? project! : pendingProjectDetail!} + projectListType={projectListType} + onDeleteProject={handleDelete} + /> + <Tabs onChange={onTabChange} activeTab={activeKey}> + <Tabs.TabPane title="合作伙伴" key="participant"> + <Table + className="custom-table" + columns={columns} + data={completeParticipantList} + rowKey="pure_domain_name" + /> + </Tabs.TabPane> + {projectListType === ProjectListType.COMPLETE && ( + <Tabs.TabPane title="工作流任务" key="workflow"> + <Table + className="custom-table" + loading={isLoading && workflowsQuery.isLoading} + data={workflowsQuery.data?.data || []} + columns={getWorkflowTableColumns({ withoutActions: true, withoutFavour: true })} + rowKey="id" + /> + </Tabs.TabPane> + )} + </Tabs> + </Spin> + </SharedPageLayout> + ); + function onTabChange(val: string) { + setActiveKey(val); + } + async function handleDelete(projectId: ID, projectListType: ProjectListType) { + if (!projectId) { + return; + } + try { + const { data: workflowList } = await fetchWorkflowList({ + project: projectId, + states: ['running'], + page: 1, + pageSize: 1, + }); + if (Boolean(workflowList.length)) { + Message.info('有正在运行的任务,请终止任务后再删除'); + return; + } + Modal.delete({ + title: '确认删除工作区?', + content: '删除工作区将清空我方全部资源,请谨慎操作', + async onOk() { + if (projectListType === ProjectListType.PENDING) { + try { + await deletePendingProject(projectId); + Message.success('删除工作区成功'); + history.push('/projects?project_list_type=pending'); + } catch (error: any) { + Message.error(error.message); + } + } else { + try { + await deleteProject(projectId); + Message.success('删除工作区成功'); + history.push('/projects?project_list_type=complete'); + } catch (error: any) { + Message.error(error.message); + } + } + }, + }); + } catch (error: any) { + return error.message; + } + } +}; + +export default ProjectDetail; diff --git a/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCard/index.module.less b/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCard/index.module.less new file mode 100644 index 000000000..72ac93376 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCard/index.module.less @@ -0,0 +1,72 @@ +@import '~styles/mixins.less'; +.card_container{ + transition: box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1) 0s; + border: 0px; + border-radius: 4px; + overflow: hidden; // Prevent card from expanding grid + background-color: #ffffff; + padding: 10px 20px; + + &:hover { + box-shadow: 0px 4px 10px rgb(var(--gray-6)); + } +} +.card_header{ + display: flex; + height: 40px; + justify-content: space-between; + cursor: pointer; + @supports (gap: 10px) { + gap: 10px; + } + .card_header_left{ + .MixinEllipsis(); + display: flex; + align-items: center; + } +} +.card_main{ + display: flex; + cursor: pointer; + font-size: 12px; + margin: 10px 0px; +} + +.card_footer{ + margin: 10px 0px; + .card_footer_left{ + font-size: 12px; + line-height: 22px; + color: rgb(var(--gray-7)); + padding-left: 6px; + + } + .card_footer_right { + padding-right: 0px !important; + display:flex; + align-items: center; + justify-content: space-between; + + } + .progress_name{ + display: block; + margin-bottom: -10px; + font-weight: 400; + font-size: 12px; + line-height: 20px; + color: #1D2129; + + } + .participant_name{ + font-weight: 400; + font-size: 12px; + line-height: 20px; + color: var(--gray-7); + overflow: hidden; + flex-shrink:1; + white-space: nowrap; + text-overflow: ellipsis; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + } +} diff --git a/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCard/index.tsx b/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCard/index.tsx new file mode 100644 index 000000000..486661db2 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCard/index.tsx @@ -0,0 +1,98 @@ +import React, { ReactElement } from 'react'; +import { useGetCurrentPureDomainName } from 'hooks'; +import { Tooltip, Grid, Tag, Space, Divider } from '@arco-design/web-react'; +import ProjectMoreActions from '../../../ProjectMoreActions'; +import CreateTime from '../../../CreateTime'; +import ProjectName from '../../../ProjectName'; +import { + Project, + ProjectListType, + ProjectStateType, + ProjectTicketStatus, + RoleType, +} from 'typings/project'; +import { ParticipantType } from 'typings/participant'; +import { getCoordinateName, PARTICIPANT_TYPE_TAG_MAPPER } from 'views/Projects/shard'; +import { ProjectProgress } from 'views/Projects/shard'; + +import styles from './index.module.less'; + +const { Row, Col } = Grid; + +interface CardProps { + item: Project; + projectListType: ProjectListType; + onViewDetail: (project: Project) => void; + onDeleteProject: (projectId: ID, projectListType: ProjectListType) => void; +} + +function Card({ + item: project, + onViewDetail, + projectListType, + onDeleteProject, +}: CardProps): ReactElement { + const myPureDomainName = useGetCurrentPureDomainName(); + const tagConfig = + PARTICIPANT_TYPE_TAG_MAPPER?.[project?.participant_type || ParticipantType.PLATFORM]; + + return ( + <div className={styles.card_container}> + <div className={styles.card_header} onClick={viewDetail}> + <div className={styles.card_header_left}> + <ProjectName text={project.name} /> + </div> + <ProjectMoreActions + project={project} + projectListType={projectListType} + role={ + project?.participants_info?.participants_map?.[myPureDomainName]?.role ?? + RoleType.COORDINATOR + } + onDeleteProject={onDeleteProject} + /> + </div> + + <div className={styles.card_main} onClick={viewDetail}> + {`${String(project.num_workflow || 0)}个任务`} + </div> + <div> + <Space split={<Divider type="vertical" />}> + <Tag color={tagConfig.color}>{tagConfig.label}</Tag> + <Tag color="gray">{`${ + project?.participants_info?.participants_map?.[myPureDomainName]?.role === + RoleType.COORDINATOR + ? '我方' + : getCoordinateName(project?.participants_info?.participants_map) ?? '我方' + }创建`}</Tag> + </Space> + </div> + <Row gutter={24} className={styles.card_footer} align="center"> + <Col span={6} className={styles.card_footer_left}> + <ProjectProgress + ticketStatus={ + project.state === ProjectStateType.FAILED + ? ProjectTicketStatus.FAILED + : project.ticket_status + } + /> + </Col> + <Col span={18} className={styles.card_footer_right}> + <Tooltip content={project.creator ?? project.creator_username}> + <div className={styles.participant_name}> + {project.creator ?? project.creator_username ?? '-'} + </div> + </Tooltip> + <Divider type="vertical" /> + <CreateTime className={styles.create_time} time={project.created_at} /> + </Col> + </Row> + </div> + ); + + function viewDetail() { + onViewDetail(project); + } +} + +export default Card; diff --git a/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCardProp.module.less b/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCardProp.module.less new file mode 100644 index 000000000..200ff959b --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectList/CardView/ProjectCardProp.module.less @@ -0,0 +1,21 @@ +.div_container{ + display: flex; + flex-direction: column; + width: 50%; + height: 95px; + padding: 10px 20px; + color: rgb(var(--gray-7)); +} + +.label_container{ + margin-bottom: 15px; + font-size: 13px; + line-height: 22px; +} + +.value_container{ + flex: 1; + display: flex; + align-items: center; + color: var(--textColor); +} diff --git a/web_console_v2/client/src/views/Projects/ProjectList/CardView/index.module.less b/web_console_v2/client/src/views/Projects/ProjectList/CardView/index.module.less new file mode 100644 index 000000000..35bce8f36 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectList/CardView/index.module.less @@ -0,0 +1,26 @@ +.card_container{ + --cols: 4; + display: grid; + grid-template-columns: repeat(var(--cols), 1fr); + align-items: start; + justify-content: space-between; + grid-gap: 24px 20px; + width: 100%; + + + @media screen and (min-width: 1920px) and (max-width: 2560px) { + --cols: 5; + } + + @media screen and (max-width: 1440px) { + --cols: 3; + } + + @media screen and (max-width: 1200px) { + --cols: 2; + } + + @media screen and (max-width: 750px) { + --cols: 1; + } +} diff --git a/web_console_v2/client/src/views/Projects/ProjectList/TableView/index.module.less b/web_console_v2/client/src/views/Projects/ProjectList/TableView/index.module.less new file mode 100644 index 000000000..595b589bb --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectList/TableView/index.module.less @@ -0,0 +1,11 @@ +.table_list_container{ + width: 100%; +} +.project_name{ + color: var(--primaryColor); + cursor: pointer; + font-size: var(--textFontSizePrimary); + &:hover { + color: var(--newPrimaryHover); + } +} diff --git a/web_console_v2/client/src/views/Projects/ProjectList/index.module.less b/web_console_v2/client/src/views/Projects/ProjectList/index.module.less new file mode 100644 index 000000000..2f0a90c5b --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ProjectList/index.module.less @@ -0,0 +1,35 @@ +.pagination_container{ + padding: 20px; + background-image: url('../../../assets/images/project-list-bg.png') ; + background-repeat: no-repeat; + background-size: contain; +} +.spin_container{ + display: block; + + } + +.list_container{ + display: flex; + flex: 1; + align-items: flex-start; +} + +.list_filter_container{ + height: 32px; + margin-top: 40px; ; + margin-bottom: 20px; + display: flex; + justify-content: space-between; + .filter_content_input{ + display: inline-block; + width: 250px; + > span >span{ + background-color: #FFFFFF !important; + } + } + .filter_content_radio{ + display: inline-block; + margin-left: 12px; + } +} diff --git a/web_console_v2/client/src/views/Projects/ReceiverProject/index.module.less b/web_console_v2/client/src/views/Projects/ReceiverProject/index.module.less new file mode 100644 index 000000000..78495cc11 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ReceiverProject/index.module.less @@ -0,0 +1,46 @@ +.card_container{ + width:440px; + border: 1px solid rgb(var(--gray-3)); + border-radius: 8px; + margin: auto; + padding: 0px 16px; + .card_header{ + width: 54px; + height: 54px; + margin: 60px auto; + margin-bottom: 50px; + background-image: url('../../../assets/icons/atom-icon-algorithm-management.svg'); + } + .card_content_title{ + text-align: center; + > :first-child{ + margin-right: 0px; + } + } + .card_content_comment{ + width: 60%; + margin: 0px auto; + } + .card_content_participant{ + width: 60%; + margin: 10px auto; + > :first-child{ + margin-bottom: 10px; + } + } + .card_footer{ + width: 60%; + margin: 0px auto; + margin-top: 40px; + margin-bottom: 60px; + .btn_container{ + width: 104px; + } + } +} +.result_container{ + margin: auto; +} +.btn_content{ + width: 124px; +} diff --git a/web_console_v2/client/src/views/Projects/ReceiverProject/index.tsx b/web_console_v2/client/src/views/Projects/ReceiverProject/index.tsx new file mode 100644 index 000000000..a47bdc083 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/ReceiverProject/index.tsx @@ -0,0 +1,188 @@ +import React, { ReactElement, useMemo, useState } from 'react'; +import { useInterval } from 'react-use'; +import { useHistory, useParams } from 'react-router-dom'; +import { + Message as message, + Spin, + Typography, + Button, + Tag, + Space, + Result, + Message, +} from '@arco-design/web-react'; + +import { ResultProps } from '@arco-design/web-react/es/Result'; +import { authorizePendingProject, fetchPendingProjectList } from 'services/project'; +import { ProjectStateType } from 'typings/project'; + +import { useQuery } from 'react-query'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BackButton from 'components/BackButton'; +import ProjectName from '../ProjectName'; + +import { getCoordinateName, getParticipantsName } from '../shard'; + +import styles from './index.module.less'; +import Modal from 'components/Modal'; + +function ResultPage({ status, title }: ResultProps): ReactElement { + const history = useHistory(); + const [redirectCountdown, setRedirectCountdown] = useState<number>(5); + + useInterval(() => { + if (redirectCountdown === 0) { + history.push('/projects'); + return; + } + setRedirectCountdown(redirectCountdown - 1); + }, 1000); + + return ( + <div className={styles.result_container}> + <Result + status={status} + title={title} + subTitle={`${redirectCountdown}s钟后自动回到首页`} + extra={[ + <Button + className={styles.btn_content} + key="back" + type="primary" + onClick={() => { + history.push('/projects?project_list_type=pending'); + }} + > + 回到首页 + </Button>, + ]} + /> + </div> + ); +} + +function ReceiverProject(): ReactElement { + const history = useHistory(); + const { id } = useParams<{ id: string }>(); + const [pageShow, setPageShow] = useState({ + mainPage: true, + okPage: false, + rejectPage: false, + }); + const [btnLoading, setBtnLoading] = useState({ + reject: false, + ok: false, + }); + const pendingProjectListQuery = useQuery(['fetchPendingProjectList'], () => + fetchPendingProjectList(), + ); + const pendingProjectDetail = useMemo(() => { + return pendingProjectListQuery.data?.data.find((item) => item.id.toString() === id); + }, [pendingProjectListQuery, id]); + + return ( + <div className={styles.container}> + <Spin className={styles.spin_container} loading={pendingProjectListQuery.isLoading}> + <SharedPageLayout + title={<BackButton onClick={() => history.goBack()}>工作区管理</BackButton>} + centerTitle="工作区邀请" + > + {pageShow.mainPage && ( + <div className={styles.card_container}> + <div className={styles.card_header} /> + <div className={styles.card_content_title}> + <ProjectName text={pendingProjectDetail?.name ?? ''} /> + <Typography.Text + className={styles.card_content_comment} + type="secondary" + ellipsis={{ + rows: 2, + showTooltip: true, + }} + > + {pendingProjectDetail?.comment || '-'} + </Typography.Text> + </div> + <div className={styles.card_content_participant}> + <Space> + <Tag color="arcoblue">创建方</Tag> + + <Tag> + {getCoordinateName(pendingProjectDetail?.participants_info.participants_map)} + </Tag> + </Space> + <div> + <Space align="start"> + <Tag color="arcoblue">参与方</Tag> + <Space wrap> + {getParticipantsName( + pendingProjectDetail?.participants_info?.participants_map, + ).map((item) => ( + <Tag key={item}>{item}</Tag> + ))} + </Space> + </Space> + </div> + </div> + <div className={styles.card_footer}> + <Space size={'medium'}> + <Button + loading={btnLoading.reject} + className={styles.btn_container} + onClick={onReject} + > + 拒绝 + </Button> + <Button + className={styles.btn_container} + loading={btnLoading.ok} + type="primary" + onClick={onOK} + > + 通过 + </Button> + </Space> + </div> + </div> + )} + {pageShow.okPage && <ResultPage status="success" title="已通过邀请" />} + {pageShow.rejectPage && <ResultPage status="error" title="已拒绝邀请" />} + </SharedPageLayout> + </Spin> + </div> + ); + async function onOK() { + if (!pendingProjectDetail?.id) { + return Message.error('找不到该工作区'); + } + try { + await authorizePendingProject(pendingProjectDetail?.id, { state: ProjectStateType.ACCEPTED }); + setBtnLoading({ ok: true, reject: false }); + setPageShow({ mainPage: false, okPage: true, rejectPage: false }); + } catch (error: any) { + message.error(error.message); + } + } + async function onReject() { + if (!pendingProjectDetail?.id) { + return Message.error('找不到该工作区'); + } + Modal.reject({ + title: '拒绝申请?', + content: '拒绝后无法撤销此操作。', + async onOk() { + try { + await authorizePendingProject(pendingProjectDetail?.id!, { + state: ProjectStateType.CLOSED, + }); + setBtnLoading({ ok: false, reject: true }); + setPageShow({ mainPage: false, okPage: false, rejectPage: true }); + } catch (error: any) { + message.error(error.message); + } + }, + }); + } +} + +export default ReceiverProject; diff --git a/web_console_v2/client/src/views/Projects/index.module.less b/web_console_v2/client/src/views/Projects/index.module.less new file mode 100644 index 000000000..d423dc594 --- /dev/null +++ b/web_console_v2/client/src/views/Projects/index.module.less @@ -0,0 +1,33 @@ + +@import '~styles/mixins.less'; +.create_time_container{ + flex-shrink: 0; + padding-right: 16px; + color: rgb(var(--gray-7)); + font-size: 12px; + line-height: 40px; +} +.project_name_container{ + .MixinEllipsis(); + margin-right: 16px; + color: rgb(var(--gray-10)); + font-weight: 500; + font-size: 20px; + font-family: 'PingFang SC'; + font-style: normal; + line-height: 40px; +} +.progress_container{ + font-size: 12px; + line-height: 22px; + color: rgb(var(--gray-7)); + .progress_name{ + display: block; + margin-bottom: -10px; + font-weight: 400; + font-size: 12px; + line-height: 20px; + color: #1D2129; + } +} + diff --git a/web_console_v2/client/src/views/Projects/shard.tsx b/web_console_v2/client/src/views/Projects/shard.tsx new file mode 100644 index 000000000..0a278240e --- /dev/null +++ b/web_console_v2/client/src/views/Projects/shard.tsx @@ -0,0 +1,187 @@ +import React, { ReactElement, CSSProperties } from 'react'; +import { Progress } from '@arco-design/web-react'; +import { + ParticiPantMap, + RoleType, + ProjectTicketStatus, + ProjectStateType, + ProjectAbilityType, + ProjectActionType, +} from 'typings/project'; +import { Participant, ParticipantType } from 'typings/participant'; +import { FilterOp } from 'typings/filter'; + +import styles from './index.module.less'; + +export function getCoordinateName(participantsMap?: Record<string, ParticiPantMap>) { + if (!participantsMap) { + return undefined; + } + const keyList = Object.keys(participantsMap); + const coordinate = keyList.find((item) => { + return participantsMap?.[item].role === RoleType.COORDINATOR; + }); + return coordinate ? participantsMap?.[coordinate].name : undefined; +} + +export function getParticipantsName(participantsMap?: Record<string, ParticiPantMap>) { + if (!participantsMap) { + return []; + } + const resultParticipantsName: string[] = []; + const keyList = Object.keys(participantsMap); + keyList.forEach((item) => { + if (participantsMap?.[item].role === RoleType.PARTICIPANT) { + resultParticipantsName.push(participantsMap?.[item].name); + } + }); + + return resultParticipantsName; +} + +export const TICKET_STATUS_MAPPER: Record<ProjectTicketStatus, any> = { + APPROVED: { status: 'default', percent: 100, name: '待授权' }, + PENDING: { + status: 'default', + percent: 50, + name: '待审批', + }, + DECLINED: { + status: 'warning', + percent: 50, + name: '审批拒绝', + }, + FAILED: { + status: 'warning', + percent: 100, + name: '失败', + }, +}; + +export const PARTICIPANT_STATE_MAPPER: Record<ProjectStateType, any> = { + PENDING: { + color: 'arcoblue', + value: '待授权', + }, + ACCEPTED: { + color: 'green', + value: '已授权', + }, + FAILED: { + color: 'orange', + value: '失败', + }, + CLOSED: { + color: 'red', + value: '已拒绝', + }, +}; + +export const PARTICIPANT_TYPE_TAG_MAPPER: Record<ParticipantType, any> = { + [ParticipantType.LIGHT_CLIENT]: { + color: 'purple', + label: '轻量级', + }, + [ParticipantType.PLATFORM]: { + color: 'arcoblue', + label: '标准', + }, +}; + +export const PROJECT_TASK_LABEL_MAPPER = { + [ProjectActionType.ID_ALIGNMENT]: 'ID对齐任务', + [ProjectActionType.DATA_ALIGNMENT]: '横向数据对齐任务', + [ProjectActionType.HORIZONTAL_TRAIN]: '横向联邦模型训练', + [ProjectActionType.VERTICAL_TRAIN]: '纵向联邦模型训练', + [ProjectActionType.VERTICAL_EVAL]: '纵向联邦模型评估', + [ProjectActionType.VERTICAL_PRED]: '纵向联邦模型离线预测', + [ProjectActionType.VERTICAL_SERVING]: '纵向联邦模型在线服务', + [ProjectActionType.WORKFLOW]: '工作流任务', + [ProjectActionType.TEE_SERVICE]: '可信分析服务', + [ProjectActionType.TEE_RESULT_EXPORT]: '可信分析服务结果导出', +}; +export const PROJECT_ABILITY_LABEL_MAPPER = { + [ProjectAbilityType.ALWAYS_ALLOW]: '始终允许', + [ProjectAbilityType.ONCE]: '允许一次', + [ProjectAbilityType.MANUAL]: '发起时询问', + [ProjectAbilityType.ALWAYS_REFUSE]: '拒绝', +}; + +export const PENDING_PROJECT_FILTER_MAPPER = { + state: FilterOp.IN, + ticket_status: FilterOp.EQUAL, +}; + +interface Props { + ticketStatus: ProjectTicketStatus; + style?: CSSProperties; + className?: string; +} +export function ProjectProgress({ ticketStatus, style, className }: Props): ReactElement { + const progress = TICKET_STATUS_MAPPER?.[ticketStatus]; + return ( + <div className={`${styles.progress_container} ${className}`} style={style}> + <span className={styles.progress_name}>{progress?.name ?? '成功'}</span> + <Progress + percent={progress?.percent ?? 100} + status={progress?.status ?? 'success'} + showText={false} + trailColor="var(--color-primary-light-1)" + /> + </div> + ); +} + +export function resetParticipantsInfo( + participantMap: Record<string, ParticiPantMap>, + participantList: Participant[], + myPureDomainName: string, +) { + const keyList = Object.keys(participantMap); + const resultList: any[] = []; + keyList.forEach((key: string) => { + const participantDetail = participantList.find((item) => item.pure_domain_name === key) ?? {}; + const completeParticipant = { + ...participantMap?.[key], + ...participantDetail, + pure_domain_name: key, + }; + key !== myPureDomainName && resultList.push(completeParticipant); + }); + const participantsList = resultList.sort((a: any, b: any) => { + return a.name > b.name ? 1 : -1; + }); + participantMap?.[myPureDomainName] && + participantsList.unshift({ + ...participantMap?.[myPureDomainName], + pure_domain_name: myPureDomainName, + state: ProjectStateType.ACCEPTED, + }); + return participantsList; +} + +export function resetAbilitiesTableData( + actionRules?: Record<ProjectActionType, ProjectAbilityType>, +) { + if (!actionRules) { + return []; + } + //保证顺序不变 + const keyList = [ + ProjectActionType.ID_ALIGNMENT, + ProjectActionType.DATA_ALIGNMENT, + ProjectActionType.HORIZONTAL_TRAIN, + ProjectActionType.VERTICAL_TRAIN, + ProjectActionType.VERTICAL_EVAL, + ProjectActionType.VERTICAL_PRED, + ProjectActionType.VERTICAL_SERVING, + ProjectActionType.WORKFLOW, + ProjectActionType.TEE_SERVICE, + ProjectActionType.TEE_RESULT_EXPORT, + ]; + const actionRulesList: any[] = []; + keyList.forEach((item: ProjectActionType) => { + actionRules?.[item] && actionRulesList.push({ ability: item, rule: actionRules?.[item] }); + }); + return actionRulesList; +} diff --git a/web_console_v2/client/src/views/SSOCallback/index.tsx b/web_console_v2/client/src/views/SSOCallback/index.tsx new file mode 100644 index 000000000..a304f4d07 --- /dev/null +++ b/web_console_v2/client/src/views/SSOCallback/index.tsx @@ -0,0 +1,97 @@ +import { FC, useEffect } from 'react'; +import { useParams, useLocation, useHistory } from 'react-router-dom'; +import qs from 'qs'; +import store from 'store2'; +import { useTranslation } from 'react-i18next'; +import LOCAL_STORAGE_KEYS from 'shared/localStorageKeys'; +import { FedLoginWay } from 'typings/auth'; +import { global_login } from '../Login'; +import { useSetRecoilState } from 'recoil'; +import { userInfoQuery } from 'stores/user'; +import { Message } from '@arco-design/web-react'; + +const SSOCallback: FC = () => { + const { t } = useTranslation(); + const setUserInfo = useSetRecoilState(userInfoQuery); + const history = useHistory(); + + const { ssoName } = useParams<{ + ssoName: string; + }>(); + + const location = useLocation(); + + const query = location.search || ''; + + useEffect(() => { + if (!ssoName || !query) { + return; + } + + // Parse url query + const queryObject = qs.parse(query.slice(1)) || {}; // slice(1) to remove '?' prefix + + // Find current login way info + const loginWayList: FedLoginWay[] = store.get(LOCAL_STORAGE_KEYS.app_login_way_list) || []; + + const currentLoginWay = loginWayList.find((item: FedLoginWay) => { + return item.name === ssoName; + }); + + if (!currentLoginWay) { + Message.error( + t('login.error_not_find_sso_info', { + ssoName, + }), + ); + return; + } + + let codeKey = ''; + + switch (currentLoginWay.protocol_type.toLocaleLowerCase()) { + case 'cas': + codeKey = currentLoginWay[currentLoginWay.protocol_type]?.['code_key'] || 'ticket'; + break; + case 'oauth': + case 'oauth2': + codeKey = currentLoginWay[currentLoginWay.protocol_type]?.['code_key'] || 'code'; + break; + default: + codeKey = currentLoginWay[currentLoginWay.protocol_type]?.['code_key'] || 'code'; + break; + } + + const ssoInfo = { + ssoName: currentLoginWay.name, + ssoType: currentLoginWay.protocol_type, + ssoCode: queryObject[codeKey], + codeKey, + }; + + // Store sso_info into localstorage, it will be used in Axios request interceptors as custom HTTP header, like 'x-pc-auth': <sso_name> <type> <credentials> + store.set(LOCAL_STORAGE_KEYS.sso_info, ssoInfo); + // If ssoName,ssoType,ssoCode existed, then call login api with code and sso_name + if (ssoInfo.ssoName && ssoInfo.ssoType && ssoInfo.ssoCode && codeKey) { + try { + global_login( + { + [codeKey]: ssoInfo.ssoCode, + }, + { + sso_name: ssoName, + }, + setUserInfo, + history, + ); + } catch (error) { + Message.error(error.message); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ssoName, query]); + + return null; +}; + +export default SSOCallback; diff --git a/web_console_v2/client/src/views/Settings/ImageVersion/index.module.less b/web_console_v2/client/src/views/Settings/ImageVersion/index.module.less new file mode 100644 index 000000000..65d19f710 --- /dev/null +++ b/web_console_v2/client/src/views/Settings/ImageVersion/index.module.less @@ -0,0 +1,4 @@ +.styled_form { + width: 500px; + margin: 30vh auto auto; +} diff --git a/web_console_v2/client/src/views/Settings/ImageVersion/index.tsx b/web_console_v2/client/src/views/Settings/ImageVersion/index.tsx new file mode 100644 index 000000000..9b293ebd8 --- /dev/null +++ b/web_console_v2/client/src/views/Settings/ImageVersion/index.tsx @@ -0,0 +1,90 @@ +import React, { FC, useState } from 'react'; +import styled from './index.module.less'; +import { useMutation, useQuery } from 'react-query'; + +import { fetchSettingsImage, updateImage } from 'services/settings'; + +import { Form, Input, Button, Tooltip, Notification, Message } from '@arco-design/web-react'; +import SharedPageLayout from 'components/SharedPageLayout'; +import { QuestionCircle } from 'components/IconPark'; + +import { SettingOptions } from 'typings/settings'; + +const ImageVersion: FC = () => { + const [formInstance] = Form.useForm<SettingOptions>(); + const [currentImage, setImage] = useState<string>(); + + const query = useQuery('fetchSettingsImage', fetchSettingsImage, { + onSuccess(res) { + setImage(res.data.value); + formInstance.setFieldsValue({ webconsole_image: res.data.value }); + }, + onError(error: any) { + Message.error(error.message); + }, + refetchOnWindowFocus: false, + retry: 2, + }); + + const mutation = useMutation(updateImage, { + onSuccess() { + const isImageChanged = formInstance.getFieldValue('webconsole_image') !== currentImage; + + if (isImageChanged) { + Notification.info({ + title: '系统配置更新成功', + content: + '已启动更新程序,Pod 开始进行替换,完成后可能需要手动 Port forward,并且该窗口将在几分钟后变得不可用。', + duration: 2 * 1000 * 60, // 2min + }); + } else { + Message.success('编辑成功'); + } + }, + }); + + return ( + <SharedPageLayout title={'全局配置'}> + <Form + className={styled.styled_form} + form={formInstance} + onSubmit={onFinish} + labelCol={{ span: 6 }} + wrapperCol={{ span: 18 }} + > + <Form.Item + field="webconsole_image" + label={ + <> + <span style={{ marginRight: 4 }}>{'镜像版本'}</span> + <Tooltip content={'每次更新 Web Console 镜像版本后需等待一段时间,刷新页面后才可用'}> + <QuestionCircle /> + </Tooltip> + </> + } + rules={[{ required: true, message: '镜像版本为必填项' }]} + > + <Input placeholder={'请选择镜像版本'} disabled={query.isFetching || mutation.isLoading} /> + </Form.Item> + + <Form.Item wrapperCol={{ offset: 6, span: 18 }}> + <Button + disabled={query.isFetching} + type="primary" + htmlType="submit" + loading={mutation.isLoading} + long + > + {'确认'} + </Button> + </Form.Item> + </Form> + </SharedPageLayout> + ); + + async function onFinish(values: any) { + mutation.mutate(values); + } +}; + +export default ImageVersion; diff --git a/web_console_v2/client/src/views/Settings/ImageVersion/proxy.js b/web_console_v2/client/src/views/Settings/ImageVersion/proxy.js new file mode 100644 index 000000000..15a25511b --- /dev/null +++ b/web_console_v2/client/src/views/Settings/ImageVersion/proxy.js @@ -0,0 +1,7 @@ +if (process.env.REACT_APP_ENABLE_IMAGE_VERSION_PAGE !== 'false') { + module.exports = require('./index'); +} else { + module.exports = function () { + return null; + }; +} diff --git a/web_console_v2/client/src/views/Settings/Settings.tsx b/web_console_v2/client/src/views/Settings/Settings.tsx new file mode 100644 index 000000000..0076aafc3 --- /dev/null +++ b/web_console_v2/client/src/views/Settings/Settings.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react'; +import ErrorBoundary from 'components/ErrorBoundary'; +import { Route } from 'react-router-dom'; +import ImageVersion from './ImageVersion'; +import SystemVariables from './SystemVariables'; + +const SettingsPage: FC = () => { + return ( + <ErrorBoundary> + <Route path="/settings/image" exact component={ImageVersion} /> + <Route path="/settings/variables" exact component={SystemVariables} /> + </ErrorBoundary> + ); +}; + +export default SettingsPage; diff --git a/web_console_v2/client/src/views/Settings/SystemVariables/EnvVariablesForm.module.less b/web_console_v2/client/src/views/Settings/SystemVariables/EnvVariablesForm.module.less new file mode 100644 index 000000000..222a91d0d --- /dev/null +++ b/web_console_v2/client/src/views/Settings/SystemVariables/EnvVariablesForm.module.less @@ -0,0 +1,32 @@ +.container { + margin-top: 30px; +} + +.header { + margin-bottom: 20px; +} + +.heading { + margin-bottom: 0; + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: rgb(var(--gray-10)); + transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.no_variables { + color: var(--textColorSecondary); +} + +.list_container { + width: calc(var(--form-width, 500px) * 2); + overflow: hidden; + transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.remove_button { + position: absolute; + right: 0; + color: var(--textColor) !important; +} diff --git a/web_console_v2/client/src/views/Settings/SystemVariables/EnvVariablesForm.tsx b/web_console_v2/client/src/views/Settings/SystemVariables/EnvVariablesForm.tsx new file mode 100644 index 000000000..a30710896 --- /dev/null +++ b/web_console_v2/client/src/views/Settings/SystemVariables/EnvVariablesForm.tsx @@ -0,0 +1,164 @@ +import React, { FC, useCallback, useLayoutEffect, useRef } from 'react'; +import styled from './EnvVariablesForm.module.less'; + +import { convertToUnit, isStringCanBeParsed } from 'shared/helpers'; + +import { Form, Input, Button, Grid, Switch } from '@arco-design/web-react'; +import { Delete, Plus } from 'components/IconPark'; + +import { FormInstance } from '@arco-design/web-react/es/Form'; +import { SystemVariable } from 'typings/settings'; + +const { Row, Col } = Grid; + +export const VARIABLES_FIELD_NAME = 'variables'; +export const VARIABLES_ERROR_CHANNEL = 'system.field_variables_error'; + +const DEFAULT_VARIABLE = { + name: '', + value: '', + fixed: false, + value_type: 'STRING', +}; + +const EnvVariablesForm: FC<{ + layout: { + labelCol: { span: number }; + wrapperCol: { span: number }; + }; + formInstance?: FormInstance; + disabled?: boolean; +}> = ({ layout, disabled, formInstance }) => { + const listInnerRef = useRef<HTMLDivElement>(); + const listContainerRef = useRef<HTMLDivElement>(); + + const setListContainerMaxHeight = useCallback( + (nextHeight: any) => { + listContainerRef.current!.style.maxHeight = convertToUnit(nextHeight); + }, + [listContainerRef], + ); + const getListInnerHeight = useCallback(() => { + return listInnerRef.current!.offsetHeight!; + }, [listInnerRef]); + + useLayoutEffect(() => { + const innerHeight = getListInnerHeight() + 30; + setListContainerMaxHeight(innerHeight); + }); + + return ( + <div className={styled.container}> + <div className={styled.header}> + <Row align="center"> + <Col {...layout.labelCol}> + <h3 className={styled.heading}>{'环境变量参数配置'}</h3> + </Col> + </Row> + </div> + + <div + className={styled.list_container} + ref={listContainerRef as any} + onTransitionEnd={onFoldAnimationEnd} + > + <Form.List field={VARIABLES_FIELD_NAME}> + {(fields, { add, remove }) => ( + <div ref={listInnerRef as any}> + {fields.map((field, index) => { + const list = (formInstance?.getFieldValue(VARIABLES_FIELD_NAME) ?? + []) as SystemVariable[]; + const isFixed = list[index]?.fixed ?? false; + const valueType = list[index]?.value_type ?? 'STRING'; + + return ( + <Row key={field.key} align="center" style={{ position: 'relative' }}> + <Form.Item + style={{ flex: '0 0 50%' }} + label="Name" + field={field.field + '.name'} + rules={[{ required: true, message: '请输入变量名' }]} + > + <Input placeholder="name" disabled={disabled || isFixed} /> + </Form.Item> + + <Form.Item + labelCol={{ span: 4 }} + wrapperCol={{ span: 18 }} + style={{ flex: '0 0 50%' }} + label="Value" + field={field.field + '.value'} + rules={[ + { required: true, message: '请输入变量值' }, + valueType === 'LIST' || valueType === 'OBJECT' + ? { + validator: (value, callback) => { + if (isStringCanBeParsed(value)) { + callback(); + } else { + callback(`JSON ${valueType} 格式错误`); + } + }, + } + : {}, + ]} + > + <Input.TextArea placeholder="value" disabled={disabled} /> + </Form.Item> + <Form.Item + label="fixed" + field={field.field + '.fixed'} + triggerPropName="checked" + hidden + > + <Switch /> + </Form.Item> + <Form.Item label="value_type" field={field.field + '.value_type'} hidden> + <Input /> + </Form.Item> + + {!isFixed && ( + <Button + className={styled.remove_button} + size="small" + icon={<Delete />} + type="text" + onClick={() => remove(index)} + /> + )} + </Row> + ); + })} + {/* Empty placeholder */} + {fields.length === 0 && ( + <Form.Item className={styled.no_variables} wrapperCol={{ offset: 3 }}> + {'当前没有环境变量参数,请添加'} + </Form.Item> + )} + + <Form.Item wrapperCol={{ offset: 3 }}> + {/* DO NOT simplify `() => add()` to `add`, it will pollute form value with $event */} + <Button + type="primary" + size="small" + icon={<Plus />} + onClick={() => add(DEFAULT_VARIABLE)} + > + {'添加参数'} + </Button> + </Form.Item> + </div> + )} + </Form.List> + </div> + </div> + ); + + function onFoldAnimationEnd(_: React.TransitionEvent) { + // Because of user can adjust list inner's height by resize value-textarea or add/remove variable + // we MUST set container's maxHeight to 'initial' after unfolded (after which user can interact) + listContainerRef.current!.style.maxHeight = 'initial'; + } +}; + +export default EnvVariablesForm; diff --git a/web_console_v2/client/src/views/Settings/SystemVariables/index.module.less b/web_console_v2/client/src/views/Settings/SystemVariables/index.module.less new file mode 100644 index 000000000..db82666b9 --- /dev/null +++ b/web_console_v2/client/src/views/Settings/SystemVariables/index.module.less @@ -0,0 +1,13 @@ +.styled_form { + --form-width: 500px; + + display: grid; + grid-auto-rows: auto 1fr auto; + + > .form-title { + margin-bottom: 24px; + + font-size: 27px; + line-height: 36px; + } +} diff --git a/web_console_v2/client/src/views/Settings/SystemVariables/index.tsx b/web_console_v2/client/src/views/Settings/SystemVariables/index.tsx new file mode 100644 index 000000000..900d2dafa --- /dev/null +++ b/web_console_v2/client/src/views/Settings/SystemVariables/index.tsx @@ -0,0 +1,125 @@ +import React, { FC, useState, useRef } from 'react'; +import styled from './index.module.less'; +import { useQuery } from 'react-query'; + +import { fetchSettingVariables, updateSettingVariables } from 'services/settings'; +import { formatValueToString, parseValueFromString } from 'shared/helpers'; + +import { Form, Button, Message, Spin } from '@arco-design/web-react'; +import GridRow from 'components/_base/GridRow'; +import SharedPageLayout from 'components/SharedPageLayout'; +import Modal from 'components/Modal'; +import EnvVariablesForm, { + VARIABLES_FIELD_NAME, + VARIABLES_ERROR_CHANNEL, +} from './EnvVariablesForm'; + +import { FormProps } from '@arco-design/web-react/es/Form'; +import { SettingOptions, SystemVariable } from 'typings/settings'; + +const layout = { + labelCol: { span: 8 }, + wrapperCol: { span: 16 }, +}; +const Systemvariables: FC = () => { + const [form] = Form.useForm(); + + const [loading, setLoading] = useState(false); + const defaultVariables = useRef<SystemVariable[]>([]); + + const systemVariablesQuery = useQuery(['fetchSettingVariables'], () => fetchSettingVariables(), { + onSuccess(res) { + const variables: SystemVariable[] = (res.data?.variables ?? []).map((item) => { + return { + ...item, + value: formatValueToString(item.value, item.value_type), + }; + }); + + defaultVariables.current = variables; + form.setFieldsValue({ + variables: variables, + }); + }, + onError(error: any) { + Message.error(error.message); + }, + cacheTime: 1, + refetchOnWindowFocus: false, + }); + + return ( + <SharedPageLayout title={'全局配置'}> + <Form + className={styled.styled_form} + form={form} + labelCol={{ span: 6 }} + wrapperCol={{ span: 18 }} + onSubmit={onFinish} + onSubmitFailed={onFinishFailed} + > + <Spin loading={systemVariablesQuery.isFetching}> + <EnvVariablesForm layout={layout} formInstance={form} disabled={loading} /> + </Spin> + <Form.Item + wrapperCol={{ offset: 3 }} + style={{ + width: 'calc(var(--form-width, 500px) * 2)', + }} + > + <GridRow gap="16"> + <Button type="primary" loading={loading} onClick={onSubmitClick}> + {'确认'} + </Button> + <Button onClick={onCancelClick}>{'取消'}</Button> + </GridRow> + </Form.Item> + </Form> + </SharedPageLayout> + ); + + function resetForm() { + form.setFieldsValue({ + variables: defaultVariables.current, + }); + } + function onCancelClick() { + Modal.confirm({ + title: '确认取消?', + content: '取消后,已填写内容将不再保留', + onOk: resetForm, + }); + } + function onSubmitClick() { + form.submit(); + } + async function onFinish(data: any) { + setLoading(true); + try { + const params: SettingOptions = { + webconsole_image: undefined, + variables: (data.variables ?? []).map((item: SystemVariable) => { + return { + ...item, + value: parseValueFromString(item.value, item.value_type), + }; + }), + }; + + const systemVariables = await updateSettingVariables({ variables: params.variables }); + Message.success('修改环境变量成功'); + defaultVariables.current = systemVariables.data?.variables ?? []; + } catch (error) { + Message.error(error.message); + } + setLoading(false); + } + function onFinishFailed(errorInfo: Parameters<Required<FormProps>['onSubmitFailed']>[0]) { + const regx = new RegExp(`^${VARIABLES_FIELD_NAME}`); + if (Object.keys(errorInfo).some((key) => regx.test(key))) { + PubSub.publish(VARIABLES_ERROR_CHANNEL); + } + } +}; + +export default Systemvariables; diff --git a/web_console_v2/client/src/views/TokenCallback/index.tsx b/web_console_v2/client/src/views/TokenCallback/index.tsx new file mode 100644 index 000000000..b067305ec --- /dev/null +++ b/web_console_v2/client/src/views/TokenCallback/index.tsx @@ -0,0 +1,87 @@ +import { FC, useEffect } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import qs from 'qs'; +import store from 'store2'; +import { useTranslation } from 'react-i18next'; +import { useSetRecoilState } from 'recoil'; +import { useUnmount } from 'react-use'; + +import { userInfoQuery } from 'stores/user'; +import { getMyUserInfo } from 'services/user'; + +import LOCAL_STORAGE_KEYS from 'shared/localStorageKeys'; + +import { Message } from '@arco-design/web-react'; + +const TokenCallback: FC = () => { + const { t } = useTranslation(); + + const history = useHistory(); + const location = useLocation(); + + const setUserInfo = useSetRecoilState(userInfoQuery); + + const query = location.search || ''; + + useEffect(() => { + if (!query) { + Message.error(t('login.error_not_find_access_token')); + store.remove(LOCAL_STORAGE_KEYS.temp_access_token); + return; + } + + // Parse url query + const queryObject = qs.parse(query.slice(1)) || {}; // slice(1) to remove '?' prefix + + // Get access_token info from queryObject + const accessToken = decodeURIComponent((queryObject['access_token'] as string) ?? ''); + + if (!accessToken) { + Message.error(t('login.error_not_find_access_token')); + store.remove(LOCAL_STORAGE_KEYS.temp_access_token); + return; + } + + // Store accessToken into localstorage, it will be used in Axios request interceptors as HTTP header, like Authorization = `Bearer ${token}` + store.set(LOCAL_STORAGE_KEYS.temp_access_token, accessToken); + + // Call API to get my userInfo + getMyUserInfo() + .then((resp) => { + const { data } = resp; + + // Remove temp_access_token + store.remove(LOCAL_STORAGE_KEYS.temp_access_token); + + // Store userInfo + store.set(LOCAL_STORAGE_KEYS.current_user, { + ...data, + access_token: accessToken, + date: Date.now(), + }); + setUserInfo(data); + + Message.success(t('app.login_success')); + + if (queryObject.from) { + history.replace(decodeURIComponent(queryObject.from as string) || '/projects'); + return; + } + + history.replace('/projects'); + }) + .catch((error) => { + store.remove(LOCAL_STORAGE_KEYS.temp_access_token); + Message.error(error + ''); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query]); + + useUnmount(() => { + store.remove(LOCAL_STORAGE_KEYS.temp_access_token); + }); + + return null; +}; + +export default TokenCallback; diff --git a/web_console_v2/client/src/views/TrustedCenter/CreateTrustedJobGroup/index.tsx b/web_console_v2/client/src/views/TrustedCenter/CreateTrustedJobGroup/index.tsx new file mode 100644 index 000000000..a18e5c9bc --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/CreateTrustedJobGroup/index.tsx @@ -0,0 +1,8 @@ +import React, { FC } from 'react'; +import TrustedJobGroupForm from '../TrustedJobGroupForm'; + +const CreateTrustedJobGroup: FC = () => { + return <TrustedJobGroupForm />; +}; + +export default CreateTrustedJobGroup; diff --git a/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/ApplicationResult/index.less b/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/ApplicationResult/index.less new file mode 100644 index 000000000..004318605 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/ApplicationResult/index.less @@ -0,0 +1,26 @@ +.passed-container { + margin: 100px auto 0 auto; + padding: 70px; + text-align: center; + .arco-result-icon { + margin-bottom: 10px; + } + .arco-result-icon-tip { + width: 68px; + height: 68px; + } + .arco-icon { + margin-top: 20px; + font-size: 30px; + } + .arco-result-title { + margin-bottom: 30px; + font-size: 16px; + } + .arco-result-subtitle { + font-size: 12px; + } + .arco-result-extra { + margin-top: 10px; + } +} diff --git a/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/ApplicationResult/index.tsx b/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/ApplicationResult/index.tsx new file mode 100644 index 000000000..090cb4a56 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/ApplicationResult/index.tsx @@ -0,0 +1,58 @@ +import React, { FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory, useParams } from 'react-router'; +import BackButton from 'components/BackButton'; +import SharedPageLayout from 'components/SharedPageLayout'; +import { Button, Result } from '@arco-design/web-react'; +import './index.less'; +import { useInterval } from 'react-use'; + +const REDIRECT_COUNTDOWN_DURATION = 5; +const ApplicationResult: FC = () => { + const { t } = useTranslation(); + const history = useHistory(); + const [redirectCountdown, setRedirectCountdown] = useState(REDIRECT_COUNTDOWN_DURATION); + const { result } = useParams<{ result: string }>(); + + useInterval(() => { + if (redirectCountdown === 0) { + goBackTrustedCenter(); + return; + } + setRedirectCountdown(redirectCountdown - 1); + }, 1000); + + const layoutTitle = ( + <BackButton onClick={goBackTrustedCenter}> + {t('trusted_center.label_trusted_center')} + </BackButton> + ); + return ( + <SharedPageLayout title={layoutTitle} centerTitle="数据集导出申请"> + <div className="passed-container"> + <Result + status={result === 'passed' ? 'success' : 'warning'} + title={ + result === 'passed' + ? t('trusted_center.title_passed') + : t('trusted_center.title_rejected') + } + subTitle={t('trusted_center.title_status_tip', { + second: redirectCountdown, + })} + extra={[ + <Button key="back" type="primary" onClick={goBackTrustedCenter}> + {t('trusted_center.btn_go_back')} + </Button>, + ]} + /> + </div> + </SharedPageLayout> + ); + + function goBackTrustedCenter() { + history.push('/trusted-center'); + } +}; + +export default ApplicationResult; diff --git a/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/index.less b/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/index.less new file mode 100644 index 000000000..481f521d6 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/index.less @@ -0,0 +1,40 @@ +.dataset-application-container { + width: 440px; + height: 438px; + margin: 120px auto 0 auto; + padding: 70px; + border: 1px solid #e5e6eb; + border-radius: 8px; + text-align: center; + .avatar { + display: inline-block; + width: 54px; + height: 54px; + } + .title { + font-weight: 500; + font-size: 20px; + margin-top: 60px; + } + .comment { + width: 300px; + height: 36px; + font-weight: 400; + font-size: 12px; + line-height: 18px; + } + .tag-container { + margin-top: 21px; + } + .bottom { + width: 230px; + height: 32px; + margin: 40px auto 0 auto; + display: flex; + justify-content: space-between; + .bottom___button { + width: 104px; + height: 32px; + } + } +} diff --git a/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/index.tsx b/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/index.tsx new file mode 100644 index 000000000..8a5ca8aff --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/DatasetExportApplication/index.tsx @@ -0,0 +1,78 @@ +import React, { FC } from 'react'; +import { useHistory, useParams } from 'react-router'; +import { Tag, Button, Message } from '@arco-design/web-react'; +import BackButton from 'components/BackButton'; +import SharedPageLayout from 'components/SharedPageLayout'; +import { updateTrustedJob } from 'services/trustedCenter'; +import { useGetCurrentProjectId, useGetCurrentProjectParticipantList } from 'hooks'; +import CONSTANTS from 'shared/constants'; +import { AuthStatus } from 'typings/trustedCenter'; +import { Avatar } from '../shared'; +import './index.less'; + +const DatasetExportApplication: FC = () => { + const projectId = useGetCurrentProjectId(); + const params = useParams<{ id: string; coordinator_id: string; name: string }>(); + const history = useHistory(); + const participantList = useGetCurrentProjectParticipantList(); + const participant = participantList.filter((item) => item?.id === Number(params.coordinator_id)); + const layoutTitle = <BackButton onClick={goBackTrustedCenter}>{'可信中心'}</BackButton>; + + return ( + <SharedPageLayout title={layoutTitle} centerTitle="数据集导出申请"> + <div className="dataset-application-container"> + <Avatar className="avatar" /> + <h3 className="title">{`「${params.name || CONSTANTS.EMPTY_PLACEHOLDER}」 的导出申请`}</h3> + <div className="comment"> + {'该数据集为可信中心安全计算生成的计算结果,导出时需各合作伙伴审批通过'} + </div> + <div className="tag-container"> + <Tag color="arcoblue">{'发起方'}</Tag> + <Tag style={{ marginLeft: '10px' }}> + {participant?.[0]?.name || CONSTANTS.EMPTY_PLACEHOLDER} + </Tag> + </div> + <div className="bottom"> + <Button className="bottom___button" onClick={onReject}> + {'拒绝'} + </Button> + <Button className="bottom___button" type="primary" onClick={onPass}> + {'通过'} + </Button> + </div> + </div> + </SharedPageLayout> + ); + + function goBackTrustedCenter() { + history.push('/trusted-center'); + } + + async function onReject() { + try { + await updateTrustedJob(projectId!, params.id, { + comment: '', + auth_status: AuthStatus.WITHDRAW, + }); + history.push('/trusted-center/dataset-application/rejected'); + } catch (error) { + Message.error(error.message); + return Promise.reject(error); + } + } + + async function onPass() { + try { + await updateTrustedJob(projectId!, params.id, { + comment: '', + auth_status: AuthStatus.AUTHORIZED, + }); + history.push('/trusted-center/dataset-application/passed'); + } catch (error) { + Message.error(error.message); + return Promise.reject(error); + } + } +}; + +export default DatasetExportApplication; diff --git a/web_console_v2/client/src/views/TrustedCenter/EditTrustedJobGroup/index.tsx b/web_console_v2/client/src/views/TrustedCenter/EditTrustedJobGroup/index.tsx new file mode 100644 index 000000000..5be47b32f --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/EditTrustedJobGroup/index.tsx @@ -0,0 +1,8 @@ +import React, { FC } from 'react'; +import TrustedJobForm from '../TrustedJobGroupForm'; + +const EditTrustedJobGroup: FC = () => { + return <TrustedJobForm isEdit={true} />; +}; + +export default EditTrustedJobGroup; diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobDetail/index.less b/web_console_v2/client/src/views/TrustedCenter/TrustedJobDetail/index.less new file mode 100644 index 000000000..9a37052e8 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobDetail/index.less @@ -0,0 +1,5 @@ +.display-dataset__tooltip { + display: flex; + align-items: center; + margin-top: -4px; +} diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobDetail/index.tsx b/web_console_v2/client/src/views/TrustedCenter/TrustedJobDetail/index.tsx new file mode 100644 index 000000000..ef3bf4adc --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobDetail/index.tsx @@ -0,0 +1,331 @@ +import React, { FC, useMemo, useState } from 'react'; +import { Drawer, Table, Button, Tag, Tooltip } from '@arco-design/web-react'; +import { useTranslation } from 'react-i18next'; +import PropertyList from 'components/PropertyList'; +import { TrustedJob, TrustedJobGroup, TrustedJobStatus } from 'typings/trustedCenter'; +import { useQuery } from 'react-query'; +import { fetchTrustedJob } from 'services/trustedCenter'; +import { useGetCurrentProjectId } from 'hooks'; +import { Pod, PodState } from 'typings/job'; +import { fetchJobById } from 'services/workflow'; +import { formatTimestamp } from 'shared/date'; +import CONSTANTS from 'shared/constants'; +import CountTime from 'components/CountTime'; +import WhichAlgorithm from 'components/WhichAlgorithm'; +import WhichDataset from 'components/WhichDataset'; +import StateIndicator from 'components/StateIndicator'; +import { getPodState } from 'views/Workflows/shared'; +import dayjs from 'dayjs'; +import './index.less'; +import WhichParticipant from 'components/WhichParticipant'; + +export enum TResourceFieldType { + MASTER = 'master', + PS = 'ps', + WORKER = 'worker', +} + +export type TrustedJobProps = { + visible: boolean; + id?: ID; + jobId?: ID; + toggleVisible: (val: any) => void; + group: TrustedJobGroup; +}; + +const TrustedJobDetail: FC<TrustedJobProps> = ({ visible, toggleVisible, id, jobId, group }) => { + const { t } = useTranslation(); + const [trustedJobInfo, setTrustedJobInfo] = useState<TrustedJob>(); + const projectId = useGetCurrentProjectId(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const trustedJobQuery = useQuery( + ['fetchTrustedJob', id], + () => { + return fetchTrustedJob(projectId!, id!); + }, + { + retry: 1, + refetchOnWindowFocus: false, + enabled: visible && Boolean(id), + onSuccess(res) { + setTrustedJobInfo(res.data); + }, + }, + ); + + const jobsQuery = useQuery( + ['fetchJobById', jobId], + () => fetchJobById(jobId).then((res) => res.data.pods), + { + enabled: visible && Boolean(jobId), + retry: 1, + refetchOnWindowFocus: false, + }, + ); + + const jobList = useMemo(() => { + if (!jobsQuery?.data) { + return []; + } + const jobs = jobsQuery.data || []; + return jobs; + }, [jobsQuery.data]); + + const displayedProps = useMemo( + () => [ + { + value: '可信计算', + label: t('trusted_center.label_algorithm_type'), + }, + { + value: ( + <WhichAlgorithm + id={trustedJobInfo?.algorithm_id || 0} + uuid={trustedJobInfo?.algorithm_uuid} + participantId={group?.algorithm_participant_id} + /> + ), + label: t('trusted_center.label_algorithm_select'), + }, + { + value: renderDatasetTooltip(group), + label: t('trusted_center.col_trusted_job_dataset'), + }, + { + value: trustedJobInfo?.started_at + ? formatTimestamp(trustedJobInfo?.started_at || 0) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: t('trusted_center.col_trusted_job_start_time'), + }, + { + value: trustedJobInfo?.finished_at + ? formatTimestamp(trustedJobInfo?.finished_at || 0) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: t('trusted_center.col_trusted_job_end_time'), + }, + { + value: trustedJobInfo?.status ? renderRuntime(trustedJobInfo) : CONSTANTS.EMPTY_PLACEHOLDER, + label: t('trusted_center.col_trusted_job_runtime'), + }, + { + value: ( + <div> + {trustedJobInfo?.resource ? ( + <div> + <Tag color="arcoblue">{TResourceFieldType.WORKER}</Tag> + <span>{`${trustedJobInfo?.resource.cpu / 1000}CPU+${ + trustedJobInfo?.resource.memory + }GiB*${trustedJobInfo?.resource.replicas}个实例`}</span> + </div> + ) : ( + CONSTANTS.EMPTY_PLACEHOLDER + )} + </div> + ), + label: t('trusted_center.title_resource_config'), + }, + { + value: + trustedJobInfo?.coordinator_id === 0 ? ( + t('trusted_center.label_coordinator_self') + ) : ( + <WhichParticipant id={trustedJobInfo?.coordinator_id} /> + ), + label: '发起方', + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [trustedJobInfo], + ); + + const columns = useMemo( + () => [ + { + title: t('trusted_center.col_instance_id'), + dataIndex: 'name', + name: 'name', + }, + { + dataIndex: 'state', + title: '状态', + filters: [ + PodState.SUCCEEDED, + PodState.RUNNING, + PodState.FAILED, + PodState.PENDING, + PodState.FAILED_AND_FREED, + PodState.SUCCEEDED_AND_FREED, + PodState.UNKNOWN, + ].map((state) => { + const { text } = getPodState({ state } as Pod); + return { + text, + value: state, + }; + }), + onFilter: (state: PodState, record: Pod) => { + return record?.state === state; + }, + render(state: any, record: Pod) { + return <StateIndicator {...getPodState(record)} />; + }, + }, + { + title: t('trusted_center.col_instance_start_at'), + dataIndex: 'created_at', + name: 'created_at', + render(_: any, record: any) { + return record.creation_timestamp + ? formatTimestamp(record.creation_timestamp) + : CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + title: t('trusted_center.col_trusted_job_operation'), + dataIndex: 'operation', + name: 'operation', + render: (_: any, record: any) => { + return ( + <> + <Button + type="text" + onClick={() => { + onLogClick(record); + }} + > + {t('trusted_center.btn_inspect_logs')} + </Button> + </> + ); + }, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [jobId], + ); + + return ( + <Drawer + width={807} + title={ + <span>{t('trusted_center.title_trusted_job_detail', { name: trustedJobInfo?.name })}</span> + } + visible={visible} + onOk={() => { + toggleVisible(false); + }} + onCancel={() => { + toggleVisible(false); + }} + > + {renderBasicInfo()} + {renderInstanceInfo()} + </Drawer> + ); + + function renderBasicInfo() { + return ( + <> + <h3>{t('trusted_center.title_base_info')}</h3> + <PropertyList cols={6} colProportions={[1.5, 1, 1]} properties={displayedProps} /> + </> + ); + } + + function renderInstanceInfo() { + return ( + <> + <h3>{t('trusted_center.title_instance_info')}</h3> + <Table + loading={trustedJobQuery.isFetching} + size="small" + rowKey="name" + scroll={{ x: '100%' }} + columns={columns} + data={jobId ? jobList : []} + pagination={{ + showTotal: true, + pageSizeChangeResetCurrent: true, + hideOnSinglePage: true, + }} + /> + </> + ); + } + + function renderRuntime(trustedJobInfo: TrustedJob) { + let isRunning = false; + let isStopped = true; + let runningTime = 0; + + const { status } = trustedJobInfo; + const { PENDING, RUNNING, STOPPED, SUCCEEDED, FAILED } = TrustedJobStatus; + isRunning = [RUNNING, PENDING].includes(status); + isStopped = [STOPPED, SUCCEEDED, FAILED].includes(status); + + if (isRunning || isStopped) { + const { finished_at, started_at } = trustedJobInfo; + runningTime = isStopped ? finished_at! - started_at! : dayjs().unix() - started_at!; + } + return <CountTime time={runningTime} isStatic={!isRunning} />; + } + + function onLogClick(pod: Pod) { + const startTime = 0; + window.open(`/v2/logs/pod/${jobId}/${pod.name}/${startTime}`, '_blank noopener'); + } + + function renderDatasetTooltip(record: TrustedJobGroup) { + if (!record) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + // without participant datasets + if (record.participant_datasets.items?.length === 0) { + return <WhichDataset.DatasetDetail id={record.dataset_id} />; + } + + const hasMyDataset = record.dataset_id !== 0; + let length = record.participant_datasets.items?.length || 0; + if (hasMyDataset) { + length += 1; + } + const datasets = record.participant_datasets.items!; + const nameList = datasets.map((item) => { + return item.name; + }); + + return ( + <div className="display-dataset__tooltip"> + {hasMyDataset ? ( + <WhichDataset.DatasetDetail id={record.dataset_id} /> + ) : ( + <div style={{ marginTop: '3px' }}>{nameList[0]}</div> + )} + {length > 1 ? ( + <Tooltip + position="top" + trigger="hover" + color="#FFFFFF" + content={nameList.map((item, index) => { + if (!hasMyDataset && index === 0) return <></>; + return ( + <> + <Tag style={{ marginTop: '5px' }} key={index}> + {item} + </Tag> + <br /> + </> + ); + })} + > + <Tag>{`+${length - 1}`}</Tag> + </Tooltip> + ) : ( + <></> + )} + </div> + ); + } +}; + +export default TrustedJobDetail; diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ComputingJobTab/index.tsx b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ComputingJobTab/index.tsx new file mode 100644 index 000000000..f85ac1799 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ComputingJobTab/index.tsx @@ -0,0 +1,371 @@ +import { Button, Input, Message, Progress, Table, Tooltip } from '@arco-design/web-react'; +import NoResult from 'components/NoResult'; +import GridRow from 'components/_base/GridRow'; +import Modal from 'components/Modal'; +import dayjs from 'dayjs'; +import { useGetCurrentProjectId, useUrlState } from 'hooks'; +import React, { FC, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { useParams } from 'react-router'; +import { useToggle } from 'react-use'; +import { + exportTrustedJobResult, + fetchTrustedJobList, + stopTrustedJob, + updateTrustedJob, +} from 'services/trustedCenter'; +import CONSTANTS from 'shared/constants'; +import { formatTimestamp } from 'shared/date'; +import { + AuthStatus, + TicketAuthStatus, + TrustedJobGroup, + TrustedJobGroupTabType, + TrustedJobListItem, + TrustedJobStatus, +} from 'typings/trustedCenter'; +import { Edit } from 'components/IconPark'; +import CountTime from 'components/CountTime'; +import StateIndicator from 'components/StateIndicator'; +import { getTicketAuthStatus, getTrustedJobStatus } from 'shared/trustedCenter'; +import { to } from 'shared/helpers'; +import TrustedJobDetail from 'views/TrustedCenter/TrustedJobDetail'; +import { AuthStatusMap } from 'views/TrustedCenter/shared'; + +export type Props = { + trustedJobGroup: TrustedJobGroup; +}; + +const ComputingJobTab: FC<Props> = ({ trustedJobGroup }) => { + const { t } = useTranslation(); + const projectId = useGetCurrentProjectId(); + const params = useParams<{ id: string; tabType: TrustedJobGroupTabType }>(); + const [commentVisible, setCommentVisible] = useState(false); + const [comment, setComment] = useState(''); + const [trustedJobId, setTrustedJobId] = useState<ID>(); + const [selectedJobId, setSelectedJobId] = useState<ID>(); + const [drawerVisible, toggleDrawerVisible] = useToggle(false); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: '', + }); + + const listQuery = useQuery( + ['trustedJobListQuery', params], + () => { + return fetchTrustedJobList(projectId!, { trusted_job_group_id: params.id }); + }, + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const columns = useMemo( + () => [ + { + title: '名称', + dataIndex: 'name', + name: 'name', + ellipsis: true, + render: (name: string, record: TrustedJobListItem) => { + return ( + <GridRow left={-13}> + <Button type="text" size="mini" onClick={() => onCheck(record)}> + {name} + </Button> + </GridRow> + ); + }, + }, + { + title: '授权状态', + dataIndex: 'ticket_auth_status', + name: 'ticket_auth_status', + render: (_: any, record: TrustedJobListItem) => { + const data = getTicketAuthStatus(record); + return ( + <> + <Tooltip + position="tl" + content={ + record.ticket_auth_status === TicketAuthStatus.AUTH_PENDING + ? renderUnauthParticipantList(record) + : undefined + } + > + <div>{data.text}</div> + </Tooltip> + <Progress + percent={data.percent} + showText={false} + style={{ width: 100 }} + status={data.type} + /> + </> + ); + }, + }, + { + title: '任务状态', + dataIndex: 'status', + name: 'status', + render: (_: any, record: TrustedJobListItem) => { + return ( + <div className="indicator-with-tip"> + <StateIndicator {...getTrustedJobStatus(record)} /> + </div> + ); + }, + }, + { + title: '运行时长', + dataIndex: 'runtime', + name: 'runtime', + render: (_: any, record: TrustedJobListItem) => { + let isRunning = false; + let isStopped = true; + let runningTime = 0; + + const { status } = record; + const { PENDING, RUNNING, STOPPED, SUCCEEDED, FAILED } = TrustedJobStatus; + isRunning = [RUNNING, PENDING].includes(status); + isStopped = [STOPPED, SUCCEEDED, FAILED].includes(status); + + if (isRunning || isStopped) { + const { finished_at, started_at } = record; + runningTime = isStopped ? finished_at! - started_at! : dayjs().unix() - started_at!; + } + return <CountTime time={runningTime} isStatic={!isRunning} />; + }, + }, + { + title: '开始时间', + dataIndex: 'started_at', + name: 'started_at', + sorter(a: TrustedJobListItem, b: TrustedJobListItem) { + return a.started_at - b.started_at; + }, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + { + title: '结束时间', + dataIndex: 'finished_at', + name: 'finished_at', + sorter(a: TrustedJobListItem, b: TrustedJobListItem) { + return a.finished_at - b.finished_at; + }, + render: (date: number) => ( + <div>{date ? formatTimestamp(date) : CONSTANTS.EMPTY_PLACEHOLDER}</div> + ), + }, + { + title: '备注', + dataIndex: 'comment', + name: 'comment', + width: 180, + render: (_: any, record: TrustedJobListItem) => { + return ( + <GridRow> + {record.comment ? ( + <Tooltip position="tl" content={record.comment}> + <div className="col-description">{record.comment}</div> + </Tooltip> + ) : ( + <></> + )} + + <Button + type="text" + size="mini" + icon={<Edit />} + onClick={() => { + setTrustedJobId(record.id); + setComment(record.comment); + setCommentVisible(true); + }} + /> + </GridRow> + ); + }, + }, + { + title: t('trusted_center.col_trusted_job_operation'), + dataIndex: 'operation', + name: 'operation', + render: (_: any, record: TrustedJobListItem) => { + return ( + <GridRow left={-15}> + <Button + disabled={record.status !== TrustedJobStatus.RUNNING} + type="text" + size="mini" + onClick={() => { + Modal.terminate({ + title: `确认终止${record.name || ''}吗?`, + content: '终止后,该任务将无法重启,请谨慎操作', + onOk() { + stopTrustedJob(projectId!, record.id) + .then(() => { + Message.success('终止成功!'); + listQuery.refetch(); + }) + .catch((error) => { + Message.error(error.message); + }); + }, + }); + }} + > + {'终止'} + </Button> + <Button + type="text" + disabled={record.status !== TrustedJobStatus.SUCCEEDED} + size="mini" + onClick={() => { + Modal.confirm({ + title: `可信数据导出申请`, + content: '导出需要工作区合作伙伴共同审批,是否确认发起申请?', + onOk() { + exportTrustedJobResult(projectId!, record.id) + .then(() => { + Message.success('开始导出,请在数据中心-结果数据集查看导出结果'); + }) + .catch((error) => { + Message.error(error.message); + }); + }, + }); + }} + > + {'导出'} + </Button> + </GridRow> + ); + }, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const listShow = useMemo(() => { + if (!listQuery.data?.data) { + return []; + } + const trustedJobList = listQuery.data.data || []; + return trustedJobList; + }, [listQuery.data]); + + const isEmpty = false; + + return ( + <div> + <div className="list-container"> + {isEmpty ? ( + <NoResult text="暂无可信计算任务" /> + ) : ( + <Table + rowKey="id" + className="custom-table custom-table-left-side-filter" + loading={listQuery.isFetching} + data={listShow} + scroll={{ x: '100%' }} + columns={columns} + pagination={{ + showTotal: true, + pageSizeChangeResetCurrent: true, + hideOnSinglePage: true, + total: listQuery.data?.page_meta?.total_items ?? undefined, + current: Number(urlState.page), + pageSize: Number(urlState.pageSize), + onChange: onPageChange, + }} + /> + )} + </div> + <Modal + title={t('trusted_center.title_edit_trusted_job', { name: trustedJobGroup?.name })} + id={trustedJobId} + visible={commentVisible} + onOk={() => onCommentModalConfirm()} + onCancel={() => { + setCommentVisible(false); + setComment(''); + }} + autoFocus={false} + focusLock={true} + > + <div className="modal-label">{t('trusted_center.label_trusted_job_comment')}</div> + <Input.TextArea + placeholder={t('trusted_center.placeholder_trusted_job_set_comment')} + autoSize={{ minRows: 3 }} + value={comment} + onChange={setComment} + /> + </Modal> + <TrustedJobDetail + visible={drawerVisible} + group={trustedJobGroup!} + toggleVisible={toggleDrawerVisible} + id={trustedJobId} + jobId={selectedJobId} + /> + </div> + ); + + function onPageChange(page: number, pageSize: number | undefined) { + setUrlState((prevState) => ({ + ...prevState, + page, + pageSize, + })); + } + + function onCheck(record: any) { + setTrustedJobId(record.id); + setSelectedJobId(record.job_id); + toggleDrawerVisible(true); + } + + async function onCommentModalConfirm() { + const [res, error] = await to( + updateTrustedJob(projectId!, trustedJobId!, { + comment: comment, + }), + ); + setCommentVisible(false); + setComment(''); + if (error) { + Message.error(error.message); + return; + } + if (res.data) { + const msg = '编辑成功'; + Message.success(msg); + listQuery.refetch(); + return; + } + } + + function renderUnauthParticipantList(record: any) { + return ( + <div> + {Object.keys(record.participants_info.participants_map).map((key) => { + return ( + <div>{`${key} ${ + AuthStatusMap[ + record.participants_info?.participants_map[key].auth_status as AuthStatus + ] + }`}</div> + ); + })} + </div> + ); + } +}; + +export default ComputingJobTab; diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ExportJobDetailDrawer/index.tsx b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ExportJobDetailDrawer/index.tsx new file mode 100644 index 000000000..c39c37618 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ExportJobDetailDrawer/index.tsx @@ -0,0 +1,295 @@ +import React, { FC, useMemo, useState } from 'react'; +import { Drawer, Table, Button, Tooltip, Progress } from '@arco-design/web-react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import PropertyList from 'components/PropertyList'; +import { AuthStatus, TicketAuthStatus, TrustedJob, TrustedJobStatus } from 'typings/trustedCenter'; +import { useQuery } from 'react-query'; +import { fetchTrustedJob } from 'services/trustedCenter'; +import { useGetCurrentProjectId } from 'hooks'; +import { Pod, PodState } from 'typings/job'; +import { fetchJobById } from 'services/workflow'; +import { formatTimestamp } from 'shared/date'; +import CONSTANTS from 'shared/constants'; +import CountTime from 'components/CountTime'; +import StateIndicator from 'components/StateIndicator'; +import { getPodState } from 'views/Workflows/shared'; +import dayjs from 'dayjs'; +import { getTicketAuthStatus, getTrustedJobStatus } from 'shared/trustedCenter'; +import { DatasetDetailSubTabs } from 'views/Datasets/DatasetDetail'; + +const AuthStatusMap: Record<AuthStatus, string> = { + [AuthStatus.AUTHORIZED]: '已授权', + [AuthStatus.PENDING]: '待授权', + [AuthStatus.WITHDRAW]: '拒绝授权', +}; + +export type ExportJobProps = { + visible: boolean; + id?: ID; + jobId?: ID; + toggleVisible: (val: any) => void; +}; + +const ExportJobDetailDrawer: FC<ExportJobProps> = ({ visible, toggleVisible, id, jobId }) => { + const { t } = useTranslation(); + const [trustedJobInfo, setTrustedJobInfo] = useState<TrustedJob>(); + const projectId = useGetCurrentProjectId(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const trustedJobQuery = useQuery( + ['fetchTrustedJob', id], + () => { + return fetchTrustedJob(projectId!, id!); + }, + { + retry: 1, + refetchOnWindowFocus: false, + enabled: visible && Boolean(id), + onSuccess(res) { + setTrustedJobInfo(res.data); + }, + }, + ); + const jobsQuery = useQuery( + ['fetchJobById', jobId], + () => fetchJobById(jobId).then((res) => res.data.pods), + { + enabled: visible && Boolean(jobId), + retry: 1, + refetchOnWindowFocus: false, + }, + ); + + const jobList = useMemo(() => { + if (!jobsQuery?.data) { + return []; + } + const jobs = jobsQuery.data || []; + return jobs; + }, [jobsQuery.data]); + + const displayedProps = useMemo( + () => [ + { + value: trustedJobInfo?.name, + label: '导出任务', + }, + { + value: trustedJobInfo?.started_at + ? formatTimestamp(trustedJobInfo?.started_at || 0) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '开始时间', + }, + { + value: trustedJobInfo?.finished_at + ? formatTimestamp(trustedJobInfo?.finished_at || 0) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: '结束时间', + }, + { + value: trustedJobInfo?.status ? renderRuntime(trustedJobInfo) : CONSTANTS.EMPTY_PLACEHOLDER, + label: '运行时长', + }, + { + value: (() => { + const data = getTicketAuthStatus(trustedJobInfo!); + return ( + <> + <Tooltip + position="tl" + content={ + trustedJobInfo?.ticket_auth_status === TicketAuthStatus.AUTH_PENDING + ? renderUnauthParticipantList(trustedJobInfo) + : undefined + } + > + <div>{data.text}</div> + </Tooltip> + <Progress + percent={data.percent} + showText={false} + style={{ width: 100 }} + status={data.type} + /> + </> + ); + })(), + label: '审批状态', + }, + { + value: (() => { + return ( + <div className="indicator-with-tip"> + <StateIndicator {...getTrustedJobStatus(trustedJobInfo!)} /> + {trustedJobInfo?.status === TrustedJobStatus.SUCCEEDED && ( + <Link + to={`/datasets/processed/detail/${trustedJobInfo.export_dataset_id}/${DatasetDetailSubTabs.DatasetJobDetail}`} + > + 查看数据集 + </Link> + )} + </div> + ); + })(), + label: '任务状态', + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [trustedJobInfo], + ); + + const columns = useMemo( + () => [ + { + title: t('trusted_center.col_instance_id'), + dataIndex: 'name', + name: 'name', + }, + { + dataIndex: 'state', + title: '状态', + filters: [ + PodState.SUCCEEDED, + PodState.RUNNING, + PodState.FAILED, + PodState.PENDING, + PodState.FAILED_AND_FREED, + PodState.SUCCEEDED_AND_FREED, + PodState.UNKNOWN, + ].map((state) => { + const { text } = getPodState({ state } as Pod); + return { + text, + value: state, + }; + }), + onFilter: (state: PodState, record: Pod) => { + return record?.state === state; + }, + render(state: any, record: Pod) { + return <StateIndicator {...getPodState(record)} />; + }, + }, + { + title: t('trusted_center.col_instance_start_at'), + dataIndex: 'created_at', + name: 'created_at', + render(_: any, record: any) { + return record.creation_timestamp + ? formatTimestamp(record.creation_timestamp) + : CONSTANTS.EMPTY_PLACEHOLDER; + }, + }, + { + title: t('trusted_center.col_trusted_job_operation'), + dataIndex: 'operation', + name: 'operation', + render: (_: any, record: any) => { + return ( + <> + <Button + type="text" + onClick={() => { + onLogClick(record); + }} + > + {t('trusted_center.btn_inspect_logs')} + </Button> + </> + ); + }, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [jobId], + ); + + return ( + <Drawer + width={807} + title={ + <span>{t('trusted_center.title_trusted_job_detail', { name: trustedJobInfo?.name })}</span> + } + visible={visible} + onOk={() => { + toggleVisible(false); + }} + onCancel={() => { + toggleVisible(false); + }} + > + {renderBasicInfo()} + {renderInstanceInfo()} + </Drawer> + ); + + function renderBasicInfo() { + return ( + <> + <h3>{t('trusted_center.title_base_info')}</h3> + <PropertyList cols={6} colProportions={[1.5, 1, 1]} properties={displayedProps} /> + </> + ); + } + + function renderInstanceInfo() { + return ( + <> + <h3>{t('trusted_center.title_instance_info')}</h3> + <Table + loading={trustedJobQuery.isFetching} + size="small" + rowKey="name" + scroll={{ x: '100%' }} + columns={columns} + data={jobId ? jobList : []} + pagination={{ + showTotal: true, + pageSizeChangeResetCurrent: true, + hideOnSinglePage: true, + }} + /> + </> + ); + } + + function renderRuntime(trustedJobInfo: TrustedJob) { + let isRunning = false; + let isStopped = true; + let runningTime = 0; + + const { status } = trustedJobInfo; + const { PENDING, RUNNING, STOPPED, SUCCEEDED, FAILED } = TrustedJobStatus; + isRunning = [RUNNING, PENDING].includes(status); + isStopped = [STOPPED, SUCCEEDED, FAILED].includes(status); + + if (isRunning || isStopped) { + const { finished_at, started_at } = trustedJobInfo; + runningTime = isStopped ? finished_at! - started_at! : dayjs().unix() - started_at!; + } + return <CountTime time={runningTime} isStatic={!isRunning} />; + } + + function onLogClick(pod: Pod) { + const startTime = 0; + window.open(`/v2/logs/pod/${jobId}/${pod.name}/${startTime}`, '_blank noopener'); + } + + function renderUnauthParticipantList(record: any) { + return ( + <div> + {Object.keys(record.participants_info.participants_map).map((key) => { + return ( + <div>{`${key} ${ + AuthStatusMap[ + record.participants_info?.participants_map[key].auth_status as AuthStatus + ] + }`}</div> + ); + })} + </div> + ); + } +}; + +export default ExportJobDetailDrawer; diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ExportJobTab/index.tsx b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ExportJobTab/index.tsx new file mode 100644 index 000000000..c6aa45979 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/ExportJobTab/index.tsx @@ -0,0 +1,268 @@ +import { Button, Message, Progress, Table, Tooltip } from '@arco-design/web-react'; +import NoResult from 'components/NoResult'; +import GridRow from 'components/_base/GridRow'; +import Modal from 'components/Modal'; +import dayjs from 'dayjs'; +import { useGetCurrentProjectId, useUrlState } from 'hooks'; +import React, { FC, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useParams } from 'react-router'; +import { useToggle } from 'react-use'; +import { fetchTrustedJobList, stopTrustedJob } from 'services/trustedCenter'; +import CONSTANTS from 'shared/constants'; +import { formatTimestamp } from 'shared/date'; +import { + AuthStatus, + TicketAuthStatus, + TrustedJobGroupTabType, + TrustedJobListItem, + TrustedJobParamType, + TrustedJobStatus, +} from 'typings/trustedCenter'; +import CountTime from 'components/CountTime'; +import StateIndicator from 'components/StateIndicator'; +import { getTicketAuthStatus, getTrustedJobStatus } from 'shared/trustedCenter'; +import ExportJobDetailDrawer from '../ExportJobDetailDrawer'; +import { AuthStatusMap } from 'views/TrustedCenter/shared'; + +const ExportJobTab: FC = () => { + const projectId = useGetCurrentProjectId(); + const params = useParams<{ id: string; tabType: TrustedJobGroupTabType }>(); + const [trustedJobId, setTrustedJobId] = useState<ID>(); + const [selectedJobId, setSelectedJobId] = useState<ID>(); + const [drawerVisible, toggleDrawerVisible] = useToggle(false); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: '', + }); + + const listQuery = useQuery( + ['trustedJobListQuery', params], + () => { + return fetchTrustedJobList(projectId!, { + trusted_job_group_id: params.id, + type: TrustedJobParamType.EXPORT, + }); + }, + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const columns = useMemo( + () => [ + { + title: '名称', + dataIndex: 'name', + name: 'name', + ellipsis: true, + render: (name: string, record: TrustedJobListItem) => { + return ( + <GridRow left={-13}> + <Button type="text" size="mini" onClick={() => onCheck(record)}> + {name} + </Button> + </GridRow> + ); + }, + }, + { + title: '授权状态', + dataIndex: 'ticket_auth_status', + name: 'ticket_auth_status', + render: (_: any, record: TrustedJobListItem) => { + const data = getTicketAuthStatus(record); + return ( + <> + <Tooltip + position="tl" + content={ + record.ticket_auth_status === TicketAuthStatus.AUTH_PENDING + ? renderUnauthParticipantList(record) + : undefined + } + > + <div>{data.text}</div> + </Tooltip> + <Progress + percent={data.percent} + showText={false} + style={{ width: 100 }} + status={data.type} + /> + </> + ); + }, + }, + { + title: '任务状态', + dataIndex: 'status', + name: 'status', + render: (_: any, record: TrustedJobListItem) => { + return ( + <div className="indicator-with-tip"> + <StateIndicator {...getTrustedJobStatus(record)} /> + </div> + ); + }, + }, + { + title: '运行时长', + dataIndex: 'runtime', + name: 'runtime', + render: (_: any, record: TrustedJobListItem) => { + let isRunning = false; + let isStopped = true; + let runningTime = 0; + + const { status } = record; + const { PENDING, RUNNING, STOPPED, SUCCEEDED, FAILED } = TrustedJobStatus; + isRunning = [RUNNING, PENDING].includes(status); + isStopped = [STOPPED, SUCCEEDED, FAILED].includes(status); + + if (isRunning || isStopped) { + const { finished_at, started_at } = record; + runningTime = isStopped ? finished_at! - started_at! : dayjs().unix() - started_at!; + } + return <CountTime time={runningTime} isStatic={!isRunning} />; + }, + }, + { + title: '开始时间', + dataIndex: 'started_at', + name: 'started_at', + sorter(a: TrustedJobListItem, b: TrustedJobListItem) { + return a.started_at - b.started_at; + }, + render: (date: number) => ( + <div>{date ? formatTimestamp(date) : CONSTANTS.EMPTY_PLACEHOLDER}</div> + ), + }, + { + title: '结束时间', + dataIndex: 'finished_at', + name: 'finished_at', + sorter(a: TrustedJobListItem, b: TrustedJobListItem) { + return a.finished_at - b.finished_at; + }, + render: (date: number) => ( + <div>{date ? formatTimestamp(date) : CONSTANTS.EMPTY_PLACEHOLDER}</div> + ), + }, + { + title: '操作', + dataIndex: 'operation', + name: 'operation', + render: (_: any, record: TrustedJobListItem) => { + return ( + <GridRow left={-15}> + <Button + disabled={record.status !== TrustedJobStatus.RUNNING} + type="text" + size="mini" + onClick={() => { + Modal.terminate({ + title: `确认终止${record.name || ''}吗?`, + content: '终止后,该任务将无法重启,请谨慎操作', + onOk() { + stopTrustedJob(projectId!, record.id) + .then(() => { + Message.success('终止成功!'); + listQuery.refetch(); + }) + .catch((error) => { + Message.error(error.message); + }); + }, + }); + }} + > + {'终止'} + </Button> + </GridRow> + ); + }, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const listShow = useMemo(() => { + if (!listQuery.data?.data) { + return []; + } + const trustedJobList = listQuery.data.data || []; + return trustedJobList; + }, [listQuery.data]); + + const isEmpty = false; + + return ( + <div> + <div className="list-container"> + {isEmpty ? ( + <NoResult text="暂无导出任务" /> + ) : ( + <Table + rowKey="id" + className="custom-table custom-table-left-side-filter" + loading={listQuery.isFetching} + data={listShow} + scroll={{ x: '100%' }} + columns={columns} + pagination={{ + showTotal: true, + pageSizeChangeResetCurrent: true, + hideOnSinglePage: true, + total: listQuery.data?.page_meta?.total_items ?? undefined, + current: Number(urlState.page), + pageSize: Number(urlState.pageSize), + onChange: onPageChange, + }} + /> + )} + </div> + <ExportJobDetailDrawer + visible={drawerVisible} + toggleVisible={toggleDrawerVisible} + id={trustedJobId} + jobId={selectedJobId} + /> + </div> + ); + + function renderUnauthParticipantList(record: any) { + return ( + <div> + {Object.keys(record.participants_info.participants_map).map((key) => { + return ( + <div>{`${key} ${ + AuthStatusMap[ + record.participants_info?.participants_map[key].auth_status as AuthStatus + ] + }`}</div> + ); + })} + </div> + ); + } + + function onPageChange(page: number, pageSize: number | undefined) { + setUrlState((prevState) => ({ + ...prevState, + page, + pageSize, + })); + } + + function onCheck(record: any) { + setTrustedJobId(record.id); + setSelectedJobId(record.job_id); + toggleDrawerVisible(true); + } +}; + +export default ExportJobTab; diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/index.module.less b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/index.module.less new file mode 100644 index 000000000..9213c366b --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/index.module.less @@ -0,0 +1,62 @@ +.padding_container { + padding: 20px 20px 0; + .header_name { + display: flex; + align-items: center; + height: 24px; + font-size: 16px; + font-weight: 600; + .header_name__tag { + height: 24px; + font-size: 12px; + margin-left: 8px; + border-radius: 40px; + } + } + .header_comment { + font-size: 12px; + color: var(--textColorSecondary); + } + .header_col { + margin-top: 9px; + text-align: right; + } + .display_dataset__tooltip { + margin-top: -4px; + display: flex; + align-items: center; + } + .content { + position: relative; + .list_container { + display: flex; + flex: 1; + width: 100%; + .indicator_with_tip { + display: flex; + flex-direction: row; + align-items: center; + } + } + } +} + +.col_description { + width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modal_label { + text-align: left; + margin-bottom: 10px; +} + +.data_detail_tab_pane { + display: grid; +} + +.data_detail_tab { + margin-bottom: 0 !important; +} diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/index.tsx b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/index.tsx new file mode 100644 index 000000000..72ca57cee --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupDetail/index.tsx @@ -0,0 +1,351 @@ +import React, { FC, useMemo, useState } from 'react'; +import { useHistory, useParams } from 'react-router'; +import { useQuery } from 'react-query'; +import { + Button, + Grid, + Input, + Message, + Progress, + Space, + Tabs, + Tag, + Tooltip, +} from '@arco-design/web-react'; +import { useGetCurrentProjectId } from 'hooks'; +import { useTranslation } from 'react-i18next'; + +import Modal from 'components/Modal'; +import BackButton from 'components/BackButton'; +import SharedPageLayout from 'components/SharedPageLayout'; +import WhichParticipant from 'components/WhichParticipant'; +import MoreActions from 'components/MoreActions'; +import PropertyList from 'components/PropertyList'; +import WhichDataset from 'components/WhichDataset'; + +import atomIcon from 'assets/icons/atom-icon-algorithm-management.svg'; +import { Avatar } from '../shared'; +import routeMaps from '../routes'; +import { formatTimestamp } from 'shared/date'; +import CONSTANTS from 'shared/constants'; +import styled from './index.module.less'; + +import { + deleteTrustedJobGroup, + fetchTrustedJobGroupById, + launchTrustedJobGroup, +} from 'services/trustedCenter'; +import { + AuthStatus, + TrustedJobGroup, + TrustedJobGroupStatus, + TrustedJobGroupTabType, +} from 'typings/trustedCenter'; +import ComputingJobTab from './ComputingJobTab'; +import ExportJobTab from './ExportJobTab'; +import { to } from 'shared/helpers'; +import { getTicketAuthStatus } from 'shared/trustedCenter'; + +const Row = Grid.Row; +const Col = Grid.Col; + +export enum CommentModalType { + INITIATE = 'initiate', + EDIT = 'edit', +} + +const TrustedJobGroupDetail: FC<{ isEdit?: boolean }> = ({ isEdit }) => { + const history = useHistory(); + const { t } = useTranslation(); + const projectId = useGetCurrentProjectId(); + const params = useParams<{ id: string; tabType: TrustedJobGroupTabType }>(); + const [trustedJobGroup, setTrustedJobGroup] = useState<TrustedJobGroup>(); + const [commentVisible, setCommentVisible] = useState(false); + const [comment, setComment] = useState(''); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const trustedJobGroupQuery = useQuery( + ['fetchTrustedJobGroupById', params.id], + () => { + return fetchTrustedJobGroupById(projectId!, params.id); + }, + { + retry: 1, + refetchOnWindowFocus: false, + onSuccess(res) { + setTrustedJobGroup(res.data); + }, + }, + ); + + const displayedProps = useMemo( + () => [ + { + value: + trustedJobGroup?.coordinator_id === 0 ? ( + t('trusted_center.label_coordinator_self') + ) : ( + <WhichParticipant id={trustedJobGroup?.coordinator_id} /> + ), + label: t('trusted_center.col_trusted_job_coordinator'), + }, + { + value: ( + <div> + <div>{getTicketAuthStatus(trustedJobGroup!).text}</div> + <Progress + percent={getTicketAuthStatus(trustedJobGroup!).percent} + showText={false} + style={{ width: 100 }} + status={getTicketAuthStatus(trustedJobGroup!).type} + /> + </div> + ), + label: t('trusted_center.col_trusted_job_status'), + }, + { + value: renderDatasetTooltip(trustedJobGroup!), + label: t('trusted_center.col_trusted_job_dataset'), + }, + { + value: trustedJobGroup?.creator_username || CONSTANTS.EMPTY_PLACEHOLDER, + label: t('trusted_center.col_trusted_job_creator'), + }, + { + value: formatTimestamp(trustedJobGroup?.updated_at || 0), + label: t('trusted_center.col_trusted_job_update_at'), + }, + { + value: formatTimestamp(trustedJobGroup?.created_at || 0), + label: t('trusted_center.col_trusted_job_create_at'), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [trustedJobGroup], + ); + + return ( + <SharedPageLayout + title={ + <BackButton onClick={goBackToListPage}> + {t('trusted_center.label_trusted_center')} + </BackButton> + } + cardPadding={0} + > + <div className={styled.padding_container}> + <Row> + <Col span={12}> + <Space size="medium"> + <Avatar data-name={CONSTANTS.EMPTY_PLACEHOLDER} bgSrc={atomIcon} /> + <div> + <div className={styled.header_name}> + <div>{trustedJobGroup?.name ?? '....'}</div> + <Tag className={styled.header_name__tag}>可信计算</Tag> + <Tag className={styled.header_name__tag}> {`ID ${params.id}`}</Tag> + </div> + <Space className={styled.header_comment}> + {trustedJobGroup?.comment ?? CONSTANTS.EMPTY_PLACEHOLDER} + </Space> + </div> + </Space> + </Col> + <Col span={12} className={styled.header_col}> + <Space> + <Button + type="primary" + disabled={ + !trustedJobGroup?.name || + trustedJobGroup.status !== TrustedJobGroupStatus.SUCCEEDED || + trustedJobGroup.auth_status !== AuthStatus.AUTHORIZED || + trustedJobGroup.unauth_participant_ids?.length !== 0 + } + onClick={() => { + setCommentVisible(true); + }} + > + {t('trusted_center.btn_post_task')} + </Button> + <Button + disabled={ + !trustedJobGroup?.name || + trustedJobGroup.status !== TrustedJobGroupStatus.SUCCEEDED + } + onClick={() => + trustedJobGroup?.coordinator_id + ? history.push(`/trusted-center/edit/${params.id}/receiver`) + : history.push(`/trusted-center/edit/${params.id}/sender`) + } + > + {t('edit')} + </Button> + <MoreActions + actionList={[ + { + label: t('delete'), + danger: true, + disabled: !trustedJobGroup?.name || Boolean(trustedJobGroup.coordinator_id), + onClick: () => { + Modal.confirm({ + title: `确认删除${trustedJobGroup?.name || ''}吗?`, + content: '删除后,该可信计算将无法进行操作,请谨慎删除', + onOk() { + deleteTrustedJobGroup(projectId!, params.id) + .then(() => { + Message.success(t('trusted_center.msg_delete_success')); + }) + .catch((error) => { + Message.error(error.message); + }); + }, + }); + }, + }, + ]} + /> + </Space> + </Col> + </Row> + <PropertyList + cols={6} + colProportions={[1, 1, 1, 1, 1.5, 1.5]} + properties={displayedProps} + /> + </div> + <Tabs + defaultActiveTab={params.tabType} + onChange={(tab) => history.push(getTabPath(tab))} + style={{ marginBottom: 0 }} + className={styled.data_detail_tab} + > + <Tabs.TabPane + title="计算任务" + key={TrustedJobGroupTabType.COMPUTING} + className={styled.data_detail_tab_pane} + /> + <Tabs.TabPane + title="导出任务" + key={TrustedJobGroupTabType.EXPORT} + className={styled.data_detail_tab_pane} + /> + </Tabs> + <div style={{ padding: '20px 20px 0' }}> + {params.tabType === TrustedJobGroupTabType.COMPUTING && ( + <ComputingJobTab trustedJobGroup={trustedJobGroup!} /> + )} + {params.tabType === TrustedJobGroupTabType.EXPORT && <ExportJobTab />} + </div> + <Modal + title={t('trusted_center.title_initiate_trusted_job', { name: trustedJobGroup?.name })} + visible={commentVisible} + onOk={() => onCommentModalConfirm()} + onCancel={() => { + setCommentVisible(false); + setComment(''); + }} + autoFocus={false} + focusLock={true} + > + <div className={styled.modal_label}>{t('trusted_center.label_trusted_job_comment')}</div> + <Input.TextArea + placeholder={t('trusted_center.placeholder_trusted_job_set_comment')} + autoSize={{ minRows: 3 }} + value={comment} + onChange={setComment} + /> + </Modal> + </SharedPageLayout> + ); + + function getTabPath(tabType: string) { + let path = `/trusted-center/detail/${params.id}/computing`; + switch (tabType) { + case TrustedJobGroupTabType.COMPUTING: + path = `/trusted-center/detail/${params.id}/${TrustedJobGroupTabType.COMPUTING}`; + break; + case TrustedJobGroupTabType.EXPORT: + path = `/trusted-center/detail/${params.id}/${TrustedJobGroupTabType.EXPORT}`; + break; + default: + break; + } + return path; + } + + function goBackToListPage() { + history.push(routeMaps.TrustedJobGroupList); + } + + function renderDatasetTooltip(record: TrustedJobGroup) { + if (!record) { + return CONSTANTS.EMPTY_PLACEHOLDER; + } + // without participant datasets + if (record.participant_datasets.items?.length === 0) { + return <WhichDataset.DatasetDetail id={record.dataset_id} />; + } + + const hasMyDataset = record.dataset_id !== 0; + let length = record.participant_datasets.items?.length || 0; + if (hasMyDataset) { + length += 1; + } + const datasets = record.participant_datasets.items!; + const nameList = datasets.map((item) => { + return item.name; + }); + + return ( + <div className={styled.display_dataset__tooltip}> + {hasMyDataset ? ( + <WhichDataset.DatasetDetail id={record.dataset_id} /> + ) : ( + <div style={{ marginTop: '3px' }}>{nameList[0]}</div> + )} + {length > 1 ? ( + <Tooltip + position="top" + trigger="hover" + color="#FFFFFF" + content={nameList.map((item, index) => { + if (!hasMyDataset && index === 0) return <></>; + return ( + <> + <Tag style={{ marginTop: '5px' }} key={index}> + {item} + </Tag> + <br /> + </> + ); + })} + > + <Tag>{`+${length - 1}`}</Tag> + </Tooltip> + ) : ( + <></> + )} + </div> + ); + } + + async function onCommentModalConfirm() { + const [res, error] = await to( + launchTrustedJobGroup(projectId!, params.id, { + comment: comment, + }), + ); + setCommentVisible(false); + setComment(''); + if (error) { + Message.error(error.message); + return; + } + if (res.data) { + const msg = '发布成功'; + Message.success(msg); + return; + } + } +}; + +export default TrustedJobGroupDetail; diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupForm/index.less b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupForm/index.less new file mode 100644 index 000000000..0b9390f85 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupForm/index.less @@ -0,0 +1,21 @@ +.group-form-container { + .card { + .arco-card-body { + padding: 32px 40px; + .form { + max-width: 600px; + margin: 0 auto; + .form-section { + margin-bottom: 20px; + overflow: hidden; // bfc + > h3 { + margin-bottom: 20px; + font-weight: 500; + font-size: 14px; + color: #1d252f; + } + } + } + } + } +} diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupForm/index.tsx b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupForm/index.tsx new file mode 100644 index 000000000..763658118 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupForm/index.tsx @@ -0,0 +1,449 @@ +import React, { FC, useState } from 'react'; +import { useHistory, useParams } from 'react-router'; +import { Avatar, Button, Card, Form, Input, Message, Space, Spin } from '@arco-design/web-react'; +import { IconInfoCircle } from '@arco-design/web-react/icon'; +import { useToggle } from 'react-use'; +import { useQuery } from 'react-query'; +import { LabelStrong } from 'styles/elements'; +import { getResourceConfigInitialValues, defaultTrustedJobGroup } from '../shared'; +import { to } from 'shared/helpers'; + +import BackButton from 'components/BackButton'; +import DatasetSelect from 'components/DatasetSelect'; +import SharedPageLayout from 'components/SharedPageLayout'; +import ButtonWithModalConfirm from 'components/ButtonWithModalConfirm'; +import ResourceConfig, { Value as ResourceConfigValue } from 'components/ResourceConfig'; +import TitleWithIcon from 'components/TitleWithIcon'; +import { useTranslation } from 'react-i18next'; +import routeMaps from '../routes'; +import './index.less'; + +import { + useGetCurrentProjectId, + useGetCurrentProjectParticipantList, + useGetCurrentProjectParticipantName, + useIsFormValueChange, +} from 'hooks'; +import { + ResourceTemplateType, + TrustedJobGroupPayload, + ParticipantDataset, + AuthStatus, + TrustedJobGroup, + TrustedJobGroupStatus, +} from 'typings/trustedCenter'; +import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import { Dataset, DatasetDataType, DatasetKindBackEndType } from 'typings/dataset'; +import { + createTrustedJobGroup, + fetchTrustedJobGroupById, + launchTrustedJobGroup, + updateTrustedJobGroup, +} from 'services/trustedCenter'; +import AlgorithmSelect, { AlgorithmSelectValue } from 'components/AlgorithmSelect'; + +type FormData = TrustedJobGroupPayload & { + resource_config: ResourceConfigValue; + algorithm_type: EnumAlgorithmProjectType; + algorithm_info?: AlgorithmSelectValue; + self_dataset_info: Dataset; + participant: any; +}; + +const TrustedJobGroupForm: FC<{ isEdit?: boolean }> = ({ isEdit }) => { + const [formInstance] = Form.useForm<FormData>(); + const history = useHistory(); + const { t } = useTranslation(); + const projectId = useGetCurrentProjectId(); + const participantList = useGetCurrentProjectParticipantList(); + const participantName = useGetCurrentProjectParticipantName(); + const params = useParams<{ id: string; role: 'sender' | 'receiver' }>(); + + const { isFormValueChanged, onFormValueChange } = useIsFormValueChange(onFormChange); + const [trustedJobGroup, setTrustedJobGroup] = useState<TrustedJobGroup>(defaultTrustedJobGroup); + const [formData, setFormData] = useState<Partial<FormData>>(); + const [algorithmOwner, setAlgorithmOwner] = useState<string>(''); + const [isLaunch, toggleIsLaunch] = useToggle(false); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const trustedJobGroupQuery = useQuery( + ['fetchTrustedJobGroupById', params.id], + () => { + return fetchTrustedJobGroupById(projectId!, params.id); + }, + { + enabled: Boolean(isEdit), + retry: 1, + refetchOnWindowFocus: false, + onSuccess(res) { + const data = res.data; + setTrustedJobGroup(data); + setAlgorithmOwner(data.algorithm_participant_id === 0 ? 'self' : 'peer'); + const participant_datasets: any[] = []; + data.participant_datasets.items.forEach((item) => { + participant_datasets[item.participant_id as number] = { + dataset_info: { + participant_id: item.participant_id, + uuid: item.uuid, + name: item.name, + }, + }; + }); + formInstance.setFieldsValue({ + name: data.name, + comment: data.comment, + algorithm_id: data.algorithm_id, + algorithm_info: { + //可信计算暂时不需要配置算法超参数 + algorithmId: data.algorithm_id, + algorithmProjectUuid: data.algorithm_project_uuid, + algorithmUuid: data.algorithm_uuid, + participantId: data.algorithm_participant_id, + }, + + self_dataset_info: { + id: data.dataset_id, + }, + participant: participant_datasets, + resource_config: data?.resource + ? getResourceConfigInitialValues(data.resource!) + : undefined, + }); + }, + }, + ); + + const isReceiver = params.role === 'receiver'; + const isPeerUnauthorized = isReceiver && !trustedJobGroup?.resource; + const algorithmType = EnumAlgorithmProjectType.TRUSTED_COMPUTING; + + return ( + <SharedPageLayout + title={ + <BackButton onClick={goBackToListPage}> + {t('trusted_center.label_trusted_center')} + </BackButton> + } + contentWrapByCard={false} + centerTitle={isEdit ? (isPeerUnauthorized ? '授权可信计算' : '编辑可信计算') : '创建可信计算'} + > + <Spin loading={trustedJobGroupQuery.isLoading}> + <div className="group-form-container"> + {isPeerUnauthorized ? renderReceiverLayout() : renderSenderLayout()} + </div> + </Spin> + </SharedPageLayout> + ); + + function renderReceiverLayout() { + return ( + <> + {isEdit && renderBannerCard()} + {renderContentCard()} + </> + ); + } + function renderSenderLayout() { + return <>{renderContentCard()}</>; + } + function renderBannerCard() { + const title = t('trusted_center.title_authorization_request', { + peerName: participantName, + name: trustedJobGroupQuery.data?.data?.name ?? '', + }); + return ( + <Card className="card" bordered={false} style={{ marginBottom: 20 }}> + <Space size="medium"> + <Avatar /> + <> + <LabelStrong fontSize={16}>{title ?? '....'}</LabelStrong> + <TitleWithIcon + title={t('trusted_center.tip_agree_authorization')} + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + </> + </Space> + </Card> + ); + } + function renderContentCard() { + return ( + <Card className="card" bordered={false}> + <Form + className="form" + form={formInstance} + initialValues={formData} + onSubmit={onSubmit} + onValuesChange={onFormValueChange} + scrollToFirstError + > + {renderBaseInfoConfig()} + {renderComputingConfig()} + {renderResourceConfig()} + {renderFooterButton()} + </Form> + </Card> + ); + } + + function renderBaseInfoConfig() { + return ( + <section className="form-section"> + <h3>{t('trusted_center.title_base_info')}</h3> + <Form.Item + field="name" + label={t('trusted_center.label_computing_name')} + rules={[{ required: true, message: t('trusted_center.msg_required') }]} + disabled={isEdit} + > + <Input placeholder={t('trusted_center.placeholder_input')} /> + </Form.Item> + <Form.Item field="comment" label={t('trusted_center.label_description')}> + <Input.TextArea placeholder={t('trusted_center.placeholder_input_comment')} /> + </Form.Item> + </section> + ); + } + + function renderComputingConfig() { + return ( + <section className="form-section"> + <h3>{t('trusted_center.title_computing_config')}</h3> + <Form.Item + field="algorithm_info" + label={t('trusted_center.label_algorithm_select')} + rules={[{ required: true, message: t('trusted_center.msg_required') }]} + > + <AlgorithmSelect + algorithmType={[algorithmType]} + algorithmOwnerType={algorithmOwner} + onAlgorithmOwnerChange={(value: any) => setAlgorithmOwner(value)} + leftDisabled={isEdit || isReceiver} + rightDisabled={isReceiver} + showHyperParameters={false} + filterReleasedAlgo={true} + /> + </Form.Item> + <Form.Item + field="self_dataset_info" + label={t('trusted_center.label_our_dataset')} + disabled={isEdit} + > + <DatasetSelect + lazyLoad={{ + enable: true, + page_size: 10, + }} + isParticipant={false} + isCreateVisible={!isPeerUnauthorized} + filterOptions={{ + dataset_format: [DatasetDataType.NONE_STRUCTURED], + dataset_kind: [DatasetKindBackEndType.RAW], + }} + placeholder={t('trusted_center.placeholder_select')} + /> + </Form.Item> + {participantList.map((item, index) => { + return ( + <Form.Item + field={`participant.${item.id}.dataset_info`} + label={item.name} + disabled={isEdit} + > + <DatasetSelect + queryParams={{ + //TODO Temporarily obtain full data and will be removed soon + page_size: 0, + }} + isParticipant={true} + isCreateVisible={!isPeerUnauthorized} + filterOptions={{ + dataset_format: [DatasetDataType.NONE_STRUCTURED], + dataset_kind: [DatasetKindBackEndType.RAW], + participant_id: item.id, + }} + placeholder={t('trusted_center.placeholder_select_dataset')} + /> + </Form.Item> + ); + })} + </section> + ); + } + + function renderResourceConfig() { + return ( + <section className="form-section"> + <h3>{t('trusted_center.title_resource_config')}</h3> + <Form.Item + field="resource_config" + label={t('model_center.label_resource_template')} + rules={[{ required: true, message: t('model_center.msg_required') }]} + > + <ResourceConfig + isTrustedCenter={true} + defaultResourceType={ResourceTemplateType.CUSTOM} + isIgnoreFirstRender={isReceiver} + /> + </Form.Item> + </section> + ); + } + + function renderFooterButton() { + let submitText = '提交并申请'; + if (isPeerUnauthorized) { + submitText = '确认授权'; + } else if (isEdit) { + submitText = '保存并执行'; + } + + return ( + <> + {!isReceiver && !isEdit && ( + <TitleWithIcon + title={t('trusted_center.msg_trusted_computing_create')} + isLeftIcon={true} + isShowIcon={true} + icon={IconInfoCircle} + /> + )} + <Space> + {isPeerUnauthorized || !isEdit ? ( + <></> + ) : ( + <Button type="primary" onClick={() => formInstance.submit()}> + 保存 + </Button> + )} + <Button + type="primary" + disabled={ + isEdit && + !isPeerUnauthorized && + (trustedJobGroup?.status !== TrustedJobGroupStatus.SUCCEEDED || + trustedJobGroup?.auth_status !== AuthStatus.AUTHORIZED || + trustedJobGroup?.unauth_participant_ids?.length !== 0) + } + onClick={() => { + if (isEdit && !isPeerUnauthorized) { + toggleIsLaunch(); + } + formInstance.submit(); + }} + > + {submitText} + </Button> + <ButtonWithModalConfirm + isShowConfirmModal={isFormValueChanged} + onClick={goBackToListPage} + > + {t('cancel')} + </ButtonWithModalConfirm> + </Space> + </> + ); + } + + async function onSubmit() { + if (!projectId) { + return Message.error(t('select_project_notice')); + } + // validate params + const self_dataset_info = formInstance.getFieldValue('self_dataset_info'); + const participant_datasets: ParticipantDataset[] = []; + const participantParams: any = formInstance.getFieldValue('participant'); + // self and participants dataset empty + if (!self_dataset_info && !participantParams) { + Message.warning('我方数据集及合作伙伴数据集不能全为空!'); + return; + } + participantList.forEach((item) => { + const dataset_info = participantParams?.[item.id]?.dataset_info || {}; + if (Object.keys(dataset_info).length === 0) { + return; + } + participant_datasets.push({ + participant_id: item.id, + name: dataset_info.name, + uuid: dataset_info.uuid, + }); + }); + const resource = formInstance.getFieldValue('resource_config') as ResourceConfigValue; + + if (isEdit) { + // edit + const payload = { + comment: formInstance.getFieldValue('comment'), + algorithm_uuid: isReceiver + ? undefined + : (formInstance.getFieldValue('algorithm_info') as AlgorithmSelectValue).algorithmUuid, + resource: { + cpu: parseInt(resource.worker_cpu?.replace('m', '') || ''), + memory: parseInt(resource.worker_mem?.replace('Gi', '') || ''), + replicas: parseInt(resource.worker_replicas || ''), + }, + auth_status: isReceiver ? AuthStatus.AUTHORIZED : undefined, + } as TrustedJobGroupPayload; + + const [res, error] = await to(updateTrustedJobGroup(projectId, params.id, payload)); + if (error) { + Message.error(error.message); + return; + } + if (res.data) { + if (isPeerUnauthorized) { + Message.success('授权成功'); + } else { + Message.success('编辑成功'); + } + // launch trusted computing group + if (isLaunch) { + launchTrustedJobGroup(projectId, params.id, { comment: '' }).catch((error) => { + Message.error(error.message); + }); + } + history.push('/trusted-center/list'); + return; + } + } else { + // create + const payload = { + name: formInstance.getFieldValue('name'), + comment: formInstance.getFieldValue('comment'), + algorithm_uuid: (formInstance.getFieldValue('algorithm_info') as AlgorithmSelectValue) + .algorithmUuid, + dataset_id: self_dataset_info ? self_dataset_info.id : undefined, + participant_datasets: participant_datasets, + resource: { + cpu: parseInt(resource.worker_cpu?.replace('m', '') || ''), + memory: parseInt(resource.worker_mem?.replace('Gi', '') || ''), + replicas: parseInt(resource.worker_replicas || ''), + }, + } as TrustedJobGroupPayload; + + const [res, error] = await to(createTrustedJobGroup(projectId, payload)); + if (error) { + Message.error(error.message); + return; + } + if (res.data) { + Message.success(t('trusted_center.msg_create_success')); + history.push('/trusted-center/list'); + return; + } + } + } + + function goBackToListPage() { + history.push(routeMaps.TrustedJobGroupList); + } + + function onFormChange(_: Partial<TrustedJobGroupPayload>, values: TrustedJobGroupPayload) { + setFormData(values); + } +}; + +export default TrustedJobGroupForm; diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupList/index.less b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupList/index.less new file mode 100644 index 000000000..3f1720ead --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupList/index.less @@ -0,0 +1,5 @@ +.group-list-container { + display: flex; + flex: 1; + width: 100%; +} diff --git a/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupList/index.tsx b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupList/index.tsx new file mode 100644 index 000000000..0f83e8c95 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/TrustedJobGroupList/index.tsx @@ -0,0 +1,430 @@ +import SharedPageLayout from 'components/SharedPageLayout'; +import React, { FC, useMemo, useState } from 'react'; +import { generatePath, useHistory } from 'react-router'; +import { Link } from 'react-router-dom'; +import { useQuery } from 'react-query'; +import { useGetCurrentProjectId, useGetCurrentProjectParticipantList, useUrlState } from 'hooks'; +import './index.less'; + +import { + Button, + Input, + Popconfirm, + Table, + Progress, + Message, + Tooltip, +} from '@arco-design/web-react'; +import { IconPlus } from '@arco-design/web-react/icon'; +import { ColumnProps } from '@arco-design/web-react/es/Table'; + +import i18n from 'i18n'; +import { useTranslation } from 'react-i18next'; + +import GridRow from 'components/_base/GridRow'; +import MoreActions, { ActionItem } from 'components/MoreActions'; +import Modal from 'components/Modal'; +import TodoPopover from 'components/TodoPopover'; +import NoResult from 'components/NoResult'; +import WhichParticipant from 'components/WhichParticipant'; +import routeMaps from '../routes'; + +import { + deleteTrustedJobGroup, + fetchTrustedJobGroupList, + launchTrustedJobGroup, + updateTrustedJobGroup, +} from 'services/trustedCenter'; +import { FilterOp } from 'typings/filter'; +import { + AuthStatus, + TrustedJobGroupItem, + TrustedJobGroupStatus, + TicketAuthStatus, +} from 'typings/trustedCenter'; +import { formatTimestamp } from 'shared/date'; +import { getTicketAuthStatus, getLatestJobStatus } from 'shared/trustedCenter'; +import { to } from 'shared/helpers'; +import { constructExpressionTree, expression2Filter } from 'shared/filter'; +import StateIndicator from 'components/StateIndicator'; + +export const LIST_QUERY_KEY = 'task_list_query'; + +type QueryParams = { + name?: string; +}; + +const TrustedJobList: FC = () => { + const { t } = useTranslation(); + const history = useHistory(); + const projectId = useGetCurrentProjectId(); + const participantList = useGetCurrentProjectParticipantList(); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + filter: initFilter(), + }); + const [commentVisible, setCommentVisible] = useState(false); + const [comment, setComment] = useState(''); + const [selectedTrustedJobGroup, setSelectedTrustedJobGroup] = useState<TrustedJobGroupItem>(); + const listQueryKey = [LIST_QUERY_KEY, projectId, urlState]; + const initFilterParams = expression2Filter(urlState.filter); + const [filterParams, setFilterParams] = useState<QueryParams>({ + name: initFilterParams.name || '', + }); + + const listQuery = useQuery( + [listQueryKey, urlState], + () => { + return fetchTrustedJobGroupList(projectId!, urlState); + }, + { + retry: 2, + refetchOnWindowFocus: false, + }, + ); + + const trustedJobListShow = useMemo(() => { + if (!listQuery.data?.data) { + return []; + } + const trustedJobList = (listQuery.data.data || []).filter( + (item) => item.is_configured === true, + ); + return trustedJobList; + }, [listQuery.data]); + + const isEmpty = trustedJobListShow.length === 0; + + const columns = useMemo<ColumnProps<TrustedJobGroupItem>[]>( + () => [ + { + title: '名称', + dataIndex: 'name', + name: 'name', + ellipsis: true, + render: (name: string, record: TrustedJobGroupItem) => { + return <Link to={gotoTrustedJobGroupDetail(record)}>{name}</Link>; + }, + }, + { + title: '发起方', + dataIndex: 'creator_name', + name: 'creator_name', + render: (_: any, record: TrustedJobGroupItem) => { + return record.is_creator ? '本方' : <WhichParticipant id={record.creator_id} />; + }, + }, + { + title: '授权状态', + dataIndex: 'ticket_auth_status', + name: 'ticket_auth_status', + render: (_: any, record: TrustedJobGroupItem) => { + const data = getTicketAuthStatus(record); + return ( + <> + <Tooltip + position="tl" + content={ + record.ticket_auth_status === TicketAuthStatus.AUTH_PENDING + ? renderUnauthParticipantList(record) + : undefined + } + > + <div>{data.text}</div> + </Tooltip> + <Progress + percent={data.percent} + showText={false} + style={{ width: 100 }} + status={data.type} + /> + </> + ); + }, + }, + { + title: '任务状态', + dataIndex: 'status', + name: 'status', + render: (_: any, record: TrustedJobGroupItem) => { + return ( + <div className="indicator-with-tip"> + <StateIndicator {...getLatestJobStatus(record)} /> + </div> + ); + }, + }, + { + title: '创建时间', + dataIndex: 'created_at', + name: 'created_at', + sorter(a: TrustedJobGroupItem, b: TrustedJobGroupItem) { + return a.created_at - b.created_at; + }, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + { + title: '操作', + dataIndex: 'operation', + name: 'operation', + render: (_: any, record: TrustedJobGroupItem) => { + const actionList = [ + { + label: '编辑', + disabled: record.status !== TrustedJobGroupStatus.SUCCEEDED, + onClick: () => { + const editPath = generatePath(routeMaps.TrustedJobGroupEdit, { + id: record.id, + role: record.is_creator ? 'sender' : 'receiver', + }); + history.push(editPath); + }, + }, + { + label: '删除', + disabled: !record.is_creator, + onClick: () => { + Modal.delete({ + title: `确认删除${record.name || ''}吗?`, + content: '删除后,该可信计算将无法进行操作,请谨慎删除', + onOk() { + deleteTrustedJobGroup(projectId!, record.id) + .then(() => { + Message.success(t('trusted_center.msg_delete_success')); + listQuery.refetch(); + }) + .catch((error) => { + Message.error(error.message); + }); + }, + }); + }, + danger: true, + }, + ].filter(Boolean) as ActionItem[]; + + return ( + <GridRow left="-20"> + <Button + type="text" + onClick={() => { + setCommentVisible(true); + setSelectedTrustedJobGroup(record); + }} + disabled={ + record.status !== TrustedJobGroupStatus.SUCCEEDED || + record.auth_status !== AuthStatus.AUTHORIZED || + record.unauth_participant_ids?.length !== 0 + } + > + {'发起任务'} + </Button> + {record.auth_status === AuthStatus.AUTHORIZED ? ( + <Popconfirm + title={i18n.t('trusted_center.unauthorized_confirm_title', { + name: record.name, + })} + okText={i18n.t('submit')} + cancelText={i18n.t('cancel')} + onConfirm={() => onUnauthorizedConfirm(record)} + > + <Button type="text" disabled={record.status !== TrustedJobGroupStatus.SUCCEEDED}> + {'撤销'} + </Button> + </Popconfirm> + ) : ( + <Button + type="text" + disabled={record.status !== TrustedJobGroupStatus.SUCCEEDED} + onClick={() => { + updateTrustedJobGroup(projectId!, record.id, { + auth_status: AuthStatus.AUTHORIZED, + }).then(() => { + listQuery.refetch(); + }); + }} + > + {'授权'} + </Button> + )} + <MoreActions actionList={actionList} /> + </GridRow> + ); + }, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [listQuery], + ); + + return ( + <SharedPageLayout + title={i18n.t('menu.label_trusted_center')} + rightTitle={<TodoPopover.TrustedCenter />} + > + <GridRow justify="space-between" align="center"> + <Button + className={'custom-operation-button'} + type="primary" + icon={<IconPlus />} + onClick={onCreateClick} + > + {t('trusted_center.btn_create_trusted_computing')} + </Button> + <Input.Search + allowClear + defaultValue={filterParams.name} + onSearch={onSearch} + onClear={() => onSearch('')} + placeholder={t('trusted_center.placeholder_search_task')} + /> + </GridRow> + <div className="group-list-container"> + {isEmpty ? ( + <NoResult text="暂无工作可信计算任务" /> + ) : ( + <Table + rowKey="id" + className="custom-table custom-table-left-side-filter" + loading={listQuery.isFetching} + data={trustedJobListShow} + scroll={{ x: '100%' }} + columns={columns} + pagination={{ + showTotal: true, + hideOnSinglePage: true, + pageSizeChangeResetCurrent: true, + total: listQuery.data?.page_meta?.total_items ?? undefined, + current: Number(urlState.page), + pageSize: Number(urlState.pageSize), + onChange: onPageChange, + }} + /> + )} + </div> + <Modal + title={t('trusted_center.title_initiate_trusted_job', { + name: selectedTrustedJobGroup?.name, + })} + id={selectedTrustedJobGroup?.id} + visible={commentVisible} + onOk={() => onCommentModalConfirm()} + onCancel={() => { + setCommentVisible(false); + setComment(''); + }} + autoFocus={false} + focusLock={true} + > + <div className="modal-label">{t('trusted_center.label_trusted_job_comment')}</div> + <Input.TextArea + placeholder={t('trusted_center.placeholder_trusted_job_set_comment')} + autoSize={{ minRows: 3 }} + value={comment} + onChange={setComment} + /> + </Modal> + </SharedPageLayout> + ); + + function renderUnauthParticipantList(record: any) { + return ( + <div> + {participantList.map((item) => { + return ( + <div> + {`${item.name} ${ + record.unauth_participant_ids.includes(item.id) ? '未授权' : '已授权' + }`} + </div> + ); + })} + </div> + ); + } + + function onUnauthorizedConfirm(record: any) { + updateTrustedJobGroup(projectId!, record.id, { + auth_status: AuthStatus.PENDING, + }).then(() => { + listQuery.refetch(); + }); + } + + function gotoTrustedJobGroupDetail(record: any) { + return generatePath(routeMaps.TrustedJobGroupDetail, { + id: record.id, + tabType: 'computing', + }); + } + + function onCreateClick() { + const createPath = generatePath(routeMaps.TrustedJobGroupCreate, { + role: 'sender', + }); + history.push(createPath); + } + + function onSearch(value: any) { + constructFilterArray({ name: value }); + } + + function constructFilterArray(value: QueryParams) { + const expressionNodes = []; + if (value.name) { + expressionNodes.push({ + field: 'name', + op: FilterOp.CONTAIN, + string_value: value.name, + }); + } + const serialization = constructExpressionTree(expressionNodes); + setFilterParams({ + name: value.name, + }); + setUrlState((prevState) => ({ + ...prevState, + filter: serialization, + page: 1, + })); + } + + function onPageChange(page: number, pageSize: number | undefined) { + setUrlState((prevState) => ({ + ...prevState, + page, + pageSize, + })); + } + + async function onCommentModalConfirm() { + const [res, error] = await to( + launchTrustedJobGroup(projectId!, selectedTrustedJobGroup!.id, { + comment: comment, + }), + ); + setCommentVisible(false); + if (error) { + Message.error(error.message); + return; + } + if (res.data) { + Message.success(t('trusted_center.msg_publish_success')); + listQuery.refetch(); + return; + } + } + + function initFilter() { + const expressionNodes = []; + expressionNodes.push({ + field: 'name', + op: FilterOp.CONTAIN, + string_value: '', + }); + return constructExpressionTree(expressionNodes); + } +}; + +export default TrustedJobList; diff --git a/web_console_v2/client/src/views/TrustedCenter/index.tsx b/web_console_v2/client/src/views/TrustedCenter/index.tsx new file mode 100644 index 000000000..0e2444482 --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/index.tsx @@ -0,0 +1,43 @@ +import React, { FC } from 'react'; +import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; +import ErrorBoundary from 'components/ErrorBoundary'; +import TrustedJobList from './TrustedJobGroupList'; +import CreateTrustedJobGroup from './CreateTrustedJobGroup'; +import EditTrustedJobGroup from './EditTrustedJobGroup'; +import TrustedJobGroupDetail from './TrustedJobGroupDetail'; +import DatasetExportApplication from './DatasetExportApplication'; +import ApplicationResult from './DatasetExportApplication/ApplicationResult'; + +const TrustedCenter: FC = () => { + const location = useLocation(); + return ( + <ErrorBoundary> + <Switch> + <Route path="/trusted-center/list" exact component={TrustedJobList} /> + <Route + path="/trusted-center/create/:role(receiver|sender)" + component={CreateTrustedJobGroup} + /> + <Route + path="/trusted-center/edit/:id/:role(receiver|sender)" + component={EditTrustedJobGroup} + /> + <Route + path="/trusted-center/detail/:id/:tabType(computing|export)" + component={TrustedJobGroupDetail} + /> + <Route + path="/trusted-center/dataset-application/:result(passed|rejected)" + component={ApplicationResult} + /> + <Route + path="/trusted-center/dataset-application/:id/:coordinator_id/:name" + component={DatasetExportApplication} + /> + {location.pathname === '/trusted-center' && <Redirect to="/trusted-center/list" />} + </Switch> + </ErrorBoundary> + ); +}; + +export default TrustedCenter; diff --git a/web_console_v2/client/src/views/TrustedCenter/routes.tsx b/web_console_v2/client/src/views/TrustedCenter/routes.tsx new file mode 100644 index 000000000..9af3d351b --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/routes.tsx @@ -0,0 +1,10 @@ +const INDEX_PATH = '/trusted-center'; + +const routes: Record<string, string> = { + TrustedJobGroupList: `${INDEX_PATH}/list`, + TrustedJobGroupDetail: `${INDEX_PATH}/detail/:id/:tabType(computing|export)`, + TrustedJobGroupCreate: `${INDEX_PATH}/create/:role(receiver|sender)`, + TrustedJobGroupEdit: `${INDEX_PATH}/edit/:id/:role(receiver|sender)`, +}; + +export default routes; diff --git a/web_console_v2/client/src/views/TrustedCenter/shared.test.ts b/web_console_v2/client/src/views/TrustedCenter/shared.test.ts new file mode 100644 index 000000000..2e8d1958c --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/shared.test.ts @@ -0,0 +1,27 @@ +import { getResourceConfigInitialValues } from './shared'; +import { ResourceTemplateType, TrustedJobResource } from 'typings/trustedCenter'; + +describe('getResourceConfigInitialValues', () => { + it('normal', () => { + const resource: TrustedJobResource = { + cpu: 16, + memory: 32, + replicas: 100, + }; + expect(getResourceConfigInitialValues(resource)).toEqual({ + master_cpu: '0m', + master_mem: '0Gi', + master_replicas: '1', + master_roles: 'master', + ps_cpu: '0m', + ps_mem: '0Gi', + ps_replicas: '1', + ps_roles: 'ps', + resource_type: ResourceTemplateType.CUSTOM, + worker_cpu: '16m', + worker_mem: '32Gi', + worker_replicas: '100', + worker_roles: 'worker', + }); + }); +}); diff --git a/web_console_v2/client/src/views/TrustedCenter/shared.ts b/web_console_v2/client/src/views/TrustedCenter/shared.ts new file mode 100644 index 000000000..307a6c5af --- /dev/null +++ b/web_console_v2/client/src/views/TrustedCenter/shared.ts @@ -0,0 +1,74 @@ +import styled from 'styled-components'; +import { MixinSquare } from 'styles/mixins'; +import atomIcon from 'assets/icons/atom-icon-algorithm-management.svg'; +import { + AuthStatus, + ResourceTemplateType, + TicketAuthStatus, + TicketStatus, + TrustedJobGroup, + TrustedJobResource, +} from 'typings/trustedCenter'; + +export const Avatar = styled.div<{ bgSrc?: string }>` + ${MixinSquare(48)}; + background-color: var(--primary-1); + color: white; + border-radius: 4px; + font-size: 18px; + text-align: center; + + &::before { + display: inline-block; + width: 100%; + height: 100%; + content: ''; + background: url(${(props) => props.bgSrc || atomIcon}) no-repeat; + background-size: contain; + } +`; + +export const AuthStatusMap: Record<AuthStatus, string> = { + [AuthStatus.AUTHORIZED]: '已授权', + [AuthStatus.PENDING]: '待授权', + [AuthStatus.WITHDRAW]: '拒绝授权', +}; + +export function getResourceConfigInitialValues(resource: TrustedJobResource) { + return { + master_cpu: '0m', + master_mem: '0Gi', + master_replicas: '1', + master_roles: 'master', + ps_cpu: '0m', + ps_mem: '0Gi', + ps_replicas: '1', + ps_roles: 'ps', + resource_type: ResourceTemplateType.CUSTOM, + worker_cpu: resource.cpu + 'm', + worker_mem: resource.memory + 'Gi', + worker_replicas: String(resource.replicas), + worker_roles: 'worker', + }; +} + +export const defaultTrustedJobGroup: TrustedJobGroup = { + id: 0, + name: '', + uuid: 0, + latest_version: '1', + comment: '', + project_id: 0, + ticket_uuid: 0, + ticket_status: TicketStatus.PENDING, + ticket_auth_status: TicketAuthStatus.CREATE_PENDING, + creator_name: '', + participant_datasets: { + items: [], + }, + resource: { + cpu: 4, + memory: 8, + replicas: 1, + }, +}; diff --git a/web_console_v2/client/src/views/Users/UserForm/index.module.less b/web_console_v2/client/src/views/Users/UserForm/index.module.less new file mode 100644 index 000000000..7c0da6189 --- /dev/null +++ b/web_console_v2/client/src/views/Users/UserForm/index.module.less @@ -0,0 +1,6 @@ +.styled_form { + width: 600px; + margin: 0 auto; + background-color: white; + padding-top: 80px; +} diff --git a/web_console_v2/client/src/views/Users/UserList/index.module.less b/web_console_v2/client/src/views/Users/UserList/index.module.less new file mode 100644 index 000000000..6d3ccfd3a --- /dev/null +++ b/web_console_v2/client/src/views/Users/UserList/index.module.less @@ -0,0 +1,4 @@ +.list_container { + display: flex; + flex: 1; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/DefaultMode/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/DefaultMode/index.module.less new file mode 100644 index 000000000..741fc00e1 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/DefaultMode/index.module.less @@ -0,0 +1,41 @@ +.form_section { + margin-bottom: 20px; + padding-top: 24px; + &:not([data-fill-width]) { + padding-right: 60px; + } + > .section_heading { + background-color: white; + padding: 10px 0; + margin-bottom: 6px; + font-size: 14px; + color: var(--textColorStrong); + } +} + +.perspective_tab { + --error-icon-display: none; + + width: 150px; + text-align: center; + + > img[alt='error-icon'] { + display: var(--error-icon-display); + } + + &[data-has-error='true'] { + --error-icon-display: inline-block; + color: var(--errorColor); + } +} + +.has_error_icon { + width: 12px; + margin-right: 4px; +} + +.perspective_container { + &[data-hidden='true'] { + display: none; + } +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/DefaultMode/index.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/DefaultMode/index.tsx new file mode 100644 index 000000000..d74a8aae7 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/DefaultMode/index.tsx @@ -0,0 +1,218 @@ +import { Input, Grid, Switch, Tabs, Form } from '@arco-design/web-react'; +import Modal from 'components/Modal'; +import errorIcon from 'assets/icons/workflow-error.svg'; +import BlockRadio from 'components/_base/BlockRadio'; +import { useSubscribe } from 'hooks'; +import { omit } from 'lodash-es'; +import PubSub from 'pubsub-js'; +import React, { FC, useContext, useEffect, useState } from 'react'; +import { nextTick } from 'shared/helpers'; +import { isValidJobName } from 'shared/validator'; +import styled from './index.module.less'; +import { ValidateErrorEntity } from 'typings/component'; +import { JobType } from 'typings/job'; +import { definitionsStore } from 'views/WorkflowTemplates/TemplateForm/stores'; +import { + ComposeDrawerContext, + COMPOSE_DRAWER_CHANNELS, + HighlightPayload, + InspectPayload, +} from '..'; +import SlotEntryList from '../SloEntrytList'; +import VariableList from '../VariableList'; + +const Row = Grid.Row; + +type Props = { + isGlobal: boolean; + isCheck?: boolean; + onJobTypeChange: (type: JobType) => void; +}; + +const jobTypeOptions = Object.values(omit(JobType, 'UNSPECIFIED')).map((item) => ({ + value: item, + label: item, +})); + +export enum Perspective { + Slots = 'slots', + Variables = 'variables', +} + +const DefaultMode: FC<Props> = ({ isGlobal, isCheck, onJobTypeChange }) => { + const [perspective, setPerspective] = useState<Perspective>( + isGlobal ? Perspective.Variables : Perspective.Slots, + ); + const [errorTabs, setErrorTabs] = useState({ + slots: false, + variables: false, + }); + + const context = useContext(ComposeDrawerContext); + + const jobNameRules = [ + { required: true, message: '请输入 Job 名' }, + { + validator(value: any, callback: (error?: string) => void) { + if (!isValidJobName(value)) { + callback('只支持小写字母,数字开头或结尾,可包含“-”,不超过 24 个字符'); + } + }, + }, + { + validator(value: any, callback: (error?: string) => void) { + if ( + definitionsStore.entries + .filter(([uuid]) => uuid !== context.uuid) + .some(([_, jobDef]) => jobDef.name && jobDef.name === value.trim()) + ) { + callback('检测到任务重名'); + } + }, + }, + ]; + // ============ Subscribers ================ + useSubscribe(COMPOSE_DRAWER_CHANNELS.broadcast_error, (_: any, errInfo: ValidateErrorEntity) => { + const errors = { + slots: false, + variables: false, + }; + errors.slots = errInfo.errorFields.some((field) => /_slotEntries/.test(field.name[0])); + errors.variables = errInfo.errorFields.some((field) => /variables/.test(field.name[0])); + setErrorTabs(errors); + }); + useSubscribe(COMPOSE_DRAWER_CHANNELS.validation_passed, () => + setErrorTabs({ slots: false, variables: false }), + ); + useSubscribe( + COMPOSE_DRAWER_CHANNELS.inspect, + (_: string, { perspective, slotName, varUuid }: InspectPayload) => { + if (perspective) { + setPerspective(perspective); + } + + if (slotName || varUuid) { + if (slotName) { + setPerspective(Perspective.Slots); + } else { + setPerspective(Perspective.Variables); + } + nextTick(() => { + PubSub.publish(COMPOSE_DRAWER_CHANNELS.highlight, { + slotName, + varUuid, + } as HighlightPayload); + }); + } + }, + ); + // ============ Subscribers ================ + + useEffect(() => { + setErrorTabs({ slots: false, variables: false }); + }, [context.uuid]); + + useEffect(() => { + if (isGlobal) { + setPerspective(Perspective.Variables); + } + }, [isGlobal]); + + return ( + <> + {!isGlobal && ( + <section className={styled.form_section} style={{ width: 620 }}> + <h4 className={styled.section_heading}>基础配置</h4> + + <Form.Item field="name" label="Job 名称" rules={jobNameRules}> + <Input disabled={isCheck} placeholder="请输入 Job 名" /> + </Form.Item> + + <Form.Item field="is_federated" label="是否联邦" triggerPropName="checked"> + <Switch disabled={isCheck} /> + </Form.Item> + + <h4 className={styled.section_heading}>任务类型</h4> + <Form.Item + labelCol={{ span: 0 }} + field="job_type" + label="任务类型" + rules={[{ required: true, message: '请输入 Job 名' }]} + > + <BlockRadio + disabled={isCheck} + gap={10} + flexGrow={0} + options={jobTypeOptions} + beforeChange={beforeTypeChange} + onChange={onJobTypeChange} + /> + </Form.Item> + </section> + )} + + <section className={styled.form_section} data-fill-width> + {!isGlobal && ( + <> + <Row className={styled.section_heading} justify="space-between"> + <Tabs activeTab={perspective} onChange={onPerspectiveChange}> + <Tabs.TabPane + key={Perspective.Slots} + title={ + <div className={styled.perspective_tab} data-has-error={errorTabs.slots}> + <img className={styled.has_error_icon} src={errorIcon} alt="error-icon" /> + 插槽赋值 + </div> + } + /> + + <Tabs.TabPane + key={Perspective.Variables} + title={ + <div className={styled.perspective_tab} data-has-error={errorTabs.variables}> + <img className={styled.has_error_icon} src={errorIcon} alt="error-icon" /> + 自定义变量 + </div> + } + /> + </Tabs> + </Row> + </> + )} + <div + className={styled.perspective_container} + data-hidden={perspective !== Perspective.Variables} + > + <VariableList isCheck={isCheck} /> + </div> + + <div + className={styled.perspective_container} + data-hidden={perspective !== Perspective.Slots} + > + <SlotEntryList isCheck={isCheck} /> + </div> + </section> + </> + ); + + function onPerspectiveChange(val: string) { + setPerspective(val as Perspective); + } + function beforeTypeChange(): Promise<boolean> { + return new Promise((resolve) => { + Modal.confirm({ + title: '确认变更任务类型', + content: '更改任务类型将会使当前配置的插槽值将会丢失,确认这样做吗?', + onOk() { + resolve(true); + }, + onCancel() { + resolve(false); + }, + }); + }); + } +}; + +export default DefaultMode; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/ExpertMode/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/ExpertMode/index.module.less new file mode 100644 index 000000000..ffe3e220c --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/ExpertMode/index.module.less @@ -0,0 +1,14 @@ +.form_section { + margin-bottom: 20px; + padding-top: 24px; + &:not([data-fill-width]) { + padding-right: 60px; + } + > .section_heading { + background-color: white; + padding: 10px 0; + margin-bottom: 6px; + font-size: 14px; + color: var(--textColorStrong); + } +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/ExpertMode/index.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/ExpertMode/index.tsx new file mode 100644 index 000000000..e8a2175a2 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/ExpertMode/index.tsx @@ -0,0 +1,76 @@ +import { Input, Select, Switch, Form } from '@arco-design/web-react'; +import YAMLTemplateEditorButton from 'components/YAMLTemplateEditorButton'; +import { omit } from 'lodash-es'; +import React, { FC } from 'react'; +import { isValidJobName } from 'shared/validator'; +import { JobType } from 'typings/job'; +import VariableList from '../VariableList'; +import styled from './index.module.less'; + +type Props = { + isGlobal: boolean; + isCheck?: boolean; +}; + +const jobTypeOptions = Object.values(omit(JobType, 'UNSPECIFIED')); + +const ExpertMode: FC<Props> = ({ isGlobal, isCheck }) => { + return ( + <> + {!isGlobal && ( + <section className={styled.form_section} style={{ width: 620 }}> + <h4 className={styled.section_heading}>基本信息</h4> + <Form.Item + field="job_type" + label="任务类型" + rules={[{ required: true, message: '请输入 Job 名' }]} + > + <Select disabled={isCheck} placeholder="请选择任务类型"> + {jobTypeOptions.map((type) => ( + <Select.Option key={type} value={type}> + {type} + </Select.Option> + ))} + </Select> + </Form.Item> + + <Form.Item + field="name" + label="Job 名称" + rules={[ + { required: true, message: '请输入 Job 名' }, + { + validator(value: any, callback: (error?: string) => void) { + if (!isValidJobName(value)) { + callback('只支持小写字母,数字开头或结尾,可包含“-”,不超过 24 个字符'); + } + }, + }, + ]} + > + <Input disabled={isCheck} placeholder="请输入 Job 名" /> + </Form.Item> + + <Form.Item field="is_federated" label="是否联邦" triggerPropName="checked"> + <Switch disabled={isCheck} /> + </Form.Item> + + <Form.Item + field="yaml_template" + label="YAML 模板" + rules={[{ required: true, message: '请加入 YAML 模板' }]} + > + <YAMLTemplateEditorButton isCheck={isCheck} /> + </Form.Item> + </section> + )} + + <section className={styled.form_section} data-fill-width> + {!isGlobal && <h4 className={styled.section_heading}>自定义变量</h4>} + <VariableList isCheck={isCheck} /> + </section> + </> + ); +}; + +export default ExpertMode; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/JobProperty.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/JobProperty.tsx new file mode 100644 index 000000000..df51c02f8 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/JobProperty.tsx @@ -0,0 +1,93 @@ +import React, { FC, useContext, useEffect, useState } from 'react'; +import { OptionLabel } from './elements'; +import { Select, Grid } from '@arco-design/web-react'; +import { + definitionsStore, + JobDefinitionFormWithoutSlots, + TPL_GLOBAL_NODE_UUID, +} from 'views/WorkflowTemplates/TemplateForm/stores'; +import { ComposeDrawerContext } from '../../index'; +import { RefModelSharedProps } from './types'; +import { composeJobPropRef, parseJobPropRef } from '../helpers'; + +const Row = Grid.Row; +const Col = Grid.Col; + +type JobList = (JobDefinitionFormWithoutSlots & { uuid: string })[]; + +const jobPropOptions = [ + { + value: 'name', + label: 'name - 任务名', + }, +]; + +const JobProperty: FC<RefModelSharedProps> = ({ isCheck, value, onChange }) => { + const { uuid: selfJobUuid } = useContext(ComposeDrawerContext); + + const [jobUuid, prop] = parseJobPropRef(value); + + const [localJobUuid, setLocalJob] = useState(jobUuid); + const [localProp, setLocalProp] = useState(prop); + + const [jobList, setJobList] = useState<JobList>([]); + + useEffect(() => { + setJobList( + definitionsStore.entries + .filter(([uuid]) => uuid !== TPL_GLOBAL_NODE_UUID) + .map(([uuid, jobDef]) => ({ ...jobDef, uuid })), + ); + }, [selfJobUuid]); + + return ( + <Row gutter={10}> + <Col span={12}> + <Select + disabled={isCheck} + value={jobUuid === '__SELF__' ? selfJobUuid : jobUuid} + placeholder={'目标任务'} + onChange={onJobChangeChange} + > + {jobList.map((item, index: number) => { + return ( + <Select.Option key={item.uuid + index} value={item.uuid}> + {item.uuid === selfJobUuid ? ( + <OptionLabel>本任务</OptionLabel> + ) : ( + <OptionLabel data-empty-text="//未命名任务">{item.name}</OptionLabel> + )} + </Select.Option> + ); + })} + </Select> + </Col> + <Col span={12}> + <Select value={prop} disabled={!localJobUuid} placeholder={'属性'} onChange={onPropChange}> + {jobPropOptions.map((item, index: number) => { + return ( + <Select.Option key={item.value + index} value={item.value}> + {item.label} + </Select.Option> + ); + })} + </Select> + </Col> + </Row> + ); + + function onJobChangeChange(val: string) { + setLocalJob(val); + onChange && + onChange(composeJobPropRef({ isSelf: selfJobUuid === val, job: val, prop: localProp })); + } + function onPropChange(val: string) { + setLocalProp(val); + onChange && + onChange( + composeJobPropRef({ isSelf: selfJobUuid === localJobUuid, job: localJobUuid, prop: val }), + ); + } +}; + +export default JobProperty; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/OtherJobVariable.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/OtherJobVariable.tsx new file mode 100644 index 000000000..e466d1e4b --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/OtherJobVariable.tsx @@ -0,0 +1,171 @@ +import React, { FC, useContext, useEffect, useState } from 'react'; +import { NoAvailable, OptionLabel } from './elements'; +import { Select, Grid, Button } from '@arco-design/web-react'; +import { + definitionsStore, + JobDefinitionFormWithoutSlots, + TPL_GLOBAL_NODE_UUID, +} from 'views/WorkflowTemplates/TemplateForm/stores'; +import { ComposeDrawerContext, COMPOSE_DRAWER_CHANNELS, InspectPayload } from '../..'; +import { RefModelSharedProps } from './types'; +import { composeOtherJobRef, parseOtherJobRef } from '../helpers'; +import VariableLinkAnchor from './VariableLinkAnchor'; +import PubSub from 'pubsub-js'; +import { Perspective } from '../../DefaultMode'; +import { algorithmTypeOptionList } from './shared'; +import { Variable, VariableComponent } from 'typings/variable'; + +const Row = Grid.Row; +const Col = Grid.Col; + +type JobList = (JobDefinitionFormWithoutSlots & { uuid: string })[]; + +const OtherJobVariable: FC<RefModelSharedProps> = ({ isCheck, value, onChange }) => { + const context = useContext(ComposeDrawerContext); + + // workflow.jobs['186d1db127359'].variables.186d1db127359 + const [jobUuid, varUuid, algorithmType] = parseOtherJobRef(value); + + const [localJobUuid, setLocalJob] = useState(jobUuid); + const [localVarUuid, setLocalVar] = useState(varUuid); + const [isShowAlgorithmTypeSelect, setIsShowAlgorithmTypeSelect] = useState( + Boolean(algorithmType), + ); + + const [jobList, setJobList] = useState<JobList>([]); + + useEffect(() => { + const tmp: JobList = []; + definitionsStore.entries.forEach(([uuid, jobDef]) => { + if (uuid !== TPL_GLOBAL_NODE_UUID && uuid !== context.uuid) { + tmp.push({ ...jobDef, uuid }); + } + }); + + setJobList(tmp); + }, [context.uuid]); + + const hasOtherJobs = jobList.length !== 0; + const availableVariables = jobList.find((item) => localJobUuid === item.uuid)?.variables ?? []; + const hasVariables = availableVariables.length !== 0; + + if (!hasOtherJobs) { + return <NoAvailable>暂不存在其他任务</NoAvailable>; + } + + return ( + <Row gutter={5}> + <Col span={isShowAlgorithmTypeSelect ? 8 : 10}> + <Select + disabled={isCheck} + value={jobUuid} + placeholder={'目标任务'} + onChange={onJobChangeChange} + allowClear + > + {jobList.map((item, index: number) => { + return ( + <Select.Option key={item.uuid + index} value={item.uuid}> + <OptionLabel data-empty-text="//未命名任务">{item.name}</OptionLabel> + </Select.Option> + ); + })} + </Select> + </Col> + + {localJobUuid && ( + <Col span={isShowAlgorithmTypeSelect ? 8 : 12}> + {!hasVariables ? ( + <NoAvailable> + 该任务暂无变量, + <Button + disabled={isCheck} + type="text" + size="small" + onClick={onGoTheJobToCreateVarClick} + > + {'点击前往创建'} + </Button> + </NoAvailable> + ) : ( + <Select + value={varUuid} + disabled={!localJobUuid || isCheck} + placeholder={'目标变量'} + onChange={onVarChangeChange} + allowClear + > + {availableVariables.map((item, index: number) => { + return ( + <Select.Option key={item._uuid + index} value={item._uuid} extra={item}> + <OptionLabel data-empty-text="// 未命名变量">{item.name}</OptionLabel> + </Select.Option> + ); + })} + </Select> + )} + </Col> + )} + + {isShowAlgorithmTypeSelect && ( + <Col span={6}> + <Select + disabled={isCheck} + defaultValue={algorithmTypeOptionList[0].value} + onChange={onAlgorithmTypeSelectChange} + > + {algorithmTypeOptionList.map((item) => { + return ( + <Select.Option key={item.value} value={item.value}> + <OptionLabel>{item.label}</OptionLabel> + </Select.Option> + ); + })} + </Select> + </Col> + )} + + {localJobUuid && hasVariables && ( + <VariableLinkAnchor + jobUuid={jobUuid} + varUuid={varUuid} + disabled={!localJobUuid || !localVarUuid || isCheck} + /> + )} + </Row> + ); + + function onJobChangeChange(val: string) { + setLocalJob(val); + setLocalVar(undefined as any); + setIsShowAlgorithmTypeSelect(false); + + onChange?.(composeOtherJobRef(val, localVarUuid)); + } + function onVarChangeChange(val: string, options: any) { + setLocalVar(val); + + if ( + (options?.extra as Variable)?.widget_schema?.component === VariableComponent.AlgorithmSelect + ) { + setIsShowAlgorithmTypeSelect(true); + onChange?.(`${composeOtherJobRef(localJobUuid, val)}.${algorithmTypeOptionList[0].value}`); + } else { + setIsShowAlgorithmTypeSelect(false); + onChange?.(composeOtherJobRef(localJobUuid, val)); + } + } + function onAlgorithmTypeSelectChange(val: string) { + // workflow.jobs['186d1db127359'].variables.186d1db127359.path + onChange?.(`${composeOtherJobRef(localJobUuid, localVarUuid)}.${val}`); + } + function onGoTheJobToCreateVarClick() { + if (!localJobUuid) return; + PubSub.publish(COMPOSE_DRAWER_CHANNELS.inspect, { + jobUuid: localJobUuid, + perspective: Perspective.Variables, + } as InspectPayload); + } +}; + +export default OtherJobVariable; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/ProjectVariable.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/ProjectVariable.tsx new file mode 100644 index 000000000..e44118be7 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/ProjectVariable.tsx @@ -0,0 +1,41 @@ +import React, { FC, useState } from 'react'; +import { Input } from '@arco-design/web-react'; +import { RefModelSharedProps } from './types'; + +const PREFIX = 'project.variables'; + +const PrjectVariable: FC<RefModelSharedProps> = ({ isCheck, value, onChange }) => { + const varName = _parse(value); + const [localVarname, setLocalVar] = useState(varName); + + return ( + <Input + disabled={isCheck} + value={localVarname} + addBefore={`${PREFIX}.`} + onChange={onInputChange} + placeholder={'输入工作区变量名'} + /> + ); + + function onInputChange(value: string, e: any) { + setLocalVar(value); + onChange && onChange(_compose(value)); + } +}; + +function _compose(val: string) { + if (!val) return ''; + return `${PREFIX}.${val}`; +} +function _parse(reference: string | undefined): string { + if (!reference) return ''; + const fragments = reference.split('.'); + if (fragments.length !== 3) { + return ''; + } + const [, , varName] = fragments; + return varName; +} + +export default PrjectVariable; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/SelfVariable.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/SelfVariable.tsx new file mode 100644 index 000000000..9228ac8b6 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/SelfVariable.tsx @@ -0,0 +1,110 @@ +import React, { FC, useContext, useState } from 'react'; +import { COMPOSE_DRAWER_CHANNELS, ComposeDrawerContext, InspectPayload } from '../..'; +import { RefModelSharedProps } from './types'; +import { Button, Select, Grid } from '@arco-design/web-react'; +import { NoAvailable, OptionLabel } from './elements'; +import { composSelfRef, parseSelfRef } from '../helpers'; +import VariableLinkAnchor from './VariableLinkAnchor'; +import PuBSub from 'pubsub-js'; +import { Perspective } from '../../DefaultMode'; +import { Variable, VariableComponent } from 'typings/variable'; +import { algorithmTypeOptionList } from './shared'; + +const Row = Grid.Row; +const Col = Grid.Col; + +const SelfVariable: FC<RefModelSharedProps> = ({ isCheck, value, onChange }) => { + const { formData } = useContext(ComposeDrawerContext); + const [algorithmType, setAlgorithmType] = useState('path'); + const [isShowAlgorithmTypeSelect, setIsShowAlgorithmTypeSelect] = useState(() => { + // e.g. self.variables.${uuid} + // If compoment type of variable is VariableComponent.AlgorithmSelect, self.variables.${uuid}.path or self.variables.${uuid}.path + const list = value?.split('.') ?? []; + if (list.length >= 4) { + setAlgorithmType(list[list.length - 1]); + } + return list.length >= 4; + }); + + if (!formData) { + return null; + } + + const selectedVarUuid = parseSelfRef(value); + const noVariableAvailable = formData.variables.length === 0; + return ( + <Row gutter={5}> + <Col span={isShowAlgorithmTypeSelect ? 10 : 20}> + {noVariableAvailable ? ( + <NoAvailable> + 本任务暂无有效变量, + <Button type="text" size="small" onClick={onGoVarTabClick}> + {'点击前往创建'} + </Button> + </NoAvailable> + ) : ( + <Select + disabled={isCheck} + value={selectedVarUuid} + onChange={onSelectChange} + placeholder={'请选择需要引用的变量'} + allowClear + > + {formData.variables.map((variable, index: number) => { + return ( + <Select.Option key={variable._uuid + index} value={variable._uuid} extra={variable}> + <OptionLabel data-empty-text="// 未命名变量">{variable.name}</OptionLabel> + </Select.Option> + ); + })} + </Select> + )} + </Col> + + {isShowAlgorithmTypeSelect && ( + <Col span={10}> + <Select + disabled={isCheck} + defaultValue={algorithmType} + onChange={onAlgorithmTypeSelectChange} + > + {algorithmTypeOptionList.map((item) => { + return ( + <Select.Option key={item.value} value={item.value}> + <OptionLabel>{item.label}</OptionLabel> + </Select.Option> + ); + })} + </Select> + </Col> + )} + + {!noVariableAvailable && ( + <VariableLinkAnchor varUuid={selectedVarUuid} disabled={!selectedVarUuid} /> + )} + </Row> + ); + + function onSelectChange(val: string, options: any) { + if ( + (options?.extra as Variable)?.widget_schema?.component === VariableComponent.AlgorithmSelect + ) { + setIsShowAlgorithmTypeSelect(true); + onChange?.(`${composSelfRef(val)}.${algorithmTypeOptionList[0].value}`); + } else { + setIsShowAlgorithmTypeSelect(false); + onChange?.(composSelfRef(val)); + } + } + function onAlgorithmTypeSelectChange(val: string) { + // self.variables.ag.path + onChange?.(`${composSelfRef(selectedVarUuid)}.${val}`); + } + function onGoVarTabClick() { + PuBSub.publish(COMPOSE_DRAWER_CHANNELS.inspect, { + perspective: Perspective.Variables, + } as InspectPayload); + } +}; + +export default SelfVariable; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/SystemVariable.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/SystemVariable.tsx new file mode 100644 index 000000000..1624f8c82 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/SystemVariable.tsx @@ -0,0 +1,41 @@ +import React, { FC, useState } from 'react'; +import { Input } from '@arco-design/web-react'; +import { RefModelSharedProps } from './types'; + +const PREFIX = 'system.variables'; + +const SystemVariable: FC<RefModelSharedProps> = ({ isCheck, value, onChange }) => { + const varName = _parse(value); + const [localVarname, setLocalVar] = useState(varName); + + return ( + <Input + disabled={isCheck} + value={localVarname} + addBefore={`${PREFIX}.`} + onChange={onInputChange} + placeholder={'输入系统变量名'} + /> + ); + + function onInputChange(value: string, e: any) { + setLocalVar(value); + onChange && onChange(_compose(value)); + } +}; + +function _compose(val: string) { + if (!val) return ''; + return `${PREFIX}.${val}`; +} +function _parse(reference: string | undefined): string { + if (!reference) return ''; + const fragments = reference.split('.'); + if (fragments.length !== 3) { + return ''; + } + const [, , varName] = fragments; + return varName; +} + +export default SystemVariable; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/VariableLinkAnchor.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/VariableLinkAnchor.tsx new file mode 100644 index 000000000..688a10182 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/VariableLinkAnchor.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import { AnchorIcon } from '../../elements'; +import IconButton from 'components/IconButton'; +import { Grid, Tooltip } from '@arco-design/web-react'; +import { COMPOSE_DRAWER_CHANNELS } from '../..'; + +const Col = Grid.Col; + +export type InspetVariableParams = { + jobUuid?: string; + varUuid: string; + disabled?: boolean; +}; + +type Props = InspetVariableParams; + +const VariableLinkAnchor: FC<Props> = ({ jobUuid, varUuid, disabled }) => { + return ( + <Col style={{ flex: '1' }}> + <Tooltip content={'查看变量'}> + <IconButton + disabled={disabled} + style={{ width: '100%', height: '100%' }} + icon={<AnchorIcon style={{ marginLeft: 0 }} />} + onClick={inspectVariable} + /> + </Tooltip> + </Col> + ); + + function inspectVariable() { + PubSub.publish(COMPOSE_DRAWER_CHANNELS.inspect, { jobUuid, varUuid }); + } +}; + +export default VariableLinkAnchor; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/WorkflowVariable.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/WorkflowVariable.tsx new file mode 100644 index 000000000..4d198e3ce --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/WorkflowVariable.tsx @@ -0,0 +1,107 @@ +import React, { FC, useState } from 'react'; +import { RefModelSharedProps } from './types'; +import { NoAvailable, OptionLabel } from './elements'; +import { Button, Select, Grid } from '@arco-design/web-react'; +import { + definitionsStore, + TPL_GLOBAL_NODE_UUID, +} from 'views/WorkflowTemplates/TemplateForm/stores'; +import { composeWorkflowRef, parseWorkflowRef } from '../helpers'; +import VariableLinkAnchor from './VariableLinkAnchor'; +import PubSub from 'pubsub-js'; +import { COMPOSE_DRAWER_CHANNELS } from '../..'; +import { algorithmTypeOptionList } from './shared'; +import { Variable, VariableComponent } from 'typings/variable'; + +const Row = Grid.Row; +const Col = Grid.Col; + +const WorkflowVariable: FC<RefModelSharedProps> = ({ isCheck, value, onChange }) => { + const [isShowAlgorithmTypeSelect, setIsShowAlgorithmTypeSelect] = useState(() => { + // e.g. workflow.variables.${uuid} + // If compoment type of variable is VariableComponent.AlgorithmSelect, workflow.variables.${uuid}.path or workflow.variables.${uuid}.path + const list = value?.split('.') ?? []; + return list.length >= 4; + }); + + const globalNodeDef = definitionsStore.getValueById(TPL_GLOBAL_NODE_UUID); + if (!globalNodeDef || !globalNodeDef?.variables || globalNodeDef?.variables.length === 0) { + return ( + <NoAvailable> + 暂无全局变量, + <Button disabled={isCheck} type="text" size="small" onClick={onGoGlobalNodeClick}> + {'点击前往创建'} + </Button> + </NoAvailable> + ); + } + + const selectVal = parseWorkflowRef(value); + + return ( + <Row gutter={10}> + <Col span={isShowAlgorithmTypeSelect ? 10 : 20}> + <Select + value={selectVal} + onChange={onSelectChange} + placeholder={'请选择需要引用的全局变量'} + allowClear + > + {globalNodeDef.variables.map((variable, index: number) => { + return ( + <Select.Option key={variable.name + index} value={variable._uuid} extra={variable}> + <OptionLabel data-empty-text="//未命名全局变量">{variable.name}</OptionLabel> + </Select.Option> + ); + })} + </Select> + </Col> + + {isShowAlgorithmTypeSelect && ( + <Col span={10}> + <Select + defaultValue={algorithmTypeOptionList[0].value} + onChange={onAlgorithmTypeSelectChange} + > + {algorithmTypeOptionList.map((item) => { + return ( + <Select.Option key={item.value} value={item.value}> + <OptionLabel>{item.label}</OptionLabel> + </Select.Option> + ); + })} + </Select> + </Col> + )} + + <VariableLinkAnchor + varUuid={selectVal} + jobUuid={TPL_GLOBAL_NODE_UUID} + disabled={!selectVal} + /> + </Row> + ); + + function onSelectChange(val: string, options: any) { + if ( + (options?.extra as Variable)?.widget_schema?.component === VariableComponent.AlgorithmSelect + ) { + setIsShowAlgorithmTypeSelect(true); + onChange?.(`${composeWorkflowRef(val)}.${algorithmTypeOptionList[0].value}`); + } else { + setIsShowAlgorithmTypeSelect(false); + onChange?.(composeWorkflowRef(val)); + } + } + function onAlgorithmTypeSelectChange(val: string) { + // self.variables.ag.path + onChange?.(`${composeWorkflowRef(selectVal)}.${val}`); + } + function onGoGlobalNodeClick() { + PubSub.publish(COMPOSE_DRAWER_CHANNELS.inspect, { + jobUuid: TPL_GLOBAL_NODE_UUID, + }); + } +}; + +export default WorkflowVariable; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/elements.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/elements.module.less new file mode 100644 index 000000000..96b0437e6 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/elements.module.less @@ -0,0 +1,17 @@ +.no_available { + padding-left: 5px; + font-size: 13px; + line-height: 32px; + white-space: nowrap; + color: var(--textColorSecondary); +} + +.option_label { + &:empty { + color: var(--textColorSecondary); + &::before { + content: attr(data-empty-text); + font-weight: normal; + } + } +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/elements.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/elements.tsx new file mode 100644 index 000000000..aec2a62de --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/elements.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import styled from './elements.module.less'; + +export function NoAvailable({ children, ...props }: any) { + return ( + <div className={styled.no_available} {...props}> + {children} + </div> + ); +} + +export function OptionLabel({ children, ...props }: any) { + return ( + <span className={styled.option_label} {...props}> + {children} + </span> + ); +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/shared.ts b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/shared.ts new file mode 100644 index 000000000..9ef030cc0 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/shared.ts @@ -0,0 +1,10 @@ +export const algorithmTypeOptionList = [ + { + label: 'path', + value: 'path', + }, + { + label: 'config', + value: 'config', + }, +]; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/types.ts b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/types.ts new file mode 100644 index 000000000..682a44944 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/RefVariableSelect/types.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ + +export type RefModelSharedProps = { + value?: string; + isCheck?: boolean; + onChange?: (refStr: string) => void; +}; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/SlotEntryItem.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/SlotEntryItem.module.less new file mode 100644 index 000000000..9252de15f --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/SlotEntryItem.module.less @@ -0,0 +1,14 @@ +.second_label { + color: var(--textColorSecondary); + font-weight: normal; + font-size: 12px; +} + +.open_indicator { + transition: 0.4s var(--commonTiming); +} +&[data-open='true'] { + .open_indicator { + transform: rotate(180deg); + } +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/SlotEntryItem.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/SlotEntryItem.tsx new file mode 100644 index 000000000..5fd3f0984 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/SlotEntryItem.tsx @@ -0,0 +1,284 @@ +import React, { ChangeEvent, CSSProperties, FC, useRef, memo, useCallback, useState } from 'react'; +import styled from './SlotEntryItem.module.less'; +import { SlotEntry } from 'views/WorkflowTemplates/TemplateForm/stores'; +import { useToggle } from 'react-use'; +import { IconDown, IconQuestionCircle } from '@arco-design/web-react/icon'; + +import { JobSlotReferenceType } from 'typings/workflow'; +import { Summary, Container, Details, Name } from '../elements'; +import { Tag, Select, Input, Tooltip, Form } from '@arco-design/web-react'; +import { useSubscribe } from 'hooks'; + +import SelfVariable from './RefVariableSelect/SelfVariable'; +import WorkflowVariable from './RefVariableSelect/WorkflowVariable'; +import ProjectVariable from './RefVariableSelect/ProjectVariable'; +import SystemVariable from './RefVariableSelect/SystemVariable'; +import OtherJobVariable from './RefVariableSelect/OtherJobVariable'; +import JobProperty from './RefVariableSelect/JobProperty'; +import { COMPOSE_DRAWER_CHANNELS, HighlightPayload, scrollDrawerBodyTo } from '..'; +import { ValidateErrorEntity } from 'typings/component'; +import { isEqual } from 'lodash-es'; +import { formatValueToString, parseValueFromString } from 'shared/helpers'; + +const { DEFAULT, SELF, OTHER_JOB, WORKFLOW, PROJECT, SYSTEM, JOB_PROPERTY } = JobSlotReferenceType; + +type RefMeta = { color: string; refWidget: any; label: string }; + +const SlotRefMetas: Partial<Record<JobSlotReferenceType, RefMeta>> = { + [DEFAULT]: { + color: '', // default + refWidget: null, + label: '模板默认值', + }, + [SELF]: { + color: 'gold', + refWidget: SelfVariable, + label: '本任务变量', + }, + [OTHER_JOB]: { + color: 'cyan', + refWidget: OtherJobVariable, + label: '其他任务变量', + }, + [JOB_PROPERTY]: { + color: 'arcoblue', + refWidget: JobProperty, + label: '任务属性', + }, + [WORKFLOW]: { + color: 'blue', + refWidget: WorkflowVariable, + label: '工作流全局变量', + }, + [PROJECT]: { + color: 'purple', + refWidget: ProjectVariable, + label: '工作区变量', + }, + [SYSTEM]: { + color: 'lime', + refWidget: SystemVariable, + label: '系统变量', + }, +}; + +const refOptions = Object.entries(JobSlotReferenceType); + +type Props = { + isCheck?: boolean; + path: number | string; + value?: SlotEntry; + onChange?: (val: SlotEntry) => any; + style?: CSSProperties; + className?: string; +}; + +const SlotEntryItem: FC<Props> = ({ isCheck, path, value, onChange, ...props }) => { + const [validatePassed, setValidatePassed] = useState<boolean>(true); + const ref = useRef<HTMLDetailsElement>(null); + const [isOpen, toggleOpen] = useToggle(_shouldInitiallyOpen(value)); + const [hasError, toggleError] = useToggle(false); + const [highlighted, setHighlight] = useToggle(false); + const refValTimer = useRef<number>(null); + + const onRefValChange = useCallback((val: string) => { + refValTimer.current && clearTimeout(refValTimer.current); + + (refValTimer.current as any) = (setTimeout(() => { + toggleError(!val); + }, 200) as unknown) as any; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useSubscribe( + COMPOSE_DRAWER_CHANNELS.broadcast_error, + (_: string, errInfo: ValidateErrorEntity) => { + const hasError = errInfo.errorFields.some((field) => { + const [pathL1] = field.name; + const reg = RegExp(/_slotEntries/g); + return reg.test(pathL1) && pathL1 === path; + }); + + toggleError(hasError); + + if (hasError) { + toggleOpen(true); + } + }, + ); + useSubscribe(COMPOSE_DRAWER_CHANNELS.highlight, (_: string, { slotName }: HighlightPayload) => { + if (value && slotName === value[0]) { + setHighlight(true); + toggleOpen(true); + + // Scroll slot into view + const verticalMiddleY = (window.innerHeight - 60) / 2; + const top = ref.current?.offsetTop || verticalMiddleY; + scrollDrawerBodyTo(top - verticalMiddleY); + + setTimeout(() => { + setHighlight && setHighlight(false); + }, 5000); + } + }); + + if (!value) return null; + + const [slotName, slotConfig] = value; + const currRefType = slotConfig.reference_type; + const currRefMeta = SlotRefMetas[currRefType]!; + const isDefaultRefType = currRefType === DEFAULT; + + return ( + <Details ref={ref as any} data-has-error={hasError} data-open={isOpen} {...props}> + <Summary + data-has-error={hasError} + data-highlighted={highlighted} + onClick={(evt: any) => onToggle(evt as any)} + > + {/* + Certain HTML elements, like <summary>, <fieldset> and <button>, do not work as flex containers. + You can work by nesting a div under your summary + https://stackoverflow.com/questions/46156669/safari-flex-item-unwanted-100-width-css/46163405 + */} + <div + style={{ + display: 'flex', + alignItems: 'center', + height: '100%', + }} + > + <Name> + <Tag color={currRefMeta?.color} style={{ marginRight: 5 }} bordered> + {currRefMeta?.label} + </Tag> + {slotConfig.label || slotName} + <small className={styled.second_label}> ({slotName}) </small> + {slotConfig.help && ( + <Tooltip content={slotConfig.help}> + <IconQuestionCircle disabled={isCheck} style={{ marginLeft: 5 }} /> + </Tooltip> + )} + </Name> + <div style={{ marginLeft: 5 }}> + <IconDown className={styled.open_indicator} /> + </div> + </div> + </Summary> + + <Container style={{ display: isOpen ? 'block' : 'none' }}> + <Form.Item + field={getNamePath('reference_type')} + label={'插槽类型'} + rules={[{ required: true }]} + > + <Select disabled={isCheck} onChange={onTypeChange}> + {refOptions.map(([key, value]) => ( + <Select.Option key={key} value={value}> + {`${SlotRefMetas[value]?.label} - ${key}`} + </Select.Option> + ))} + </Select> + </Form.Item> + + {!isDefaultRefType && ( + <Form.Item + field={getNamePath('reference')} + label={'引用路径'} + // dependencies={['variables']} + rules={[ + { required: true }, + { + match: /^[a-zA-Z_0-9]+(?:(.[a-zA-Z_0-9]+)|(\['[^'"\\]+']))+$/g, + message: '只允许大小写英文字母数字及下划线的组合', + }, + ]} + > + {currRefMeta.refWidget ? ( + <currRefMeta.refWidget + isCheck={isCheck} + key="ref-widget" + onChange={onRefValChange} + placeholder={'请补全引用路径'} + /> + ) : ( + <Input disabled={isCheck} key="ref-default-input" placeholder={'请完善引用路径'} /> + )} + </Form.Item> + )} + + <Form.Item + hidden={!isDefaultRefType} + field={getNamePath('default_value')} + label={'默认值'} + rules={[ + { + validator: (value: string | undefined, callback: (error?: string) => void) => { + if (validatePassed) { + return; + } + callback(`JSON ${slotConfig.value_type} 格式错误`); + }, + }, + ]} + formatter={(value: any) => { + if (validatePassed) { + return formatValueToString(value, slotConfig.value_type); + } + return value; + }} + getValueFromEvent={(value) => { + try { + const res = parseValueFromString(value, slotConfig.value_type); + setValidatePassed(true); + return res; + } catch (error) { + setValidatePassed(false); + return value; + } + }} + > + <Input disabled={isCheck} placeholder={'默认值'} /> + </Form.Item> + </Container> + </Details> + ); + + function getNamePath(name: string) { + // A slot entry consist with [slotName, slotValue] + // so the 1 refers to entry's value + return [path, 1, name].join('.'); + } + function onTypeChange(val: JobSlotReferenceType) { + //Reset error status + toggleError(false); + // Every time change ref type, reset reference to empty + onChange && onChange([slotName, { ...slotConfig, reference_type: val, reference: '' }]); + } + function onToggle(evt: ChangeEvent<HTMLDetailsElement>) { + toggleOpen(evt.target.open); + } +}; + +function _shouldInitiallyOpen(val: SlotEntry | undefined) { + if (!val) return false; + + const [, slot] = val; + return slot.reference_type !== JobSlotReferenceType.DEFAULT && !slot.reference; +} + +/** + * Decide if the item need re-render + * 1. ignore onChange's ref change + * 2. use Deep comparison + */ +function _propsAreEqual( + { onChange: _1, value: oldValue, ...prevProps }: Props, + { onChange: _2, value: newValue, ...newProps }: Props, +): boolean { + if (oldValue !== newValue) return false; + + return isEqual(prevProps, newProps); +} + +export default memo(SlotEntryItem, _propsAreEqual); diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/helpers.ts b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/helpers.ts new file mode 100644 index 000000000..ae1bac752 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/helpers.ts @@ -0,0 +1,71 @@ +export function composeOtherJobRef(jobUuid: string, varUuid: string) { + if (!jobUuid || !varUuid) return ''; + + return `workflow.jobs['${jobUuid}'].variables.${varUuid}`; +} + +/** @returns [job-uuid, var-uuid, extra-field-value] or [job-name, var-name, extra-field-name] */ +export function parseOtherJobRef(reference?: string): [string, string, string] { + if (!reference) return [undefined, undefined] as never; + + const fragments = reference.split(/\.|']\.?|\['/); + + if (fragments.length < 5) { + return [undefined, undefined, undefined] as never; + } + + const [, , job, , variable, extraField] = fragments; + + return [job, variable, extraField]; +} + +export function parseJobPropRef(reference?: string) { + if (!reference) return [undefined, undefined] as never; + + if (reference.startsWith('self.')) { + return ['__SELF__', reference.split(/\.|']\.?|\['/)[1]]; + } + + const fragments = reference.split(/\.|']\.?|\['/); + + const [, , job, prop] = fragments; + + return [job, prop]; +} + +export function composeJobPropRef(params: { isSelf: boolean; job?: string; prop?: string }) { + const { isSelf, job, prop } = params; + + if (!prop) return undefined as never; + + if (isSelf) { + return `self.${prop}`; + } + return `workflow.jobs['${job}'].${prop}`; +} + +export function composSelfRef(varUuid?: string) { + if (!varUuid) return undefined as never; + + return `self.variables.${varUuid}`; +} + +export function parseSelfRef(val?: string) { + // e.g. self.variables.${uuid} + // If compoment type of variable is VariableComponent.AlgorithmSelect, self.variables.${uuid}.path or self.variables.${uuid}.path + const list = val?.split('.') ?? []; + return list[2] || (undefined as never); +} + +export function composeWorkflowRef(varUuid?: string) { + if (!varUuid) return undefined as never; + + return `workflow.variables.${varUuid}`; +} + +export function parseWorkflowRef(val?: string) { + // e.g. workflow.variables.${uuid} + // If compoment type of variable is VariableComponent.AlgorithmSelect, workflow.variables.${uuid}.path or workflow.variables.${uuid}.path + const list = val?.split('.') ?? []; + return list[2] || (undefined as never); +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/index.module.less new file mode 100644 index 000000000..40f12eb75 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/index.module.less @@ -0,0 +1,9 @@ +.slot_list_row { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.slot_list_col { + flex: 1; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/index.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/index.tsx new file mode 100644 index 000000000..bcd342526 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/SloEntrytList/index.tsx @@ -0,0 +1,87 @@ +import React, { FC, useContext, memo } from 'react'; +import { Form, Input } from '@arco-design/web-react'; +import NoResult from 'components/NoResult'; +import SlotEntryItem from './SlotEntryItem'; +import styled from './index.module.less'; +import { SlotEntries } from 'views/WorkflowTemplates/TemplateForm/stores'; +import { useSearchBox } from '../hooks'; +import { SearchBox } from '../elements'; +import { ComposeDrawerContext } from '../index'; + +type Props = { + slotList?: SlotEntries; + isCheck?: boolean; +}; + +const SlotEntryList: FC<Props> = ({ isCheck }) => { + const { filter, onFilterChange, onInputKeyPress } = useSearchBox(); + const context = useContext(ComposeDrawerContext); + const slotList = context.formData?._slotEntries; + + return ( + <> + <SearchBox> + <Input.Search + placeholder="按名字搜索插槽" + onChange={onFilterChange} + onKeyPress={onInputKeyPress} + /> + </SearchBox> + + <Form.List field="_slotEntries"> + {(entries) => { + const filtedItems = entries.filter(slotNameFilter); + + return ( + <div className={styled.slot_list_row}> + {/* 2 column layout */} + <div className={styled.slot_list_col}> + {filtedItems + .filter((_, index) => index % 2 === 0) + .map((entry) => ( + <Form.Item noStyle field={entry.field} key={'_slotEntries' + entry.key}> + <SlotEntryItem isCheck={isCheck} path={entry.field} /> + </Form.Item> + ))} + </div> + + <div className={styled.slot_list_col}> + {filtedItems + .filter((_, index) => index % 2 === 1) + .map((entry) => ( + <Form.Item + {...entry} + noStyle + field={entry.field} + key={'_slotEntries' + entry.key} + > + <SlotEntryItem isCheck={isCheck} path={entry.field} /> + </Form.Item> + ))} + </div> + + {entries.length === 0 && ( + <NoResult + noImage + text="该任务没有插槽" + style={{ margin: '50px auto 20px', width: '100%' }} + /> + )} + </div> + ); + }} + </Form.List> + </> + ); + + function slotNameFilter(_: any, index: number) { + if (!slotList) return true; + + const matcher = filter.toLowerCase(); + const [slotName, slot] = slotList[index] ?? []; + + return slotName?.toLowerCase().includes(matcher) || slot?.label.includes(matcher); + } +}; + +export default memo(SlotEntryList); diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/EnvsInputForm.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/EnvsInputForm.module.less new file mode 100644 index 000000000..67b141545 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/EnvsInputForm.module.less @@ -0,0 +1,8 @@ +.list_container { + transition: 0.4s var(--commonTiming); +} + +.remove_button { + position: absolute; + right: -28px; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/EnvsInputForm.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/EnvsInputForm.tsx new file mode 100644 index 000000000..a91d5af67 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/EnvsInputForm.tsx @@ -0,0 +1,98 @@ +import React, { FC, useCallback, useLayoutEffect, useRef } from 'react'; +import styled from './EnvsInputForm.module.less'; + +import { useTranslation } from 'react-i18next'; +import { convertToUnit } from 'shared/helpers'; + +import { Form, Input, Button, Grid } from '@arco-design/web-react'; +import { Delete, PlusCircle } from 'components/IconPark'; + +const Row = Grid.Row; + +const EnvVariablesForm: FC<{ + path: any; + disabled?: boolean; +}> = ({ path, disabled }) => { + const { t } = useTranslation(); + const listInnerRef = useRef<HTMLDivElement>(); + const listContainerRef = useRef<HTMLDivElement>(); + + const setListContainerMaxHeight = useCallback( + (nextHeight: any) => { + listContainerRef.current!.style.maxHeight = convertToUnit(nextHeight); + }, + [listContainerRef], + ); + const getListInnerHeight = useCallback(() => { + return listInnerRef.current!.offsetHeight!; + }, [listInnerRef]); + + useLayoutEffect(() => { + const innerHeight = getListInnerHeight() + 30; + setListContainerMaxHeight(innerHeight); + }); + + return ( + <div> + <div + className={styled.list_container} + ref={listContainerRef as any} + onTransitionEnd={onFoldAnimationEnd} + > + <Form.List field={path.join('.')}> + {(fields, { add, remove }) => { + return ( + <div ref={listInnerRef as any}> + {fields.map((field, index) => ( + <Row key={field.key + index} align="start" style={{ position: 'relative' }}> + <Form.Item + style={{ flex: '0 0 50%' }} + {...field} + label="Name" + field={[field.field, 'name'].join('.')} + key={[field.key, 'name'].join('.')} + rules={[{ required: true }]} + > + <Input placeholder="name" disabled={disabled} /> + </Form.Item> + + <Form.Item + style={{ flex: '0 0 50%' }} + label="Value" + {...field} + field={[field.field, 'value'].join('.')} + key={[field.key, 'value'].join('.')} + rules={[{ required: true }]} + > + <Input.TextArea placeholder="value" disabled={disabled} /> + </Form.Item> + + <Button + className={styled.remove_button} + size="small" + icon={<Delete />} + shape="circle" + type="text" + onClick={() => remove(field.key)} + /> + </Row> + ))} + <Button size="small" icon={<PlusCircle />} onClick={() => add()}> + {t('project.add_parameters')} + </Button> + </div> + ); + }} + </Form.List> + </div> + </div> + ); + + function onFoldAnimationEnd(_: React.TransitionEvent) { + // Because of user can adjust list inner's height by resize value-textarea or add/remove variable + // we MUST set container's maxHeight to 'initial' after unfolded (after which user can interact) + listContainerRef.current!.style.maxHeight = 'initial'; + } +}; + +export default EnvVariablesForm; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/SlotLinkAnchor.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/SlotLinkAnchor.module.less new file mode 100644 index 000000000..5d855a349 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/SlotLinkAnchor.module.less @@ -0,0 +1,6 @@ +.container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/SlotLinkAnchor.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/SlotLinkAnchor.tsx new file mode 100644 index 000000000..f753345c1 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/SlotLinkAnchor.tsx @@ -0,0 +1,131 @@ +import React, { FC } from 'react'; +import styled from './SlotLinkAnchor.module.less'; +import { + definitionsStore, + editorInfosStore, + JobDefinitionForm, + TPL_GLOBAL_NODE_UUID, +} from 'views/WorkflowTemplates/TemplateForm/stores'; +import { JobSlotReferenceType } from 'typings/workflow'; +import { Tag } from '@arco-design/web-react'; +import PubSub from 'pubsub-js'; +import { COMPOSE_DRAWER_CHANNELS, InspectPayload } from '../../index'; +import { AnchorIcon } from '../../elements'; + +export enum SlotLinkType { + Self, + OtherJob, +} + +export type SlotLink = { + type: SlotLinkType; + jobUuid: string; + slotName: string; +}; + +type Props = { + link: SlotLink; +}; + +export function collectSlotLinks( + currNodeUuid?: string, + varUuid?: string, + context?: { formData?: JobDefinitionForm }, +) { + if (!varUuid || !currNodeUuid) return []; + + const refSourceList: SlotLink[] = []; + + editorInfosStore.entries.forEach(([nodeUuid, editInfo]) => { + if (!editInfo) return; + + const { slotEntries } = editInfo; + + /** If current node is workflow global variables */ + if (currNodeUuid === TPL_GLOBAL_NODE_UUID) { + slotEntries.forEach(([slotName, slot]) => { + if (slot.reference_type === JobSlotReferenceType.WORKFLOW) { + if (_isVarMatched(slot.reference, varUuid)) { + refSourceList.push({ + type: SlotLinkType.OtherJob, + jobUuid: nodeUuid, + slotName, + }); + } + } + }); + return; + } + + /** If current editorInfo belongs to current node */ + if (currNodeUuid === nodeUuid) { + context?.formData?._slotEntries?.forEach(([slotName, slot]) => { + if (slot.reference_type === JobSlotReferenceType.SELF) { + if (_isVarMatched(slot.reference, varUuid)) { + refSourceList.push({ + type: SlotLinkType.Self, + jobUuid: nodeUuid, + slotName, + }); + } + } + }); + return; + } + + slotEntries.forEach(([slotName, slot]) => { + if (slot.reference_type === JobSlotReferenceType.OTHER_JOB) { + if (_isVarMatched(slot.reference, varUuid) && _isJobMatched(slot.reference, currNodeUuid)) { + refSourceList.push({ + type: SlotLinkType.OtherJob, + jobUuid: nodeUuid, + slotName, + }); + } + } + }); + }); + + return refSourceList; +} + +const SlotLinkAnchor: FC<Props> = ({ link }) => { + const isOtherJob = link.type === SlotLinkType.OtherJob; + + return ( + <div className={styled.container} onClick={onLinkClick}> + <div> + {link.type === SlotLinkType.Self && <Tag>本任务</Tag>} + {isOtherJob && ( + <Tag color="magenta">{definitionsStore.getValueById(link.jobUuid)?.name}</Tag> + )} + {link.slotName} + </div> + <AnchorIcon /> + </div> + ); + + function onLinkClick() { + PubSub.publish(COMPOSE_DRAWER_CHANNELS.inspect, { + jobUuid: link.jobUuid, + slotName: link.slotName, + } as InspectPayload); + } +}; + +function _isVarMatched(ref: string, varUuid: string) { + if (!ref) return false; + return ref.endsWith(`.${varUuid}`); +} + +function _isJobMatched(ref: string, uuid: string) { + if (!ref) return false; + + const fragments = ref.split('.'); + if (fragments.length !== 5) return false; + + const [, , jobUuid] = fragments; + return jobUuid === uuid; +} + +export default SlotLinkAnchor; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.module.less new file mode 100644 index 000000000..0c34aaaba --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.module.less @@ -0,0 +1,9 @@ +.del_enum_button { + position: absolute; + top: 4px; + right: -30px; +} + +.enum { + position: relative; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.tsx new file mode 100644 index 000000000..c72ad8bf3 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.tsx @@ -0,0 +1,513 @@ +import React, { FC, useCallback, useState } from 'react'; +import styled from './WidgetSchema.module.less'; +import { set } from 'lodash-es'; +import { formatValueToString } from 'shared/helpers'; + +import { Button, Input, InputNumber, Select, Switch, Form } from '@arco-design/web-react'; +import { DatasetPathSelect } from 'components/DatasetSelect'; +import IconButton from 'components/IconButton'; +import { Delete, PlusCircle } from 'components/IconPark'; +import ModelCodesEditorButton from 'components/ModelCodesEditorButton'; +import { AlgorithmSelect } from 'components/DoubleSelect'; +import EnvsInputForm from './EnvsInputForm'; +import YAMLTemplateEditorButton from 'components/YAMLTemplateEditorButton'; + +import { + Variable, + VariableAccessMode, + VariableComponent, + VariableValueType, + VariableWidgetSchema, +} from 'typings/variable'; + +import { CpuInput, MemInput } from 'components/InputGroup/NumberTextInput'; +import { disabeldPeerWritableComponentTypeList } from '.'; +import { Tag } from 'typings/workflow'; + +const { STRING, CODE, LIST, OBJECT, NUMBER } = VariableValueType; + +/** + * NOTE: we are not open to choose [Radio, Checkbox, Switch] at the moment, + * bacause Radio, Checkbox can be replaced with Select + * and Switch's boolean type value is not supported by server side yet + */ +const WIDGET_COMPONENTS__supported: Partial<Record<VariableComponent, any>> = { + [VariableComponent.Input]: { + use: Input, + label: 'Input - 输入框', + type: STRING, + allowTypeList: [STRING, LIST, OBJECT], + displayType: STRING, + }, + [VariableComponent.Select]: { + use: Select, + label: 'Select - 选择器', + type: STRING, + allowTypeList: [STRING], + displayType: STRING, + }, + [VariableComponent.NumberPicker]: { + use: InputNumber, + label: 'Number - 数字输入框', + type: NUMBER, + allowTypeList: [NUMBER], + displayType: NUMBER, + }, + [VariableComponent.CPU]: { + use: CpuInput, + label: 'CpuInput - 输入 Cpu 资源参数的输入框', + type: STRING, + allowTypeList: [STRING], + displayType: STRING, + props: { + min: 1000, + max: Number.MAX_SAFE_INTEGER, + }, + }, + [VariableComponent.MEM]: { + use: MemInput, + label: 'MemInput - 输入 Mem 资源参数的输入框', + type: STRING, + allowTypeList: [STRING], + displayType: STRING, + props: { + min: 1, + max: 100, + }, + }, + [VariableComponent.TextArea]: { + use: Input.TextArea, + label: 'TextArea - 多行文本输入框', + type: STRING, + allowTypeList: [STRING], + displayType: STRING, + }, + [VariableComponent.Code]: { + use: ModelCodesEditorButton, + label: 'Code - 代码编辑器', + type: CODE, + allowTypeList: [CODE], + displayType: CODE, + }, + [VariableComponent.JSON]: { + use: YAMLTemplateEditorButton, + label: 'JSON - JSON编辑器', + type: OBJECT, + allowTypeList: [OBJECT], + displayType: STRING, + props: { + language: 'json', + }, + }, + [VariableComponent.DatasetPath]: { + use: DatasetPathSelect, + label: 'DatasetPath - 原始数据集路径选择器', + type: STRING, + allowTypeList: [STRING], + displayType: STRING, + props: { + lazyLoad: { + enable: true, + page_size: 10, + }, + }, + }, + [VariableComponent.FeatureSelect]: { + use: Input, + label: 'FeatureSelect - 特征选择器', + type: OBJECT, + allowTypeList: [OBJECT], + displayType: STRING, + }, + [VariableComponent.EnvsInput]: { + use: EnvsInputForm, + label: 'EnvsInput - 环境变量输入器', + type: LIST, + allowTypeList: [LIST], + displayType: LIST, + }, + [VariableComponent.AlgorithmSelect]: { + use: AlgorithmSelect, + label: 'AlgorithmSelect - 算法选择器', + type: OBJECT, + allowTypeList: [OBJECT], + displayType: OBJECT, + }, +}; + +export const componentOptions = Object.entries(WIDGET_COMPONENTS__supported).map(([key, val]) => ({ + value: key, + label: val.label, +})); + +type Props = { + form: any; + path: (number | string)[]; + value?: VariableWidgetSchema; + isCheck?: boolean; + onChange?: (val: Variable) => any; +}; + +const WidgetSchema: FC<Props> = ({ form, path, value, isCheck }) => { + const data = value; + const variableIdx = path.slice(0, -1); + + const Widget = WIDGET_COMPONENTS__supported[data?.component!]?.use || Input; + const widgetProps = WIDGET_COMPONENTS__supported[data?.component!]?.props || {}; + const allowTypeList: string[] = + WIDGET_COMPONENTS__supported[data?.component!]?.allowTypeList ?? []; + const defaultValueType: VariableValueType = WIDGET_COMPONENTS__supported[data?.component!]?.type; + const displayType: VariableValueType = + WIDGET_COMPONENTS__supported[data?.component!]?.displayType; + const widgetHasEnum = _hasEnum(data?.component); + const isCheckableCompnent = _isCheckableCompnent(data?.component); + const isDisplayTypeSelect = _isDisplayTypeSelect(data?.component); + + const [valueType, setValueType] = useState<VariableValueType>(() => { + // Get lastest valueType value from form + const variables: Variable[] = form.getFieldValue('variables'); + return variables?.[variableIdx?.[0] as number]?.value_type ?? defaultValueType; + }); + const tagList = [ + { + label: '资源配置', + value: Tag.RESOURCE_ALLOCATION, + }, + { + label: '输入参数', + value: Tag.INPUT_PARAM, + }, + { + label: '输入路径', + value: Tag.INPUT_PATH, + }, + { + label: '输出路径', + value: Tag.OUTPUT_PATH, + }, + { + label: '运行参数', + value: Tag.OPERATING_PARAM, + }, + { + label: '系统变量', + value: Tag.SYSTEM_PARAM, + }, + ]; + + const formatValue = useCallback( + (value: any) => { + if (valueType === CODE) { + return value; + } + + if (displayType === VariableValueType.STRING) { + if ((valueType === LIST || valueType === OBJECT) && typeof value === 'object') { + return formatValueToString(value, valueType); + } + + if (typeof value === 'string') { + return value; + } + + // Due to server only accept string type value + if (typeof value === 'number') { + return value.toString(); + } + } + + if ( + [VariableValueType.CODE, VariableValueType.LIST, VariableValueType.OBJECT].includes( + displayType, + ) + ) { + if (typeof value !== 'object') { + try { + const finalValue = JSON.parse(value); + return finalValue; + } catch (error) { + // Do nothing + } + } + } + + return value; + }, + [valueType, displayType], + ); + if (!data) return null; + + return ( + <div> + <Form.Item + field={[...path, 'component'].join('.')} + label="请选择组件" + rules={[{ required: true, message: '请选择组件' }]} + > + <Select disabled={isCheck} placeholder="请选择组件" onChange={onComponentChange}> + {componentOptions.map((comp) => { + return ( + <Select.Option key={comp.value} value={comp.value}> + {comp.label} + </Select.Option> + ); + })} + </Select> + </Form.Item> + + <Form.Item + field={[...variableIdx, 'value_type'].join('.')} + label="值类型" + hidden={!isDisplayTypeSelect} + > + <Select disabled={isCheck} placeholder="请选择值类型" onChange={onTypeChange}> + {allowTypeList.map((type) => { + return ( + <Select.Option key={type} value={type}> + {type} + </Select.Option> + ); + })} + </Select> + </Form.Item> + + {widgetHasEnum && ( + <Form.Item + field={[...path, 'enum'].join('.')} + label="可选项" + rules={[{ required: true, message: '请添加至少一个选项' }]} + > + <Form.List field={[...path, 'enum'].join('.')}> + {(fields, { add, remove }) => { + return ( + <div> + {fields.map((field, index) => { + return ( + <div className={styled.enum} key={field.key + index}> + <Form.Item rules={[{ required: true, message: '填写选项值' }]} {...field}> + <Input disabled={isCheck} placeholder={`选项 ${index + 1}`} /> + </Form.Item> + <IconButton + className={styled.del_enum_button} + disabled={isCheck} + circle + icon={<Delete />} + onClick={() => remove(field.key)} + /> + </div> + ); + })} + + <Button + disabled={isCheck} + size="small" + icon={<PlusCircle />} + onClick={() => add()} + > + 添加选项 + </Button> + </div> + ); + }} + </Form.List> + </Form.Item> + )} + + {/* The default value path is outside `widget_schema`, so the temp solution is name.slice(0, -1) */} + <Form.Item + field={[...variableIdx, 'value'].join('.')} + label="默认值" + triggerPropName={isCheckableCompnent ? 'checked' : 'value'} + normalize={formatValue} + formatter={(val) => { + let finalValue = val; + // Because some value were be parsed as object by parseComplexDictField function, but it is has conflicts with displayType + if (displayType === VariableValueType.STRING && typeof val === 'object') { + finalValue = JSON.stringify(val); + } + return finalValue; + }} + rules={ + !widgetHasEnum && + (data?.component === VariableComponent.Input || + data?.component === VariableComponent.FeatureSelect) && + (valueType === LIST || valueType === OBJECT) + ? [ + { + validator: (value: any, callback: (error?: string) => void) => { + // Hack: I don't know why value will be object type when I already set getValueProps function to format value to string type + // This situation happen when first render + if (typeof value === 'object') { + return; + } + + try { + JSON.parse(value); + return; + } catch (error) { + callback(`JSON ${valueType} 格式错误`); + } + }, + }, + ] + : [] + } + > + {widgetHasEnum ? ( + <Widget disabled={isCheck} placeholder="按需设置变量默认值" allowClear> + {widgetHasEnum && + (data.enum || []).map((opt: any, index: number) => { + return ( + <Select.Option key={opt + index} value={opt}> + {opt || '##请填充选项值##'} + </Select.Option> + ); + })} + </Widget> + ) : ( + <Widget + disabled={isCheck} + placeholder="按需设置变量默认值" + allowClear + path={[...variableIdx, 'value']} + {...widgetProps} + /> + )} + </Form.Item> + + <Form.Item field={[...path, 'tooltip'].join('.')} label="用户输入提示"> + <Input disabled={isCheck} placeholder="输入提示解释该字段作用" /> + </Form.Item> + + <Form.Item field={[...path, 'tag'].join('.')} label="参数类型"> + <Select disabled={isCheck} placeholder="请选择参数类型" onChange={onTagChange}> + {tagList.map((item) => { + return ( + <Select.Option key={item.value} value={item.value}> + {item.label} + </Select.Option> + ); + })} + </Select> + </Form.Item> + + <Form.Item field={[...path, 'required'].join('.')} triggerPropName="checked" label="是否必填"> + <Switch disabled={isCheck} /> + </Form.Item> + <Form.Item field={[...path, 'hidden'].join('.')} triggerPropName="checked" label="是否隐藏"> + <Switch disabled={isCheck} /> + </Form.Item> + </div> + ); + + function onTypeChange(type: VariableValueType) { + setValueType(type); + + const variables = form.getFieldValue('variables'); + + set(variables, `[${variableIdx[0]}].value_type`, type); + + form.setFieldsValue({ + variables, + }); + } + + function onTagChange(tag: string) { + const variables = form.getFieldValue('variables'); + + set(variables, `[${variableIdx[0]}].tag`, tag); + + form.setFieldsValue({ + variables, + }); + } + + function onComponentChange(val: VariableComponent) { + setValueType(WIDGET_COMPONENTS__supported[val].type); + + const variables = form.getFieldValue('variables'); + + const valueType = WIDGET_COMPONENTS__supported[val].type; + + const displayType: VariableValueType = WIDGET_COMPONENTS__supported[val]?.displayType; + + let defaultValue: any; + + // TODO: it's not clean to setFieldsValue using lodash-set, find a better way! + set(variables, `[${variableIdx[0]}].value_type`, valueType); + + if (disabeldPeerWritableComponentTypeList.includes(val)) { + set(variables, `[${variableIdx[0]}].access_mode`, VariableAccessMode.PEER_READABLE); + } + + let isChangeValue = false; + + // Set '{}' as the default value of Code componets. + if ([CODE, OBJECT].includes(valueType)) { + defaultValue = {}; + if (val === VariableComponent.AlgorithmSelect) { + defaultValue = { path: '', config: [] }; + } + + set( + variables, + `[${variableIdx[0]}].value`, + displayType === VariableValueType.STRING ? JSON.stringify(defaultValue) : defaultValue, + ); + isChangeValue = true; + } + if (valueType === LIST) { + defaultValue = []; + set( + variables, + `[${variableIdx[0]}].value`, + displayType === VariableValueType.STRING ? JSON.stringify(defaultValue) : defaultValue, + ); + isChangeValue = true; + } + /** + * Remove enum value if component is not select/checkbox/radio etc. + * cause formily will always render a select if enum is Array type in spite of + * componet type is 'Input' + */ + if (val !== VariableComponent.Select) { + set(variables, `[${variableIdx[0]}].widget_schema.enum`, undefined); + } + + if (val === VariableComponent.CPU) { + set(variables, `[${variableIdx[0]}].value`, '1000m'); + isChangeValue = true; + } + + if (val === VariableComponent.MEM) { + set(variables, `[${variableIdx[0]}].value`, '1Gi'); + isChangeValue = true; + } + + if (!isChangeValue) { + set(variables, `[${variableIdx[0]}].value`, undefined); + } + + form.setFieldsValue({ + variables, + }); + } +}; + +function _hasEnum(comp?: VariableComponent) { + if (!comp) return false; + const { Select, Radio, Checkbox } = VariableComponent; + return [Select, Radio, Checkbox].includes(comp); +} +function _isCheckableCompnent(comp?: VariableComponent) { + if (!comp) return false; + const { Switch, Radio, Checkbox } = VariableComponent; + return [Switch, Radio, Checkbox].includes(comp); +} +function _isDisplayTypeSelect(comp?: VariableComponent) { + if (!comp) return false; + const { Input } = VariableComponent; + return [Input].includes(comp); +} + +export default WidgetSchema; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/index.module.less new file mode 100644 index 000000000..16aa58f56 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/index.module.less @@ -0,0 +1,37 @@ +.var_name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 550px; + flex: 1 1 0%; + padding-left: 10px; + font-size: 13px; + user-select: none; + + &:empty { + color: var(--textColorSecondary); + + &::before { + content: '// 补全变量信息'; + font-weight: normal; + } + } +} + +.action_button { + margin-right: 5px; +} + +.no_link { + color: var(--textColorSecondary); +} + +.open_indicator { + transition: 0.4s var(--commonTiming); +} + +&[data-open='true'] { + .open_indicator { + transform: rotate(180deg); + } +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/index.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/index.tsx new file mode 100644 index 000000000..c711a09c9 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/index.tsx @@ -0,0 +1,282 @@ +import { + Form, + Button, + Radio, + Tooltip, + Message, + Dropdown, + Popconfirm, + Input, +} from '@arco-design/web-react'; +import { IconLink, IconDelete, IconDown } from '@arco-design/web-react/icon'; +import PrettyMenu, { PrettyMenuItem } from 'components/PrettyMenu'; +import { indicators } from 'components/VariableLabel'; +import VariablePermission from 'components/VariblePermission'; +import { useSubscribe } from 'hooks'; +import React, { + ChangeEvent, + FC, + memo, + useContext, + useRef, + useState, + useMemo, + useCallback, +} from 'react'; +import { useToggle } from 'react-use'; +import styled from './index.module.less'; +import { ValidateErrorEntity } from 'typings/component'; +import { Variable, VariableAccessMode, VariableComponent } from 'typings/variable'; +import { VariableDefinitionForm } from 'views/WorkflowTemplates/TemplateForm/stores'; +import { Container, Details, Name, Summary } from '../../elements'; +import { + ComposeDrawerContext, + COMPOSE_DRAWER_CHANNELS, + HighlightPayload, + scrollDrawerBodyTo, +} from '../../index'; +import SlotLinkAnchor, { collectSlotLinks, SlotLink } from './SlotLinkAnchor'; +import WidgetSchema from './WidgetSchema'; + +export const disabeldPeerWritableComponentTypeList = [ + VariableComponent.DatasetPath, + VariableComponent.AlgorithmSelect, + VariableComponent.FeatureSelect, +]; + +type Props = { + path: string; + isCheck?: boolean; + value?: VariableDefinitionForm; + onChange?: (val: VariableDefinitionForm) => any; + remover?: any; + removerRef?: any; +}; + +const VariableItem: FC<Props> = ({ path, isCheck, value, removerRef }) => { + const ref = useRef<HTMLDetailsElement>(null); + const [isOpen, toggleOpen] = useToggle(_shouldInitiallyOpen(value)); + const [hasError, toggleError] = useToggle(false); + const [slotLinks, setSlotLinks] = useState<SlotLink[]>([]); + const [highlighted, setHighlight] = useToggle(false); + + const context = useContext(ComposeDrawerContext); + const varsIdentifyStr = context.formData?.variables?.map((item) => item.name).join(); + + const duplicatedNameValidator = useCallback( + (value: any, callback: (error?: string) => void) => { + if ( + context.formData?.variables + .filter((item) => item._uuid !== data._uuid) + .some((item) => item.name === value) + ) { + callback('检测到重名变量'); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [varsIdentifyStr], + ); + + useSubscribe( + COMPOSE_DRAWER_CHANNELS.broadcast_error, + (_: string, errInfo: ValidateErrorEntity) => { + const hasError = errInfo.errorFields.some((field) => { + const [pathL1] = field.name; + return pathL1 === path; + }); + + toggleError(hasError); + + if (hasError) { + toggleOpen(true); + } + }, + ); + useSubscribe(COMPOSE_DRAWER_CHANNELS.highlight, (_: string, { varUuid }: HighlightPayload) => { + if (value && varUuid === value._uuid) { + setHighlight(true); + toggleOpen(true); + + // Scroll slot into view + const verticalMiddleY = (window.innerHeight - 60) / 2; + const top = ref.current?.offsetTop || verticalMiddleY; + scrollDrawerBodyTo(top - verticalMiddleY); + + setTimeout(() => { + setHighlight && setHighlight(false); + }, 5000); + } + }); + + const widtgetSchemaPath = useMemo(() => { + return [path, 'widget_schema']; + }, [path]); + + if (!value) { + return null; + } + + const data = value; + const varName = data.name; + + const PermissionIndicator = indicators[data.access_mode]; + const actionDisabled = !varName; + + const componentType = data?.widget_schema?.component; + + return ( + <Details data-has-error={hasError} ref={ref as any} data-open={isOpen}> + <Summary + data-has-error={hasError} + data-highlighted={highlighted} + onClick={(evt: any) => onToggle(evt as any)} + > + {/* + Certain HTML elements, like <summary>, <fieldset> and <button>, do not work as flex containers. + You can work by nesting a div under your summary + https://stackoverflow.com/questions/46156669/safari-flex-item-unwanted-100-width-css/46163405 + */} + <div + style={{ + display: 'flex', + alignItems: 'center', + height: '100%', + }} + > + <PermissionIndicator /> + + <Name className={styled.var_name}>{varName}</Name> + <StopClickPropagation> + <div> + {context.isEasyMode && ( + <Dropdown + trigger={['click']} + position="bl" + disabled={isCheck} + droplist={ + <PrettyMenu style={{ width: 'auto' }}> + {slotLinks.map((link, index) => ( + <PrettyMenuItem key={link.jobUuid + index}> + <SlotLinkAnchor link={link} /> + </PrettyMenuItem> + ))} + {slotLinks.length === 0 && ( + <PrettyMenuItem key="variable"> + <small className={styled.no_link}>该变量暂未被引用</small> + </PrettyMenuItem> + )} + </PrettyMenu> + } + > + <Tooltip content={actionDisabled ? '没有变量名无法查看引用' : ''}> + <Button + className={styled.action_button} + type="text" + size="small" + disabled={actionDisabled} + icon={<IconLink />} + onClick={inspectSlotLinks} + > + 查看引用 + </Button> + </Tooltip> + </Dropdown> + )} + <StopClickPropagation> + <Popconfirm disabled={isCheck} title="确认删除该变量吗" onOk={onRemoveClick as any}> + <Button + className={styled.action_button} + disabled={isCheck} + type="text" + size="small" + icon={<IconDelete />} + > + 删除 + </Button> + </Popconfirm> + </StopClickPropagation> + + <IconDown className={styled.open_indicator} /> + </div> + </StopClickPropagation> + </div> + </Summary> + <Container style={{ display: isOpen ? 'block' : 'none' }}> + <Form.Item + field={[path, 'name'].join('.')} + label="Key" + rules={[ + { required: true, message: '请输入变量 Key' }, + { + match: /^[a-zA-Z_0-9-]+$/g, + message: '只允许大小写英文字母数字及下划线的组合', + }, + { + validator: duplicatedNameValidator, + }, + ]} + > + <Input disabled={isCheck} placeholder="请输入变量名 (仅允许英语及下划线)" /> + </Form.Item> + + <Form.Item + field={[path, 'access_mode'].join('.')} + label="对侧权限" + rules={[{ required: true }]} + > + <Radio.Group disabled={isCheck} type="button"> + <Radio + value={VariableAccessMode.PEER_WRITABLE} + disabled={disabeldPeerWritableComponentTypeList.includes(componentType!)} + > + <VariablePermission.Writable desc /> + </Radio> + <Radio value={VariableAccessMode.PEER_READABLE}> + <VariablePermission.Readable desc /> + </Radio> + <Radio value={VariableAccessMode.PRIVATE}> + <VariablePermission.Private desc /> + </Radio> + </Radio.Group> + </Form.Item> + + <Form.Item field={widtgetSchemaPath.join('.')} noStyle> + <WidgetSchema isCheck={isCheck} form={context.formInstance!} path={widtgetSchemaPath} /> + </Form.Item> + </Container> + </Details> + ); + + function onRemoveClick() { + if (context.isEasyMode) { + const refSrcs = collectSlotLinks(context.uuid, data._uuid, context); + if (refSrcs.length) { + Message.warning('变量仍然被引用,请先解除引用关系'); + setSlotLinks(refSrcs); + return; + } + } + if (!path) return; + + removerRef.current?.(Number(path.slice(path.indexOf('[') + 1, -1))); + } + function onToggle(evt: ChangeEvent<HTMLDetailsElement>) { + toggleOpen(evt.target.open); + } + function inspectSlotLinks() { + const refSrcs = collectSlotLinks(context.uuid, data._uuid, context); + setSlotLinks(refSrcs); + } +}; + +function _shouldInitiallyOpen(val: Variable | undefined) { + if (!val) return false; + + return !val.name; +} + +function StopClickPropagation({ children }: { children: React.ReactNode }) { + return <span onClick={(evt) => evt.stopPropagation()}>{children}</span>; +} + +export default memo(VariableItem); diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/index.module.less new file mode 100644 index 000000000..ad0bfc8b2 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/index.module.less @@ -0,0 +1,16 @@ +.var_list_row { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +.var_list_col { + flex: 1 0; +} + +.add_button_row { + display: flex; + justify-content: center; + width: 100%; + margin-top: 20px; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/index.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/index.tsx new file mode 100644 index 000000000..416e9997b --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/index.tsx @@ -0,0 +1,118 @@ +import { Button, Input, Form } from '@arco-design/web-react'; +import { Plus } from 'components/IconPark'; +import NoResult from 'components/NoResult'; +import React, { FC, memo, useCallback, useContext, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from './index.module.less'; +import { giveDefaultVariable } from 'views/WorkflowTemplates/TemplateForm/stores'; +import { SearchBox } from '../elements'; +import { useSearchBox } from '../hooks'; +import { ComposeDrawerContext } from '../index'; +import VariableItem from './VariableItem'; + +const VariableList: FC<{ isCheck?: boolean }> = ({ isCheck }) => { + const { t } = useTranslation(); + const { filter, onFilterChange, onInputKeyPress } = useSearchBox(); + + const context = useContext(ComposeDrawerContext); + const varRemoverRerf = useRef<((index: number) => void) | undefined>(undefined); + const varNameFilter = useCallback( + (_: any, index: number) => { + const matcher = filter.toLowerCase(); + const variable = context.formData?.variables![index]; + + return variable?.name?.toLowerCase().includes(matcher); + }, + [filter, context], + ); + + return ( + <> + <SearchBox> + <Input.Search + placeholder="按名字搜索变量" + onChange={onFilterChange} + onKeyPress={onInputKeyPress} + /> + </SearchBox> + + <Form.List field="variables"> + {(fields, { add, remove }) => { + const filteredItems = fields.filter(varNameFilter as any); + /** + * Due to every time rerender variable list, the remove method + * will change ref, and it lead VariableItem re-render needlessly! + * so we wrap `remove` with a ref for reducing redundant render + */ + varRemoverRerf.current = remove; + + return ( + <div className={styled.var_list_row}> + {/* 2 column layout */} + <div className={styled.var_list_col}> + {filteredItems + .filter((_, index) => index % 2 === 0) + .map((field) => ( + <Form.Item + {...field} + key={'var-' + field.key} + noStyle + rules={[{ required: true, message: t('project.msg_var_name') }]} + > + <VariableItem + isCheck={isCheck} + path={field.field} + removerRef={varRemoverRerf} + /> + </Form.Item> + ))} + </div> + + <div className={styled.var_list_col}> + {filteredItems + .filter((_, index) => index % 2 === 1) + .map((field) => ( + <Form.Item + {...field} + key={'var-' + field.key} + noStyle + rules={[{ required: true, message: t('project.msg_var_name') }]} + > + <VariableItem + isCheck={isCheck} + path={field.field} + removerRef={varRemoverRerf} + /> + </Form.Item> + ))} + </div> + + {fields.length === 0 && ( + <NoResult + noImage + text={t('暂无变量,创建一个吧')} + style={{ margin: '50px auto 20px', width: '100%' }} + /> + )} + + <div className={styled.add_button_row}> + {/* DO NOT simplify `() => add()` to `add`, it will pollute form value with $event */} + <Button + disabled={isCheck} + type="primary" + size="small" + icon={<Plus />} + onClick={() => add(giveDefaultVariable())} + > + {t('workflow.btn_add_var')} + </Button> + </div> + </div> + ); + }} + </Form.List> + </> + ); +}; + +export default memo(VariableList); diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/elements.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/elements.module.less new file mode 100644 index 000000000..e9b6fb4b3 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/elements.module.less @@ -0,0 +1,107 @@ +@import '~styles/mixins.less'; + +.anchor_icon { + transform: translateX(1px) scaleX(-1); + margin-left: 10px; +} + +.details { + --highlightColor: var(--primaryColor); + + margin-bottom: 20px; + border-radius: 2px; + .open_indicator { + transition: 0.4s var(--commonTiming); + } + &[data-open='true'] { + background-color: #fcfcfc; + padding-bottom: 10px; + box-shadow: -2px 0 0 0 var(--highlightColor); + + &[data-has-error='true'] { + --highlightColor: var(--errorColor); + } + + .open_indicator { + transform: rotate(180deg); + } + } +} + +@keyframes HighlightedWave { + 0% { + box-shadow: 0 0 0 2px var(--primaryColor); + } + 33% { + box-shadow: 0 0 0 2px var(--primaryColor), 0 0 0 5px var(--blue2); + } + 66% { + box-shadow: 0 0 0 2px var(--primaryColor), 0 0 0 10px transparent; + } + 100% { + box-shadow: 0 0 0 2px var(--primaryColor); + } +} + +.summary { + transition: 'box-shadow' 0.4s var(--commonTiming), 'background-color' 0.4s var(--commonTiming); + + position: relative; + display: flex; + align-items: center; + height: 46px; + padding-left: 10px; + padding-right: 20px; + background-color: var(--backgroundColor); + border-radius: 2px; + cursor: pointer; + box-shadow: 0 0 0 2px transparent; + list-style: none; + + // Hide safari marker + &::-webkit-details-marker { + display: none; + } + + @supports (overflow: clip) { + overflow: clip; + } + + &:hover { + background-color: #f0f0f0; + } + + &[data-has-error='true'] { + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + border: 6px solid var(--errorColor); + border-radius: 0 0 50% 0; + } + } + &[data-highlighted='true'] { + background-color: var(--blue1); + animation: HighlightedWave 2s linear infinite; + } +} + +.container { + padding-right: 60px; + padding-top: 20px; +} + +.name { + .MixinEllipsis(); + max-width: 550px; + flex: 1; + padding-left: 10px; + font-size: 13px; + user-select: none; +} + +.search_box { + width: 400px; + margin-bottom: 20px; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/elements.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/elements.tsx new file mode 100644 index 000000000..69df8f37e --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/elements.tsx @@ -0,0 +1,49 @@ +/* istanbul ignore file */ + +import React from 'react'; +import { Reply } from 'components/IconPark'; +import styled from './elements.module.less'; + +export function AnchorIcon({ children, ...props }: any) { + return <Reply className={styled.anchor_icon} />; +} + +export function Details({ children, ...props }: any) { + return ( + <div className={styled.details} {...props}> + {children} + </div> + ); +} + +export function Summary({ children, ...props }: any) { + return ( + <div className={styled.summary} {...props}> + {children} + </div> + ); +} + +export function Container({ children, ...props }: any) { + return ( + <div className={styled.container} {...props}> + {children} + </div> + ); +} + +export function Name({ children, ...props }: any) { + return ( + <strong className={styled.name} {...props}> + {children} + </strong> + ); +} + +export function SearchBox({ children, ...props }: any) { + return ( + <div className={styled.search_box} {...props}> + {children} + </div> + ); +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/hooks.ts b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/hooks.ts new file mode 100644 index 000000000..4def53ad6 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/hooks.ts @@ -0,0 +1,25 @@ +import { useState, useRef, useCallback } from 'react'; + +export function useSearchBox() { + const filterTimer: any = useRef(); + + const [filter, setFilter] = useState(''); + + const onFilterChange = useCallback((value: string, e: any) => { + clearTimeout(filterTimer.current); + + filterTimer.current = setTimeout(() => { + setFilter(value); + }, 400); + }, []); + + const onInputKeyPress = useCallback((evt: React.KeyboardEvent) => { + if (evt.key === 'Enter') evt.preventDefault(); + }, []); + + return { + onFilterChange, + filter, + onInputKeyPress, + }; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/index.module.less new file mode 100644 index 000000000..1da1ab130 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/index.module.less @@ -0,0 +1,49 @@ +.compose_drawer { + :global(.arco-drawer-content) { + padding-top: 0; + padding-bottom: 200px; + } +} + +.drawer_header { + position: sticky; + top: 0; + z-index: 5; + background-color: #fff; + margin: 0 -24px 0; + padding: 20px 16px 20px 24px; + background-color: white; + border-bottom: 1px solid var(--lineColor); +} + +.drawer_title { + position: relative; + margin-bottom: 0; + margin-right: 10px; +} + +.form_section { + margin-bottom: 20px; + padding-top: 24px; + &:not([data-fill-width]) { + padding-right: 60px; + } + > .section_heading { + background-color: white; + padding: 10px 0; + margin-bottom: 6px; + font-size: 14px; + color: var(--textColorStrong); + } +} + +.button_grid_row { + position: fixed; + z-index: 1; + bottom: 0; + width: 100%; + padding: 20px 24px; + margin-left: -16px; + background-color: white; + border-top: 1px solid var(--lineColor); +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/index.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/index.tsx new file mode 100644 index 000000000..3db6c190e --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/index.tsx @@ -0,0 +1,376 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { + Button, + Spin, + Tooltip, + Message, + Grid, + Switch, + Popconfirm, + Drawer, + Form, + FormInstance, +} from '@arco-design/web-react'; +import { DrawerProps } from '@arco-design/web-react/es/Drawer'; +import Modal from 'components/Modal'; +import { IconClose, IconLeft, IconDelete, IconSwap } from '@arco-design/web-react/icon'; +import { ChartNode } from 'components/WorkflowJobsCanvas/types'; +import GridRow from 'components/_base/GridRow'; +import { useSubscribe } from 'hooks'; +import jobTypeToMetaDatasMap from 'jobMetaDatas'; +import PubSub from 'pubsub-js'; +import React, { + forwardRef, + ForwardRefRenderFunction, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from 'react'; +import { useToggle } from 'react-use'; +import styled from './index.module.less'; +import { ValidateErrorEntity } from 'typings/component'; +import { JobType } from 'typings/job'; +import { + DEFAULT_JOB, + definitionsStore, + editorInfosStore, + JobDefinitionForm, + SlotEntries, + IS_DEFAULT_EASY_MODE, +} from 'views/WorkflowTemplates/TemplateForm/stores'; +import DefaultMode, { Perspective } from './DefaultMode'; +import ExpertMode from './ExpertMode'; +import { FieldError } from '@arco-design/web-react/es/Form/interface'; + +const Row = Grid.Row; + +interface Props extends DrawerProps { + /** Is workflow global node */ + isGlobal: boolean; + isCheck?: boolean; + revisionId?: number; + uuid?: string; + prevNode?: ChartNode; + onClose?: any; + onSubmit?: any; + onDelete?: any; + onBack: () => void; + toggleVisible?: any; +} + +export type ExposedRef = { + validate(): Promise<boolean>; + getFormValues(): JobDefinitionForm; + reset(): any; + // isValidating: { current: boolean }; + isValidating: boolean; + isEasyMode: boolean; +}; + +export const COMPOSE_DRAWER_CHANNELS = { + broadcast_error: 'broadcast_error', + validation_passed: 'validation_passed', + inspect: 'inspect', + highlight: 'highlight', +}; + +export type InspectPayload = { + jobUuid?: string; + perspective?: Perspective; +} & HighlightPayload; + +export type HighlightPayload = { + varUuid?: string; + slotName?: string; +}; + +export const ComposeDrawerContext = React.createContext({ + uuid: undefined as string | undefined, + formData: undefined as JobDefinitionForm | undefined, + formInstance: undefined as FormInstance<JobDefinitionForm> | undefined, + isEasyMode: true as boolean, +}); + +const JobComposeDrawer: ForwardRefRenderFunction<ExposedRef, Props> = ( + { + isGlobal, + isCheck, + revisionId, + uuid, + prevNode, + visible, + toggleVisible, + onClose, + onSubmit, + onDelete, + onBack, + ...props + }, + parentRef, +) => { + const [formData, setFormData] = useState<JobDefinitionForm>(undefined as any); + const [isEasyMode, toggleEasyMode] = useToggle(IS_DEFAULT_EASY_MODE); + const [formInstance] = Form.useForm<JobDefinitionForm>(); + const [isValidating, toggleValidating] = useToggle(false); + const drawerTitle = () => { + if (isCheck && isGlobal) { + return '全局变量'; + } + if (isCheck && !isGlobal) { + return `${formData?.name || '任务'}`; + } + if (!isCheck && isGlobal) { + return '编辑全局变量'; + } + if (!isCheck && !isGlobal) { + return `编辑${formData?.name || '任务'}`; + } + }; + + // =========== Callbacks ================= + const onFinish = useCallback( + (values: JobDefinitionForm) => { + PubSub.publish(COMPOSE_DRAWER_CHANNELS.validation_passed); + onSubmit && onSubmit(values.variables ? values : formInstance.getFields()); + toggleVisible && toggleVisible(false); + }, + + [onSubmit], + ); + const onValidationFailed = useCallback((errors: { [key: string]: FieldError }) => { + const errorFields: { name: string[] }[] = []; + Object.keys(errors).forEach((key) => { + errorFields.push({ + name: key.split('.'), + }); + }); + const errInfo: ValidateErrorEntity<any> = { + values: undefined, + errorFields: errorFields, + outOfDate: false, + }; + Message.warning('配置有误,请检查'); + PubSub.publish(COMPOSE_DRAWER_CHANNELS.broadcast_error, errInfo); + }, []); + const onValuesChange = useCallback((_: any, values: JobDefinitionForm) => { + setFormData(values); + }, []); + const getFormValues = useCallback(() => { + return formInstance.getFieldsValue() as JobDefinitionForm; + }, []); + const reset = useCallback(() => { + return formInstance.resetFields(); + }, []); + const validateFields = useCallback(() => { + return new Promise<boolean>((resolve) => { + toggleValidating(true); + setTimeout(() => { + formInstance + .validate() + .then(() => { + resolve(true); + }) + .catch(() => { + resolve(false); + }) + .finally(() => { + toggleValidating(false); + }); + }, 50); + }); + }, []); + const onJobTypeChange = useCallback( + (type: JobType) => { + const slotEntries: SlotEntries = []; + + const jobMetaData = jobTypeToMetaDatasMap.get(type); + + if (jobMetaData && uuid) { + slotEntries.push(...Object.entries(jobMetaData.slots)); + editorInfosStore.upsertValue(uuid, { slotEntries, meta_yaml: jobMetaData.metaYamlString }); + } + + const nextFormData = jobMetaData + ? { ...formData, job_type: type, _slotEntries: slotEntries } + : { ...formData, job_type: type, _slotEntries: [] }; + + setFormData(nextFormData); + formInstance.setFieldsValue(nextFormData); + }, + [formData, uuid], + ); + + useImperativeHandle(parentRef, () => { + return { + validate: validateFields, + getFormValues, + reset, + isEasyMode, + isValidating, + }; + }); + + useEffect(() => { + if (uuid && formInstance && visible) { + const slotEntries: SlotEntries = []; + // Get definition and editor info by job uuid + // if either of them not exist, create a new one + const definition = + definitionsStore.getValueById(uuid) ?? definitionsStore.insertNewResource(uuid); + const editorInfo = + editorInfosStore.getValueById(uuid) ?? editorInfosStore.insertNewResource(uuid); + + editorInfo && slotEntries.push(...editorInfo.slotEntries); + + const nextFormData = { ...definition, _slotEntries: slotEntries }; + + // Legacy templates don't have easy_mode field + toggleEasyMode(definition.easy_mode ?? IS_DEFAULT_EASY_MODE); + setFormData(nextFormData); + formInstance.setFieldsValue(nextFormData); + } + }, [uuid, visible]); + + useEffect(() => { + toggleVisible(false); + }, [revisionId]); + + useSubscribe( + COMPOSE_DRAWER_CHANNELS.inspect, + (_: string, { jobUuid }: InspectPayload) => { + if (jobUuid) { + const definition = definitionsStore.getValueById(jobUuid); + + if (definition && !definition.easy_mode && !isEasyMode) { + Modal.confirm({ + title: '提示', + content: '任务当前模式为专家模式,不展示插槽,如需查看,请切换至普通模式', + }); + } + } + }, + [isEasyMode], + ); + + return ( + <ComposeDrawerContext.Provider value={{ uuid, formInstance, formData, isEasyMode }}> + <Drawer + className={styled.compose_drawer} + wrapClassName="#app-content" + visible={visible} + mask={false} + width="1200px" + onCancel={closeDrawer} + headerStyle={{ display: 'none' }} + {...props} + footer={null} + > + <Spin loading={isValidating} style={{ width: '100%' }}> + <Row className={styled.drawer_header} align="center" justify="space-between"> + <GridRow align="center" gap={10}> + {prevNode && ( + <Tooltip content="返回上一个浏览的任务"> + <Button icon={<IconLeft />} size="small" onClick={onBack}> + 返回 + </Button> + </Tooltip> + )} + <h3 className={styled.drawer_title}>{drawerTitle()}</h3> + </GridRow> + <GridRow gap="10"> + <> + <Button size="small" icon={<IconSwap />} onClick={onModeToggle}> + {isEasyMode ? '专家模式' : '普通模式'} + </Button> + + {!isGlobal && ( + <Popconfirm + disabled={isCheck} + title="删除后,该 Job 配置的内容都将丢失" + cancelText="取消" + okText="确认" + onConfirm={onDeleteClick} + > + <Button + disabled={isCheck} + size="small" + type="primary" + icon={<IconDelete />} + status="danger" + > + 删除 + </Button> + </Popconfirm> + )} + </> + + <Button size="small" icon={<IconClose />} onClick={closeDrawer} /> + </GridRow> + </Row> + + <Form + labelCol={{ span: 6 }} + wrapperCol={{ span: 18 }} + form={formInstance} + onSubmit={onFinish} + onSubmitFailed={onValidationFailed} + onValuesChange={onValuesChange as any} + initialValues={{ ...DEFAULT_JOB, easy_mode: isEasyMode }} + > + {/* NOTE: easy_mode is also a part of payload, + * but we are changing the value through the button in header, + * not by this form item's switch + */} + <Form.Item field="easy_mode" hidden triggerPropName="checked"> + <Switch disabled={isCheck} /> + </Form.Item> + + {isEasyMode ? ( + <DefaultMode + isCheck={isCheck} + isGlobal={isGlobal} + onJobTypeChange={onJobTypeChange} + /> + ) : ( + <ExpertMode isCheck={isCheck} isGlobal={isGlobal} /> + )} + + <Form.Item> + <GridRow className={styled.button_grid_row} gap={16}> + <Button disabled={isCheck} type="primary" htmlType="submit" loading={isValidating}> + 确认 + </Button> + <Button onClick={closeDrawer}>取消</Button> + </GridRow> + </Form.Item> + </Form> + </Spin> + </Drawer> + </ComposeDrawerContext.Provider> + ); + + function closeDrawer() { + onClose && onClose(); + toggleVisible && toggleVisible(false); + } + function onDeleteClick() { + onDelete && onDelete(); + } + function onModeToggle() { + toggleEasyMode(); + const nextFormData = { ...formData, easy_mode: !isEasyMode }; + setFormData(nextFormData); + formInstance.setFieldsValue({ easy_mode: !isEasyMode }); + } +}; + +export function scrollDrawerBodyTo(scrollTo: number) { + const target = document.querySelector('#app-content .compose-drawer .arco-drawer-content'); + + if (target) { + target.scrollTop = scrollTo; + } +} + +export default forwardRef(JobComposeDrawer); diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateCanvas.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateCanvas.tsx new file mode 100644 index 000000000..7b851bf23 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateCanvas.tsx @@ -0,0 +1,253 @@ +import React, { + useEffect, + useState, + ForwardRefRenderFunction, + forwardRef, + useImperativeHandle, + useRef, +} from 'react'; +import ReactFlow, { + Background, + BackgroundVariant, + isNode, + OnLoadParams, + FlowElement, + useStoreActions, + Controls, + Node, +} from 'react-flow-renderer'; +import { + ChartElements, + ChartNodeStatus, + JobNodeRawData, +} from 'components/WorkflowJobsCanvas/types'; +import { Container } from 'components/WorkflowJobsCanvas/elements'; +import { JobNodeRawDataSlim, WorkflowTemplateForm } from 'stores/template'; +import { Variable } from 'typings/variable'; +import { + ConvertParams, + convertToChartElements, + RawDataRows, + RawDataCol, +} from 'components/WorkflowJobsCanvas/helpers'; +import GlobalConfigNode from 'components/WorkflowJobsCanvas/JobNodes/GlobalConfigNode'; +import TemplateConfigNode from './TemplateConfigNode'; +import { TPL_GLOBAL_NODE_UUID } from 'views/WorkflowTemplates/TemplateForm/stores'; + +type Props = { + isEdit?: boolean; + isCheck?: boolean; + template: WorkflowTemplateForm; + onCanvasClick?: any; + onNodeClick?: any; +}; +type UpdateStatusParams = { + id: string; + status: ChartNodeStatus; +}; + +export type ExposedRef = { + chartInstance: OnLoadParams; + setSelectedNodes(nodes: Node[]): any; + updateNodeStatusById(params: UpdateStatusParams): any; +}; + +const TemplateCanvas: ForwardRefRenderFunction<ExposedRef, Props> = ( + { template, onCanvasClick, onNodeClick, isEdit, isCheck }, + parentRef, +) => { + const isInitialConvert = useRef(true); + + const [chartInstance, setChartInstance] = useState<OnLoadParams>(); + const [elements, setElements] = useState<ChartElements>([]); + + // ☢️ WARNING: since we using react-flow hooks here, + // an ReactFlowProvider is REQUIRED to wrap this component inside + const setSelectedNodes = useStoreActions((actions) => actions.setSelectedElements); + + const templateIdentifyString = template.config.job_definitions + .map((item, index) => index + item.uuid + (item.mark || '')) + .join('|'); + + useEffect(() => { + const jobElements = convertToChartElements( + { + jobs: template.config.job_definitions as any, + variables: template.config.variables || [], + data: { + /** + * Assign node status by current context + * 1. If the node has status before, just reuse it + * 2. If the node is new created, set to Pending + * 3. If is edit-mode plus first time convert, all node should be Success by default + */ + status({ raw, isGlobal }: RawDataCol) { + const node = elements.find( + (node) => + node.id === (isGlobal ? TPL_GLOBAL_NODE_UUID : (raw as JobNodeRawDataSlim).uuid), + ); + + if (node) { + return node.data.status; + } + + return isEdit && isInitialConvert.current + ? ChartNodeStatus.Success + : ChartNodeStatus.Pending; + }, + }, + }, + { type: 'tpl-config', selectable: true }, + { + createGlobal: _createTPLGlobalNode, + createJob: _createTPLJobNode, + groupRows: groupByUuidDeps, + }, + ); + + setElements(jobElements); + // Set isInitialConvert to false + if (isInitialConvert.current) { + isInitialConvert.current = false; + } + // eslint-disable-next-line + }, [templateIdentifyString]); + + useImperativeHandle(parentRef, () => { + return { + chartInstance: chartInstance!, + setSelectedNodes, + updateNodeStatusById: updateNodeStatus, + }; + }); + + return ( + <Container> + <ReactFlow + elements={elements} + onLoad={onLoad} + onElementClick={(_, element: FlowElement) => onElementsClick(element)} + onPaneClick={onCanvasClick} + nodesDraggable={false} + zoomOnScroll={false} + zoomOnDoubleClick={false} + minZoom={1} + maxZoom={1} + defaultZoom={1} + nodeTypes={{ + 'tpl-config': TemplateConfigNode, + 'tpl-global': GlobalConfigNode, + }} + > + <Background variant={BackgroundVariant.Dots} gap={12} size={1} color="#E1E6ED" /> + <Controls showZoom={false} showInteractive={false} /> + </ReactFlow> + </Container> + ); + + function onLoad(_reactFlowInstance: OnLoadParams) { + setChartInstance(_reactFlowInstance); + + // Fit view at next tick + setImmediate(() => { + _reactFlowInstance!.fitView(); + }); + } + function onElementsClick(element: FlowElement) { + if (isNode(element)) { + onNodeClick && onNodeClick(element); + } + } + function updateNodeStatus(params: UpdateStatusParams) { + if (!params.id) return; + + setElements((els) => { + return (els as ChartElements).map((el) => { + if (el.id === params.id) { + el.data = { + ...el.data, + status: params.status, + }; + } + return el; + }); + }); + } +}; + +function _createTPLGlobalNode(_: Variable[], data: any, options: any) { + const name = '全局配置'; + + return { + id: TPL_GLOBAL_NODE_UUID, + data: { + raw: { + variables: [], + name, + }, + status: ChartNodeStatus.Pending, + isGlobal: true, + ...data, + }, + position: { x: 0, y: 0 }, + ...options, + // Overwrite options.type passed through convertToChartElements + type: 'tpl-global', + }; +} + +function _createTPLJobNode(job: JobNodeRawDataSlim, data: any, options: any) { + return { + id: job.uuid, + data: { + raw: job, + status: ChartNodeStatus.Pending, + mark: job.mark, + ...data, + }, + position: { x: 0, y: 0 }, + ...options, + }; +} + +export function groupByUuidDeps(params: ConvertParams) { + const { jobs, variables } = params; + + const rows: RawDataRows = []; + let rowIdx = 0; + + // Always put global node into first row + rows.push([{ raw: variables, isGlobal: true }]); + rowIdx++; + + return jobs.reduce((rows, job) => { + if (shouldPutIntoNextRow()) { + rowIdx++; + } + + addANewRowIfNotExist(); + + rows[rowIdx].push({ raw: job }); + + return rows; + + function shouldPutIntoNextRow() { + if (!job.dependencies) return false; + + return job.dependencies.some((dep) => { + return rows[rowIdx].some((item) => { + const raw = item.raw as JobNodeRawData; + + return dep.source === raw.uuid; + }); + }); + } + function addANewRowIfNotExist() { + if (!rows[rowIdx]) { + rows[rowIdx] = []; + } + } + }, rows); +} + +export default forwardRef(TemplateCanvas); diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateConfigNode.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateConfigNode.module.less new file mode 100644 index 000000000..04c12b71b --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateConfigNode.module.less @@ -0,0 +1,64 @@ +@import '~styles/mixins.less'; + +.add_job_button { + .MixinCircle(20px); + .MixinFlexAlignCenter(); + position: absolute; + display: flex; + background-color: white; + color: var(--textColorDisabled); + font-size: 20px; + transition: 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + color: var(--primaryColor); + border-color: currentColor; + } + + &::before { + content: ''; + position: absolute; + height: 21px; + padding: 10px 0; + width: 13px; + background-color: currentColor; + background-clip: content-box; + } + + &.left { + left: -32px; + top: calc(50% - 12px); + + &::before { + right: -11px; + } + } + &.right { + right: -32px; + top: calc(50% - 12px); + + &::before { + left: -11px; + } + } + &.bottom { + bottom: -32px; + left: calc(50% - 12px); + + &::before { + top: -15px; + transform: rotate(90deg); + } + } +} + +.container { + &:hover { + z-index: 5; + } + &:not(:hover) { + .add_job_button { + opacity: 0; + } + } +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateConfigNode.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateConfigNode.tsx new file mode 100644 index 000000000..2368c971f --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/TemplateConfigNode.tsx @@ -0,0 +1,129 @@ +import React, { FC } from 'react'; +import { Handle, Position } from 'react-flow-renderer'; +import { + Container, + JobName, + JobStatusText, + StatusIcon, +} from 'components/WorkflowJobsCanvas/JobNodes/elements'; +import { + configStatusText, + JobNodeProps, + statusIcons, + WORKFLOW_JOB_NODE_CHANNELS, +} from 'components/WorkflowJobsCanvas/JobNodes/shared'; +import { ChartNodeStatus, NodeData } from 'components/WorkflowJobsCanvas/types'; +import GridRow from 'components/_base/GridRow'; +import classNames from 'classnames'; +import { PlusCircle } from 'components/IconPark'; +import styled from './TemplateConfigNode.module.less'; +import PubSub from 'pubsub-js'; +import { Tooltip } from '@arco-design/web-react'; +import { definitionsStore } from 'views/WorkflowTemplates/TemplateForm/stores'; +import { IconLoading } from '@arco-design/web-react/icon'; + +const detailRegx = /detail/g; +const isCheck = detailRegx.test(window.location.href); + +type AddPosition = 'left' | 'right' | 'bottom'; +const AddJobHandle: FC<{ position: AddPosition; onClick: any }> = ({ position, onClick }) => { + let _position = ''; + switch (position) { + case 'left': + _position = styled.left; + break; + case 'right': + _position = styled.right; + break; + case 'bottom': + _position = styled.bottom; + break; + default: + _position = styled.bottom; + break; + } + return ( + <Tooltip content={`Click to add a new job to ${position}`}> + <div + className={classNames([styled.add_job_button, _position])} + style={{ pointerEvents: 'auto' }} + onClick={onButtonClick} + > + <PlusCircle /> + </div> + </Tooltip> + ); + + function onButtonClick(event: React.SyntheticEvent<any>) { + onClick && onClick(position); + + event.stopPropagation(); + } +}; + +export type AddJobPayload = { + id: string; + data: NodeData; + position: AddPosition; +}; + +const NodeStatus: FC<{ status: ChartNodeStatus }> = ({ status }) => { + // node status is success in detail page + const icon = statusIcons[status]; + const text = configStatusText[status]; + + const isValidating = status === ChartNodeStatus.Validating; + + return isValidating ? ( + <GridRow gap={5}> + <IconLoading style={{ fontSize: 16, color: 'var(--primaryColor)' }} /> + <JobStatusText>{text}</JobStatusText> + </GridRow> + ) : ( + <GridRow gap={5}> + {icon && <StatusIcon src={icon} />} + + <JobStatusText>{text}</JobStatusText> + </GridRow> + ); +}; + +const TemplateConfigNode: FC<JobNodeProps> = ({ data, id }) => { + const values = definitionsStore.getValueById(id); + return ( + <Container + data-uuid={data.raw.uuid} + className={classNames([ + data.raw.is_federated && 'federated-mark', + data.mark, + styled.container, + ])} + > + <Handle type="target" position={Position.Top} /> + + <JobName data-secondary={Boolean(values?.name)}>{values?.name || '//点击配置'}</JobName> + + <NodeStatus key={data.status} status={data.status} /> + + {!isCheck && ( + <> + <AddJobHandle position="left" onClick={onAddJobClick} /> + <AddJobHandle position="right" onClick={onAddJobClick} /> + <AddJobHandle position="bottom" onClick={onAddJobClick} /> + </> + )} + + <Handle type="source" position={Position.Bottom} /> + </Container> + ); + + function onAddJobClick(position: AddPosition) { + PubSub.publish(WORKFLOW_JOB_NODE_CHANNELS.click_add_job, { + id, + data, + position, + } as AddJobPayload); + } +}; + +export default TemplateConfigNode; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/index.module.less new file mode 100644 index 000000000..9ddf6517c --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/index.module.less @@ -0,0 +1,24 @@ +.container { + height: 594px; + width: 100%; +} + +.chart_header { + height: 48px; + padding: 13px 20px; + font-size: 14px; + line-height: 22px; + background-color: white; +} + +.template_name { + margin-bottom: 0; +} + +.footer { + position: sticky; + bottom: 0; + z-index: 5; // just > react-flow' z-index + padding: 15px 36px; + background-color: white; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/index.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/index.tsx new file mode 100644 index 000000000..b0044b8b0 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/index.tsx @@ -0,0 +1,574 @@ +import React, { FC, useCallback, useRef, useState } from 'react'; +import { Button, Message, Tooltip } from '@arco-design/web-react'; +import Modal from 'components/Modal'; +import ErrorBoundary from 'components/ErrorBoundary'; +import { WORKFLOW_JOB_NODE_CHANNELS } from 'components/WorkflowJobsCanvas/JobNodes/shared'; +import { ChartNode, ChartNodeStatus } from 'components/WorkflowJobsCanvas/types'; +import GridRow from 'components/_base/GridRow'; +import { useSubscribe } from 'hooks'; +import { cloneDeep, last } from 'lodash-es'; +import { isNode, ReactFlowProvider } from 'react-flow-renderer'; +import { Redirect, useHistory, useParams } from 'react-router'; +import { useToggle } from 'react-use'; +import { useRecoilState } from 'recoil'; +import { + createTemplateRevision, + createWorkflowTemplate, + updateWorkflowTemplate, +} from 'services/workflow'; +import { stringifyComplexDictField } from 'shared/formSchema'; +import { giveWeakRandomKey, nextTick, to } from 'shared/helpers'; +import { templateForm } from 'stores/template'; +import styled from './index.module.less'; +import { JobDependency } from 'typings/job'; +import { + JobSlotReferenceType, + WorkflowTemplatePayload, + WorkflowTemplateType, +} from 'typings/workflow'; +import { + definitionsStore, + editorInfosStore, + JobDefinitionForm, + mapUuidDepToJobName, + TPL_GLOBAL_NODE_UUID, + VariableDefinitionForm, +} from 'views/WorkflowTemplates/TemplateForm/stores'; +import JobComposeDrawer, { + COMPOSE_DRAWER_CHANNELS, + ExposedRef as DrawerExposedRef, + InspectPayload, + scrollDrawerBodyTo, +} from './JobComposeDrawer'; +import { + parseJobPropRef, + parseOtherJobRef, + parseSelfRef, + parseWorkflowRef, +} from './JobComposeDrawer/SloEntrytList/helpers'; +import WorkflowTemplateCanvas, { ExposedRef as CanVasExposedRef } from './TemplateCanvas'; +import { AddJobPayload } from './TemplateConfigNode'; +import { useGetIsCanEditTemplate } from 'views/WorkflowTemplates/shared'; + +function _createASlimJobRawData({ + uuid, + dependencies, +}: { + uuid?: string; + dependencies: JobDependency[]; +}): any { + return { + uuid: uuid || giveWeakRandomKey(), + dependencies: [...dependencies], + }; +} + +const TemplateConifg: FC<{ + isEdit?: boolean; + isCheck?: boolean; + revisionId?: number; +}> = ({ isEdit, isCheck, revisionId }) => { + const history = useHistory(); + const params = useParams<{ id?: string; revision_id?: string }>(); + + const [drawerVisible, toggleDrawerVisible] = useToggle(false); + + const drawerRef = useRef<DrawerExposedRef>(); + const canvasRef = useRef<CanVasExposedRef>(); + + const rePositionChart = useRef(false); + + const [submitting, setSubmitting] = useToggle(false); + /** Whether is workflow gloabl variables node */ + const [isGlobal, setIsGlobal] = useState(false); + const [prevNode, setPrevNode] = useState<ChartNode | undefined>(); + const [currNode, setCurrNode] = useState<ChartNode>(); + + const [template, setTemplate] = useRecoilState(templateForm); + + const { isCanEdit, tip } = useGetIsCanEditTemplate( + template.kind === WorkflowTemplateType.BUILT_IN, + ); + /** updateTemplate only isEdit is true + * createTemplate when status is isCreate || isRevision || revisionId !== undefined + */ + const isRevision = Boolean(params.revision_id); + + const onDrawerFormSubmit = useCallback( + (values: JobDefinitionForm) => { + saveCurrentValues({ values, isGlobal }); + + canvasRef.current?.updateNodeStatusById({ + id: currNode?.id!, + status: ChartNodeStatus.Success, + }); + + canvasRef.current?.setSelectedNodes([]); + setCurrNode(undefined); + setPrevNode(undefined); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [currNode, isGlobal], + ); + + const onSubmitClick = useCallback(async () => { + if (!checkIfAllJobConfigCompleted()) { + return Message.warning('未完成配置或有正在编辑的任务,请确认后再次提交'); + } + toggleDrawerVisible(false); + setSubmitting(true); + + const { config, ...basics } = cloneDeep(template); + let payload: WorkflowTemplatePayload<JobDefinitionForm, VariableDefinitionForm> = { + ...basics, + config: {} as any, + }; + + payload.config.variables = cloneDeep( + definitionsStore.getValueById(TPL_GLOBAL_NODE_UUID)?.variables!, + ); + + payload.config.job_definitions = config.job_definitions.map((item) => { + const values = cloneDeep(definitionsStore.getValueById(item.uuid)); + return { + ...values, + dependencies: item.dependencies.map(mapUuidDepToJobName), + } as any; + }); + payload.editor_info = { + yaml_editor_infos: Object.fromEntries( + /** + * Convert job & variable uuid in reference to job & variable's name + */ + config.job_definitions.map((item) => { + const { name: selfName, variables: selfVars } = definitionsStore.getValueById(item.uuid)!; + const { slotEntries, meta_yaml } = cloneDeep(editorInfosStore.getValueById(item.uuid)!); + slotEntries.forEach(([_, slot]) => { + if (slot.reference_type === JobSlotReferenceType.OTHER_JOB) { + const [jobUuid, varUuid] = parseOtherJobRef(slot.reference); + const target = definitionsStore.getValueById(jobUuid); + + if (target) { + slot.reference = slot.reference + .replace(jobUuid, target.name) + .replace(varUuid, target.variables.find((item) => item._uuid === varUuid)?.name!); + } + } + + if (slot.reference_type === JobSlotReferenceType.SELF) { + const varUuid = parseSelfRef(slot.reference); + + slot.reference = slot.reference.replace( + varUuid, + selfVars.find((item) => item._uuid === varUuid)?.name!, + ); + } + + if (slot.reference_type === JobSlotReferenceType.JOB_PROPERTY) { + const [jobUuid] = parseJobPropRef(slot.reference); + const target = definitionsStore.getValueById(jobUuid); + + if (target) { + slot.reference = slot.reference.replace(jobUuid, target.name); + } + } + + if (slot.reference_type === JobSlotReferenceType.WORKFLOW) { + const varUuid = parseWorkflowRef(slot.reference); + const globalDef = definitionsStore.getValueById(TPL_GLOBAL_NODE_UUID)!; + slot.reference = slot.reference.replace( + varUuid, + globalDef.variables.find((item) => item._uuid === varUuid)?.name!, + ); + } + }); + + return [selfName, { meta_yaml, slots: Object.fromEntries(slotEntries) }]; + }), + ), + }; + + // === Remove variables' _uuid start === + payload.config.variables.forEach((item: Partial<VariableDefinitionForm>) => { + if (item._uuid) delete item._uuid; + }); + payload.config.job_definitions.forEach((job) => { + job.variables.forEach((variable: Partial<VariableDefinitionForm>) => { + if (variable._uuid) delete variable._uuid; + }); + }); + // === Remove variables' _uuid end === + + payload.config.group_alias = basics.group_alias; + + payload = stringifyComplexDictField(payload); + + const [res, error] = await to( + isEdit && !revisionId && !isRevision + ? updateWorkflowTemplate(params.id!, payload) + : createWorkflowTemplate(payload), + ); + if (error) { + setSubmitting(false); + return Message.error(error.message); + } + + if (isEdit && !revisionId && !isRevision) { + await to(createTemplateRevision(params.id!)); + } else { + await to(createTemplateRevision(res.data.id)); + } + + Message.success(isEdit ? '模板修改成功!' : '模板创建成功!'); + + if (isEdit && !revisionId && !isRevision) { + history.push(`/workflow-center/workflow-templates/detail/${params.id}/config`); + } else { + history.push(`/workflow-center/workflow-templates`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [template]); + + useSubscribe( + WORKFLOW_JOB_NODE_CHANNELS.click_add_job, + (_: any, payload: AddJobPayload) => { + const nextVal = cloneDeep(template); + const jobDefs = nextVal.config.job_definitions; + + const { position, data, id } = payload; + const rows = data.rows!; + + const rowIdx = rows?.findIndex((row) => row.find((col) => col.raw.uuid === id)); + const hasRowFollowed = Boolean(rows[rowIdx + 1]); + + const uuidOfLastJobInRow = last(rows[rowIdx])!.raw.uuid; + const uuidOfHeadJobInRow = last(rows[rowIdx])!.raw.uuid; + + const leftPivotJobIdx = jobDefs.findIndex((item) => item.uuid === uuidOfHeadJobInRow); + const rightPivotJobIdx = jobDefs.findIndex((item) => item.uuid === uuidOfLastJobInRow); + + const isInsert2Left = position === 'left'; + const isInsert2Bottom = position === 'bottom'; + + const preJobs = jobDefs.slice(0, leftPivotJobIdx); + const midJobs = jobDefs.slice(leftPivotJobIdx, rightPivotJobIdx + 1); + const postJobs = jobDefs.slice(rightPivotJobIdx + 1, jobDefs.length); + + const newJobDeps: JobDependency[] = []; + const newJobUuid = giveWeakRandomKey(); + + if (isInsert2Bottom) { + const depRow = rows[rowIdx]; + newJobDeps.push(...depRow.map((col: any) => ({ source: col.raw.uuid }))); + + if (hasRowFollowed) { + const followedRow = rows[rowIdx + 1]; + // New job will create new row that only contain the new job + followedRow.forEach((col) => { + const def = jobDefs.find((def) => def.uuid === col.raw.uuid); + if (def) { + def.dependencies = [{ source: newJobUuid }]; + } + }); + } + } else { + const depRow = rows[rowIdx - 1]; + + // New job add all depRow's job dependencies + if (depRow && depRow.every((item) => !item.isGlobal)) { + newJobDeps.push(...depRow.map((col: any) => ({ source: col.raw.uuid }))); + } + + // New job will be added by each followedRow's job dependencies + if (hasRowFollowed) { + const followedRow = rows[rowIdx + 1]; + followedRow.forEach((col) => { + const def = jobDefs.find((def) => def.uuid === col.raw.uuid); + if (def) { + def.dependencies = def.dependencies.concat([{ source: newJobUuid }]); + } + }); + } + } + + const newJob = _createASlimJobRawData({ uuid: newJobUuid, dependencies: newJobDeps }); + + // If insert to right or bottom, before should be empty + const before = [isInsert2Left && newJob].filter(Boolean); + // If insert to left, after should be empty + const after = [!isInsert2Left && newJob].filter(Boolean); + + nextVal.config.job_definitions = [...preJobs, ...before, ...midJobs, ...after, ...postJobs]; + + setTemplate(nextVal); + }, + [template.config.job_definitions.length], + ); + useSubscribe( + COMPOSE_DRAWER_CHANNELS.inspect, + (_: string, { jobUuid }: InspectPayload) => { + // Is current job` + if (jobUuid === currNode?.id || !jobUuid) return; + + inspectNode(jobUuid); + }, + // Need to refresh currNode ref inside inspectNode>selectNode each time, + // otherwise, currNode will be undefined + [currNode?.id], + ); + if (!template?.name) { + if (isEdit) { + return <Redirect to={`/workflow-center/workflow-templates/edit/basic/${params.id}`} />; + } + return <Redirect to={'/workflow-center/workflow-templates/create/basic'} />; + } + + return ( + <ErrorBoundary> + <main className={styled.container}> + {isCheck ? ( + <></> + ) : ( + <header className={styled.chart_header}> + <h3 className={styled.template_name}>{template.name}</h3> + </header> + )} + <ReactFlowProvider> + <WorkflowTemplateCanvas + ref={canvasRef as any} + isEdit={isEdit} + isCheck={isCheck} + template={template} + onNodeClick={selectNode} + onCanvasClick={onCanvasClick} + /> + </ReactFlowProvider> + + <JobComposeDrawer + ref={drawerRef as any} + isGlobal={isGlobal} + isCheck={isCheck} + revisionId={revisionId} + uuid={currNode?.id} + prevNode={prevNode} + visible={drawerVisible} + toggleVisible={toggleDrawerVisible} + onSubmit={onDrawerFormSubmit} + onClose={onCloseDrawer} + onDelete={onDeleteJob} + onBack={onBackToPrevJob} + /> + + {isCheck ? ( + <></> + ) : ( + <footer className={styled.footer}> + <GridRow gap="12"> + <Tooltip content={tip}> + <Button + type="primary" + loading={submitting} + onClick={onSubmitClick} + disabled={!isCanEdit} + > + 确认 + </Button> + </Tooltip> + <Button onClick={onPrevStepClick} disabled={submitting}> + 上一步 + </Button> + <Button onClick={onCancelForkClick} disabled={submitting}> + 取消 + </Button> + </GridRow> + </footer> + )} + </main> + </ErrorBoundary> + ); + + // ---------------- Methods -------------------- + + function saveCurrentValues(payload: { values?: JobDefinitionForm; isGlobal: boolean }) { + const currUuid = currNode?.id; + if (currUuid) { + const { _slotEntries: slotEntries, ...definitionValues } = + payload.values ?? drawerRef.current?.getFormValues()!; + + const editInfo = editorInfosStore.getValueById(currUuid); + + if (drawerRef.current?.isEasyMode && !payload.isGlobal && editInfo) { + const { meta_yaml } = editInfo; + editorInfosStore.upsertValue(currUuid, { slotEntries, meta_yaml }); + } + + definitionsStore.upsertValue(currUuid, definitionValues); + } + } + function checkIfAllJobConfigCompleted() { + const isAllCompleted = canvasRef.current?.chartInstance + .getElements() + .filter(isNode) + .every((node) => { + return node.data.status === ChartNodeStatus.Success; + }); + + return isAllCompleted; + } + + async function validateCurrentForm(nodeId?: string) { + const id = nodeId ?? currNode?.id; + if (id) { + canvasRef.current?.updateNodeStatusById({ + id, + status: ChartNodeStatus.Validating, + }); + const valid = await drawerRef.current?.validate(); + canvasRef.current?.updateNodeStatusById({ + id, + status: valid ? ChartNodeStatus.Success : ChartNodeStatus.Error, + }); + } + } + function inspectNode(uuid: string) { + const targetJobNode = canvasRef.current?.chartInstance + .getElements() + .filter(isNode) + .find((node) => { + return node.id === uuid; + }); + + if (targetJobNode && canvasRef.current) { + selectNode(targetJobNode as ChartNode); + canvasRef.current.setSelectedNodes([targetJobNode]); + } + } + async function selectNode(nextNode: ChartNode) { + if (nextNode.id === currNode?.id) return; + + if (currNode) { + setPrevNode(currNode); + } + + saveCurrentValues({ isGlobal }); + validateCurrentForm(currNode?.id).then(() => { + drawerRef.current?.reset(); + setCurrNode(nextNode); + setIsGlobal(!!nextNode?.data.isGlobal); + }); + + canvasRef.current?.updateNodeStatusById({ + id: nextNode?.id!, + status: ChartNodeStatus.Processing, + }); + + scrollDrawerBodyTo(0); + + toggleDrawerVisible(true); + + if (!drawerVisible && !rePositionChart.current) { + // Put whole chart at left side due to the opened drawer will override it, + // And we only do it once then let the user control it + canvasRef.current?.chartInstance.setTransform({ x: 50, y: 50, zoom: 1 }); + rePositionChart.current = true; + } + } + + // ---------------- Handlers -------------------- + + function onBackToPrevJob() { + if (prevNode) { + inspectNode(prevNode?.id); + + nextTick(() => { + setPrevNode(undefined); + }); + } + } + function onPrevStepClick() { + history.goBack(); + } + function onDeleteJob() { + const uuid = currNode?.id; + + if (definitionsStore.size === 2) { + Message.warning('工作流至少需要一个任务'); + return; + } + + if (uuid) { + const nextVal = cloneDeep(template); + const jobDefs = nextVal.config.job_definitions; + const idx = jobDefs.findIndex((def) => def.uuid === uuid); + const jobDefToRemove = jobDefs[idx]; + + const rows = currNode?.data.rows ?? []; + const currNodeRowIdx = rows?.findIndex((row) => row.find((col) => col.raw.uuid === uuid)); + // If row that only contain currNode, so this row will be delete soon + const shouldDeleteRow = rows[currNodeRowIdx].length === 1; + + for (let i = idx + 1; i < jobDefs.length; i++) { + const def = jobDefs[i]; + // Find followedRow job + if (def.dependencies.some((dep) => dep.source === uuid)) { + // Each followedRow job dependencies remove jobDefToRemove + def.dependencies = def.dependencies.filter((dep) => dep.source !== uuid); + + // If will delete row that only contain currNode, so each followedRow job dependencies add jobDefToRemove.dependencies + if (shouldDeleteRow) { + def.dependencies = def.dependencies.concat(jobDefToRemove.dependencies); + } + } + } + + nextVal.config.job_definitions = [ + ...jobDefs.slice(0, idx), + ...jobDefs.slice(idx + 1, jobDefs.length), + ]; + setTemplate(nextVal); + + // Remove job from store + // definitionsStore.removeValueById(uuid); + definitionsStore.map.delete(uuid); + editorInfosStore.removeValueById(uuid); + + setCurrNode(null as any); + toggleDrawerVisible(false); + } + } + function onCancelForkClick() { + Modal.confirm({ + title: '确认取消编辑模板吗?', + content: '取消后,已配置的模板内容将不再保留', + onOk() { + history.push(`/workflow-center/workflow-templates`); + }, + }); + } + + async function onCloseDrawer() { + canvasRef.current?.setSelectedNodes([]); + validateCurrentForm(currNode?.id); + setPrevNode(undefined); + } + + async function onCanvasClick() { + // If current job form is validating + if (drawerRef.current?.isValidating) { + return; + } + saveCurrentValues({ isGlobal }); + + validateCurrentForm(currNode?.id).then(() => { + drawerRef.current?.reset(); + setCurrNode(undefined); + nextTick(() => { + toggleDrawerVisible(false); + }); + }); + + setPrevNode(undefined); + } +}; + +export default TemplateConifg; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/RevisionList.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/RevisionList.module.less new file mode 100644 index 000000000..0d218a736 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/RevisionList.module.less @@ -0,0 +1,128 @@ +.container { + height: 634px; + border-right: 1px solid #e5e8ef; + border-bottom: 1px solid #e5e8ef; + .main { + width: 95%; + height: 100%; + .header { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + .name { + padding-left: 20px; + font-weight: 500; + font-size: 13px; + line-height: 40px; + color: #1d2129; + } + .number { + font-weight: 400; + font-size: 12px; + line-height: 40px; + text-align: right; + color: #86909c; + margin-right: 36px; + } + } + .list_section { + height: 594px; + overflow-y: auto; + .empty { + display: flex; + align-items: center; + justify-content: center; + height: 150px; + width: 100%; + } + .item { + height: 54px; + width: 100%; + padding-left: 20px; + cursor: pointer; + &:hover { + background: #f2f3f8; + } + .item_name { + font-weight: 500; + font-size: 12px; + line-height: 24px; + } + .item_time { + padding: 2px 8px; + font-weight: 400; + font-size: 12px; + line-height: 20px; + text-align: center; + color: #1d2129; + background: #f6f7fb; + } + .description { + height: 20px; + font-weight: 400; + font-size: 12px; + line-height: 20px; + color: #4e5969; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .popover_bottom { + text-align: right; + } + } + } + } + .collapse { + transition: 0.1s background-color; + position: absolute; + top: 250px; + left: 256px; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + width: 25px; + height: 25px; + transform: translate(-50%, 50%); + padding: 2px 0 1px; + border-radius: 50%; + cursor: pointer; + background-color: rgb(var(--gray-1)); + + &:hover { + background-color: rgb(var(--gray-3)); + } + + > .anticon { + margin-top: 1px; + font-size: 10px; + } + } + .is_reverse { + transition: 0.1s background-color cubic-bezier(0.4, 0, 0.2, 1); + position: absolute; + top: 250px; + left: 5px; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + width: 25px; + height: 25px; + transform: translate(-50%, 50%) rotate(180deg); + padding: 1px 0 2px; + border-radius: 50%; + cursor: pointer; + background-color: rgb(var(--gray-1)); + + &:hover { + background-color: rgb(var(--gray-3)); + } + > .anticon { + margin-top: -1px; + transform: rotate(180deg); + } + } +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/RevisionList.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/RevisionList.tsx new file mode 100644 index 000000000..0b1af295f --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/RevisionList.tsx @@ -0,0 +1,312 @@ +import { Left } from 'components/IconPark'; +import React, { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Form, + Grid, + Message, + Modal, + Button, + Input, + Popover, + Divider, +} from '@arco-design/web-react'; +import MoreActions from 'components/MoreActions'; +import { Edit } from 'components/IconPark'; +import InvitionTable from 'components/InvitionTable'; +import { Participant, ParticipantType } from 'typings/participant'; +import { useQuery, UseQueryResult } from 'react-query'; +import { + deleteRevision, + fetchRevisionList, + patchRevisionComment, + getTemplateRevisionDownloadHref, +} from 'services/workflow'; +import { TemplateRevision, WorkflowTemplateMenuType } from 'typings/workflow'; +import CONSTANTS from 'shared/constants'; +import { ResponseInfo } from 'typings/app'; +import { formatTimestamp } from 'shared/date'; +import { saveBlob, to } from 'shared/helpers'; +import request from 'libs/request'; +import { sendTemplateRevision } from 'services/workflow'; +import styled from './RevisionList.module.less'; + +const Row = Grid.Row; +const Col = Grid.Col; + +const REVISION_QUERY_KEY = 'fetchRevisionList'; + +interface ListProps { + id: string; + collapsed: boolean; + name?: string; + ownerType?: WorkflowTemplateMenuType; + setRevisionId: Dispatch<SetStateAction<number>>; + setCollapsed: Dispatch<SetStateAction<boolean>>; +} + +const RevisionList: FC<ListProps> = (props) => { + const { t } = useTranslation(); + const [total, setTotal] = useState(0); + const [choosen, setChoosen] = useState(0); + const { id, name, collapsed, setRevisionId, setCollapsed, ownerType } = props; + + const listQuery = useQuery( + [REVISION_QUERY_KEY, id], + () => { + return fetchRevisionList(id); + }, + { + retry: 1, + refetchOnWindowFocus: false, + onSuccess(res) { + if (res.data.length > 0) { + setRevisionId(res.data[0].id); + } + }, + }, + ); + + const list = useMemo(() => { + if (!listQuery.data?.data) return []; + if (listQuery.data?.page_meta?.total_items) { + setTotal(listQuery.data.page_meta.total_items); + } + return listQuery.data.data; + }, [listQuery.data]); + + useEffect(() => { + if (list[choosen]) { + setRevisionId(list[choosen].id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [choosen]); + + return ( + <div + className={styled.container} + style={{ + width: collapsed ? '256px' : '0px', + }} + > + {collapsed ? ( + <div className={styled.main}> + <div className={styled.header}> + <span className={styled.name}>{t('workflow.label_template_version')}</span> + <span className={styled.number}>{`共${total}个`}</span> + </div> + <section className={styled.list_section}> + {list.length === 0 ? ( + <div className={styled.empty}>{t('no_data')}</div> + ) : ( + list.map((item, index) => { + return ( + <RevisionListItem + key={item.id} + params={item} + setChoosen={setChoosen} + name={name} + index={index} + choosen={choosen} + listQuery={listQuery} + ownerType={ownerType} + /> + ); + }) + )} + </section> + </div> + ) : ( + <></> + )} + <div + onClick={() => setCollapsed(!collapsed)} + className={collapsed ? styled.collapse : styled.is_reverse} + > + <Left /> + </div> + </div> + ); +}; + +interface ItemProps { + params: TemplateRevision; + index: number; + choosen: number; + name?: string; + setChoosen: Dispatch<SetStateAction<number>>; + listQuery: UseQueryResult<ResponseInfo<TemplateRevision[]>, unknown>; + ownerType?: WorkflowTemplateMenuType; +} + +export const RevisionListItem: FC<ItemProps> = (props) => { + const { params, index, choosen, name, setChoosen, listQuery, ownerType } = props; + const [form] = Form.useForm(); + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [sendModalVisible, setSendModalVisible] = useState(false); + const [comment, setComment] = useState(params.comment); + const [isSubmitDisable, setIsSubmitDisable] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const moreActionsList = useMemo(() => { + const tempList = [ + { + label: '下载', + onClick: async () => { + const { id, revision_index } = params; + try { + const blob = await request(getTemplateRevisionDownloadHref(id), { + responseType: 'blob', + }); + saveBlob(blob, `V${revision_index}-${name}.json`); + } catch (error: any) { + Message.error(error.message); + } + }, + }, + { + label: '发送', + onClick: () => { + setSendModalVisible(true); + }, + }, + { + label: t('delete'), + danger: true, + onClick: () => { + Modal.confirm({ + title: `确认删除V${params.revision_index || ''}吗?`, + content: '删除后,该模板将无法进行操作,请谨慎删除', + onOk() { + deleteRevision(params.id) + .then(() => { + Message.success('删除成功'); + setChoosen(0); + listQuery.refetch(); + }) + .catch((error: any) => { + Message.error( + index === 0 + ? '无法删除最新版本模板' + : '删除失败,该模板已关联工作流任务。如需删除,请前往工作流替换模板后再试', + ); + }); + }, + }); + }, + }, + ]; + ownerType === WorkflowTemplateMenuType.PARTICIPANT && tempList.splice(1, 1); + return tempList; + }, [ownerType, listQuery, name, t, setChoosen, params, index]); + + return ( + <div + onClick={() => { + setChoosen(index); + }} + style={{ background: choosen === index ? '#f2f3f8' : '' }} + className={styled.item} + > + <Row> + <Col className={styled.item_name} span={4}>{`V${params.revision_index}`}</Col> + <Col className={styled.item_time} span={16}> + {formatTimestamp(params.created_at!)} + </Col> + <Col span={4} style={{ textAlign: 'center' }}> + <MoreActions actionList={moreActionsList} /> + </Col> + </Row> + <Row> + <Col span={21}> + <div className={styled.description}>{params.comment || CONSTANTS.EMPTY_PLACEHOLDER}</div> + </Col> + <Col span={3}> + <Popover + content={ + <> + <Input + defaultValue={params.comment} + placeholder="请输入模板描述" + allowClear={true} + onChange={(value) => { + setComment(value); + }} + /> + <Divider /> + <div className={styled.popover_bottom}> + <Button style={{ marginRight: '10px' }} onClick={() => setVisible(false)}> + 取消 + </Button> + <Button type="primary" onClick={onConfirm}> + {' '} + 确定 + </Button> + </div> + </> + } + title="编辑版本描述" + trigger="click" + popupVisible={visible} + > + <Button size="small" type="text" icon={<Edit />} onClick={() => setVisible(true)} /> + </Popover> + </Col> + </Row> + <Modal + title="发送至合作伙伴" + visible={sendModalVisible} + onOk={onOk} + confirmLoading={isLoading} + onCancel={() => setSendModalVisible(false)} + okButtonProps={{ disabled: isSubmitDisable }} + unmountOnExit={true} + style={{ minWidth: '700px' }} + > + <Form form={form}> + <Form.Item label="模版名称"> + <span>{name}</span> + </Form.Item> + <Form.Item label="模版版本"> + <span>{`V${params.revision_index}`}</span> + </Form.Item> + <Form.Item + label="合作伙伴" + field="participant_ids" + normalize={(value) => value?.map((item: any) => item.id)} + > + <InvitionTable + participantsType={ParticipantType.PLATFORM} + isSupportCheckbox={false} + onChange={(selectedParticipants: Participant[]) => { + setIsSubmitDisable(!selectedParticipants.length); + }} + /> + </Form.Item> + </Form> + </Modal> + </div> + ); + + async function onConfirm() { + await to(patchRevisionComment(params.id, { comment })); + setVisible(false); + listQuery.refetch(); + } + async function onOk() { + const participant_id = form.getFieldValue('participant_ids')?.[0]; + try { + setIsLoading(true); + await sendTemplateRevision(params.id, participant_id); + Message.success('发送成功!'); + setSendModalVisible(false); + } catch (error: any) { + Message.error(error.message); + } + setIsLoading(false); + } +}; + +export default RevisionList; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowList.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowList.module.less new file mode 100644 index 000000000..6527790af --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowList.module.less @@ -0,0 +1,21 @@ +.list_container { + width: 100%; +} + +.name_link { + display: block; + font-size: 16px; + + &[data-invalid='true'] { + color: var(--textColorDisabled); + + &:hover { + color: var(--primaryColor); + } + } +} + +.uuid_container { + display: block; + color: var(--textColorSecondary); +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowList.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowList.tsx new file mode 100644 index 000000000..9eab5e2ba --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowList.tsx @@ -0,0 +1,156 @@ +import React, { FC, useMemo } from 'react'; +import styled from './WorkflowList.module.less'; +import { Table, Message, Spin } from '@arco-design/web-react'; +import { Link } from 'react-router-dom'; +import { useQuery } from 'react-query'; +import { fetchWorkflowListByRevisionId } from 'services/workflow'; +import i18n from 'i18n'; +import { formatTimestamp } from 'shared/date'; +import { Workflow, WorkflowState, WorkflowStateFilterParam } from 'typings/workflow'; +import WorkflowStage from './WorkflowStage'; +import WhichProject from 'components/WhichProject'; +import { useUrlState, useTablePaginationWithUrlState, useGetCurrentProjectId } from 'hooks'; +import { TIME_INTERVAL } from 'shared/constants'; + +type TableColumnsOptions = { + onSuccess?: Function; + withoutActions?: boolean; + defaultFavourFilteredValue?: string[]; + onForkableChange?: (record: Workflow, val: boolean) => void; + onFavourSwitchChange?: (record: Workflow) => void; +}; + +export const getWorkflowTableColumns = (options: TableColumnsOptions = {}) => { + const ret = [ + { + title: i18n.t('workflow.name'), + dataIndex: 'name', + key: 'name', + width: 200, + render: (name: string, record: Workflow) => { + const { state } = record; + const { INVALID } = WorkflowState; + return ( + <> + <Link + className={styled.name_link} + to={`/workflow-center/workflows/${record.id}`} + rel="nopener" + data-invalid={state === INVALID} + > + {name} + </Link> + <small className={styled.uuid_container}>uuid: {record.uuid}</small> + </> + ); + }, + }, + { + title: i18n.t('workflow.col_status'), + dataIndex: 'state', + width: 120, + render: (_: string, record: Workflow) => <WorkflowStage workflow={record} />, + }, + { + title: i18n.t('workflow.col_project'), + dataIndex: 'project_id', + width: 150, + render: (project_id: number) => <WhichProject id={project_id} />, + }, + { + title: i18n.t('workflow.col_date'), + dataIndex: 'created_at', + width: 150, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + ]; + + return ret; +}; + +type QueryParams = { + project?: string; + keyword?: string; + uuid?: string; + states?: WorkflowStateFilterParam[]; + page?: number; +}; + +export const WORKFLOW_LIST_QUERY_KEY = 'fetchWorkflowListByRevisionId'; +interface Props { + revisionId: number; +} + +const WorkflowList: FC<Props> = (props) => { + const [urlState, setUrlState] = useUrlState<QueryParams>({ keyword: '', uuid: '', states: [] }); + const projectId = useGetCurrentProjectId(); + const { revisionId } = props; + + const { urlState: pageInfoState, paginationProps } = useTablePaginationWithUrlState(); + const listQueryKey = [ + WORKFLOW_LIST_QUERY_KEY, + urlState.keyword, + urlState.uuid, + urlState.states, + projectId, + pageInfoState.page, + pageInfoState.pageSize, + revisionId, + ]; + const listQuery = useQuery( + listQueryKey, + () => { + return fetchWorkflowListByRevisionId(projectId || 0, { + // template_revision_id is available only Revision Item is clicked + template_revision_id: revisionId, + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + }, + ); + const { isLoading, isError, data: res, error, refetch } = listQuery; + + if (isError && error) { + Message.error((error as Error).message); + } + + const workflowListShow = useMemo(() => { + const workflowList = res?.data ?? []; + return workflowList; + }, [res]); + + return ( + <Spin loading={isLoading}> + <div className={styled.list_container}> + <Table + className="custom-table custom-table-left-side-filter" + data={workflowListShow} + columns={getWorkflowTableColumns({ + onSuccess, + })} + onChange={(_, filter, sorter, extra) => { + if (extra.action === 'filter') { + setUrlState({ + page: 1, + }); + } + }} + scroll={{ x: '100%' }} + rowKey="name" + pagination={{ + ...paginationProps, + total: listQuery.data?.page_meta?.total_items ?? undefined, + }} + /> + </div> + </Spin> + ); + + function onSuccess() { + refetch(); + } +}; + +export default WorkflowList; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowStage.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowStage.tsx new file mode 100644 index 000000000..b48ba83cb --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/WorkflowStage.tsx @@ -0,0 +1,10 @@ +import StateIndicator from 'components/StateIndicator'; +import React, { FC } from 'react'; +import { getWorkflowStage } from 'shared/workflow'; +import { Workflow } from 'typings/workflow'; + +const WorkflowStage: FC<{ workflow: Workflow; tag?: boolean }> = ({ workflow, tag }) => { + return <StateIndicator {...getWorkflowStage(workflow)} tag={tag} />; +}; + +export default WorkflowStage; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/index.module.less new file mode 100644 index 000000000..5444feae6 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/index.module.less @@ -0,0 +1,60 @@ +@import '~styles/mixins.less'; + +.padding_container { + width: 100%; + border-bottom: 1px solid var(--lineColor); +} + +.tabs_container { + flex: auto; + min-width: 0; +} + +.name { + margin-top: 0; + margin-bottom: -3px; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +.comment { + font-size: 12px; + color: var(--textColorSecondary); +} + +.content { + position: relative; + display: flex; + margin: 0px -20px; +} + +.header_col { + margin-top: 9px; + text-align: right; +} + +.template_create { + position: absolute; + right: 5px; + top: 10px; + z-index: 2; +} + +.avatar { + .MixinSquare(48px); + background-color: var(--primary-1); + color: white; + border-radius: 4px; + font-size: 18px; + text-align: center; + + &::before { + display: inline-block; + width: 100%; + height: 100%; + content: ''; + background: url('../../../assets/icons/atom-icon-algorithm-management.svg') no-repeat; + background-size: contain; + } +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/index.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/index.tsx new file mode 100644 index 000000000..021980374 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateDetail/index.tsx @@ -0,0 +1,482 @@ +import React, { FC, useMemo, useState } from 'react'; +import styled from './index.module.less'; +import { useTranslation } from 'react-i18next'; +import { generatePath, useHistory, useParams } from 'react-router'; + +import { Spin, Grid, Button, Space, Tabs, Message, Modal } from '@arco-design/web-react'; +import { useGetIsCanEditTemplate } from '../shared'; +import SharedPageLayout from 'components/SharedPageLayout'; +import BreadcrumbLink from 'components/BreadcrumbLink'; + +import request from 'libs/request'; +import { saveBlob } from 'shared/helpers'; +import CONSTANTS from 'shared/constants'; +import MoreActions from 'components/MoreActions'; +import PropertyList from 'components/PropertyList'; +import TemplateConfig from '../TemplateConfig'; +import WorkflowList from './WorkflowList'; +import RevisionList from './RevisionList'; +import routes, { WorkflowTemplateDetailParams, WorkflowTemplateDetailTab } from '../routes'; +import { Rocket } from 'components/IconPark'; + +import { + deleteTemplate, + fetchRevisionDetail, + fetchTemplateById, + getTemplateDownloadHref, +} from 'services/workflow'; +import { useQuery } from 'react-query'; +import { parseComplexDictField } from 'shared/formSchema'; +import { + definitionsStore, + editorInfosStore, + JobDefinitionForm, + preprocessVariables, + TPL_GLOBAL_NODE_UUID, +} from 'views/WorkflowTemplates/TemplateForm/stores'; +import { giveWeakRandomKey } from 'shared/helpers'; +import { omit } from 'lodash-es'; +import { JobSlotReferenceType, WorkflowTemplate, WorkflowTemplateMenuType } from 'typings/workflow'; +import { + parseOtherJobRef, + parseSelfRef, + parseWorkflowRef, +} from '../TemplateConfig/JobComposeDrawer/SloEntrytList/helpers'; +import { useRecoilState } from 'recoil'; +import { templateForm } from 'stores/template'; +import CopyFormModal from '../TemplateList/CopyFormModal'; +import { formatTimestamp } from 'shared/date'; +import { Job } from 'typings/job'; +import { Variable } from 'typings/variable'; +import { useUnmount } from 'react-use'; +import { useResetCreateForm } from 'hooks/template'; + +const Row = Grid.Row; +const Col = Grid.Col; + +const TemplateDetail: FC = () => { + const { t } = useTranslation(); + const params = useParams<WorkflowTemplateDetailParams>(); + const history = useHistory(); + const reset = useResetCreateForm(); + const [template, setTemplateForm] = useRecoilState(templateForm); + const [collapsed, setCollapsed] = useState(true); + const [isShowCopyFormModal, setIsShowCopyFormModal] = useState(false); + const [selectedTemplate, setSelectedTemplate] = useState<WorkflowTemplate>(); + const [revisionId, setRevisionId] = useState(0); + const { isCanEdit } = useGetIsCanEditTemplate( + params.templateType === WorkflowTemplateMenuType.BUILT_IN, + ); + const templateQuery = useQuery( + ['fetchTemplateById', params.id], + () => { + return fetchTemplateById(params.id); + }, + { + retry: 1, + refetchOnWindowFocus: false, + onSuccess(res) { + onQuerySuccess(res.data); + }, + }, + ); + + const templateDetail = useMemo(() => { + if (!templateQuery.data?.data) return undefined; + return templateQuery.data.data; + }, [templateQuery.data]); + + const revisionQuery = useQuery( + ['fetchRevisionDetail', revisionId, templateDetail], + () => { + return fetchRevisionDetail(revisionId); + }, + { + retry: 1, + enabled: templateDetail !== undefined && revisionId !== 0, + refetchOnWindowFocus: false, + keepPreviousData: true, + onSuccess(res) { + const { + name, + kind, + group_alias, + created_at, + updated_at, + creator_username, + comment, + id, + } = (templateDetail as unknown) as WorkflowTemplate<Job, Variable>; + const { is_local, config, editor_info } = parseComplexDictField(res.data); + const revision_id = res.data.id; + const data: WorkflowTemplate<Job, Variable> = { + name: name!, + kind: kind!, + group_alias: group_alias!, + created_at, + updated_at, + creator_username, + comment, + id, + revision_id, + is_local, + config, + editor_info, + }; + onQuerySuccess(data); + }, + }, + ); + + const BreadcrumbLinkPaths = useMemo(() => { + return [ + { label: 'menu.label_workflow_tpl', to: '/workflow-center/workflow-templates' }, + { label: 'workflow.template_detail' }, + ]; + }, []); + const displayedProps = useMemo( + () => [ + { + value: template.group_alias, + label: t('workflow.col_group_alias'), + }, + { + value: template.creator_username, + label: t('workflow.col_creator'), + }, + { + value: template.updated_at + ? formatTimestamp(template.updated_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: t('workflow.col_update_time'), + }, + { + value: template.created_at + ? formatTimestamp(template.created_at) + : CONSTANTS.EMPTY_PLACEHOLDER, + label: t('workflow.col_create_time'), + }, + ], + [template, t], + ); + + useUnmount(() => { + reset(); + definitionsStore.clearMap(); + editorInfosStore.clearMap(); + }); + + return ( + <SharedPageLayout title={<BreadcrumbLink paths={BreadcrumbLinkPaths} />}> + <Spin loading={templateQuery.isLoading || revisionQuery.isLoading}> + <div className={styled.padding_container}> + <Row> + <Col span={12}> + <Space size="medium"> + <div + className={styled.avatar} + data-name={ + template?.name ? template.name.slice(0, 1) : CONSTANTS.EMPTY_PLACEHOLDER + } + /> + <div> + <h3 className={styled.name}>{template?.name ?? '....'}</h3> + <Space className={styled.comment}> + {template?.comment ?? CONSTANTS.EMPTY_PLACEHOLDER} + </Space> + </div> + </Space> + </Col> + <Col className={styled.header_col} span={12}> + <Space> + <Button + type="primary" + disabled={!template?.name} + onClick={() => + history.push(`/workflow-center/workflows/initiate/basic/${params.id}`) + } + > + {t('workflow.create_workflow')} + </Button> + {params.templateType !== WorkflowTemplateMenuType.PARTICIPANT && ( + <Button + disabled={!template?.name || !isCanEdit} + onClick={() => + history.push(`/workflow-center/workflow-templates/edit/basic/${params.id}`) + } + > + 编辑 + </Button> + )} + <MoreActions + actionList={[ + { + label: t('workflow.action_download'), + disabled: !template?.name, + onClick: async () => { + const { id, name } = template; + try { + const blob = await request(getTemplateDownloadHref(id!), { + responseType: 'blob', + }); + saveBlob(blob, `${name}.json`); + } catch (error: any) { + Message.error(error.message); + } + }, + }, + { + label: t('copy'), + disabled: !template?.name, + onClick: () => { + setIsShowCopyFormModal((prevState) => true); + }, + }, + { + label: t('delete'), + danger: true, + disabled: !template?.name, + onClick: () => { + Modal.confirm({ + title: `确认删除${template.name || ''}吗?`, + content: '删除后,该模板将无法进行操作,请谨慎删除', + onOk() { + deleteTemplate(template.id!) + .then(() => { + Message.success('删除成功'); + history.push( + `/workflow-center/workflow-templates?tab=${params.templateType}}`, + ); + }) + .catch((error: any) => { + Message.error(error.message); + }); + }, + }); + }, + }, + ]} + /> + </Space> + </Col> + </Row> + <PropertyList cols={6} colProportions={[1, 1, 1, 1]} properties={displayedProps} /> + </div> + <div className={styled.content}> + <RevisionList + id={params.id} + name={template?.name} + ownerType={params.templateType} + collapsed={collapsed} + setCollapsed={setCollapsed} + setRevisionId={setRevisionId} + /> + <Button + className={styled.template_create} + type="text" + size="mini" + icon={<Rocket />} + disabled={revisionId === 0} + onClick={() => { + history.push( + `/workflow-center/workflow-templates/edit/basic/${params.id}/${revisionId}`, + ); + }} + > + 生成新模板 + </Button> + <div + className={styled.tabs_container} + style={{ width: collapsed ? 'calc(100% - 256px)' : '100%' }} + > + <Tabs + defaultActiveTab={params.tab} + onChange={(tab) => history.push(getTabPath(tab))} + style={{ marginBottom: 0 }} + > + <Tabs.TabPane + title={t('workflow.step_tpl_config')} + key={WorkflowTemplateDetailTab.Config} + /> + <Tabs.TabPane + title={t('workflow.label_workflow_list')} + key={WorkflowTemplateDetailTab.List} + /> + </Tabs> + <div className={styled.padding_container} style={{ paddingTop: 0 }}> + {params.tab === WorkflowTemplateDetailTab.Config && template.name && ( + <TemplateConfig isCheck={true} revisionId={revisionId} /> + )} + {params.tab === WorkflowTemplateDetailTab.List && ( + <WorkflowList revisionId={revisionId} /> + )} + </div> + </div> + </div> + </Spin> + <CopyFormModal + selectedWorkflowTemplate={selectedTemplate} + initialValues={{ + name: selectedTemplate ? `${selectedTemplate.name}${t('workflow.copy')}` : undefined, + }} + visible={isShowCopyFormModal} + onSuccess={onCopyFormModalSuccess} + onCancel={onCopyFormModalClose} + /> + </SharedPageLayout> + ); + + // ------------- Methods --------------- + function getTabPath(tab: string) { + return generatePath(routes.WorkflowTemplateDetail, { + ...params, + tab: tab as WorkflowTemplateDetailTab, + }); + } + function onCopyFormModalSuccess() { + setIsShowCopyFormModal((prevState) => false); + } + function onCopyFormModalClose() { + setIsShowCopyFormModal((prevState) => false); + setSelectedTemplate(() => undefined); + } + + function onQuerySuccess(data: WorkflowTemplate<Job, Variable>) { + /** + * Parse the template data from server: + * 1. basic infos like name, group_alias... write to recoil all the same + * 2. each job_definition will be tagged with a uuid, + * and replace deps souce job name with corresponding uuid, + * then the {uuid, dependencies} will save to recoil and real job def values should go ../store.ts + */ + const { + name, + kind, + group_alias, + created_at, + updated_at, + creator_username, + comment, + id, + is_local, + config, + editor_info, + revision_id, + } = parseComplexDictField(data); + setSelectedTemplate(data); + definitionsStore.upsertValue(TPL_GLOBAL_NODE_UUID, { + variables: config.variables.map(preprocessVariables), + } as JobDefinitionForm); + /** + * 1. Genrate a Map<uuid, job-name> + * 2. upsert job definition values to store + * - need to stringify code type variable's value + */ + const nameToUuidMap = config.job_definitions.reduce((map, job) => { + const thisJobUuid = giveWeakRandomKey(); + map[job.name] = thisJobUuid; + + const value = omit(job, 'dependencies') as JobDefinitionForm; + value.variables = value.variables.map(preprocessVariables); + // Save job definition values to definitionsStore + definitionsStore.upsertValue(thisJobUuid, { ...value }); + + return map; + }, {} as Record<string, string>); + /** + * Convert job & variable name in reference to + * job & variable's UUID we assign above + */ + config.job_definitions.forEach((job) => { + const jobUuid = nameToUuidMap[job.name]; + const self = definitionsStore.getValueById(jobUuid)!; + + // Save job editor info to editorInfosStore + const targetEditInfo = editor_info?.yaml_editor_infos[job.name]; + + if (targetEditInfo) { + editorInfosStore.upsertValue(jobUuid, { + slotEntries: Object.entries(targetEditInfo.slots) + .sort() + .map(([slotName, slot]) => { + if (slot.reference_type === JobSlotReferenceType.OTHER_JOB) { + const [jobName, varName] = parseOtherJobRef(slot.reference); + const targetJobUuid = nameToUuidMap[jobName]; + const target = definitionsStore.getValueById(targetJobUuid); + + if (target) { + slot.reference = slot.reference + .replace(jobName, targetJobUuid) + .replace( + varName, + target.variables.find((item) => item.name === varName)?._uuid!, + ); + } + } + + if (slot.reference_type === JobSlotReferenceType.JOB_PROPERTY) { + const [jobName] = parseOtherJobRef(slot.reference); + const targetJobUuid = nameToUuidMap[jobName]; + const target = definitionsStore.getValueById(targetJobUuid); + + if (target) { + slot.reference = slot.reference.replace(jobName, targetJobUuid); + } + } + + if (slot.reference_type === JobSlotReferenceType.SELF) { + const varName = parseSelfRef(slot.reference); + + slot.reference = slot.reference.replace( + varName, + self.variables.find((item) => item.name === varName)?._uuid!, + ); + } + + if (slot.reference_type === JobSlotReferenceType.WORKFLOW) { + const varName = parseWorkflowRef(slot.reference); + const globalDef = definitionsStore.getValueById(TPL_GLOBAL_NODE_UUID)!; + + slot.reference = slot.reference.replace( + varName, + globalDef.variables.find((item) => item.name === varName)?._uuid!, + ); + } + + return [slotName, slot]; + }), + meta_yaml: targetEditInfo.meta_yaml, + }); + } else { + editorInfosStore.insertNewResource(jobUuid); + } + }); + + const jobNodeSlimRawDataList = config.job_definitions.map((job) => { + const uuid = nameToUuidMap[job.name]; + + return { + uuid, + dependencies: job.dependencies.map((dep) => ({ source: nameToUuidMap[dep.source] })), + }; + }); + setTemplateForm({ + id, + revision_id, + name, + comment, + is_local, + group_alias, + config: { + variables: [], + job_definitions: jobNodeSlimRawDataList, + }, + kind, + created_at, + updated_at, + creator_username, + }); + } +}; + +export default TemplateDetail; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepOneBasic/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepOneBasic/index.module.less new file mode 100644 index 000000000..ccbfe91ec --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/StepOneBasic/index.module.less @@ -0,0 +1,21 @@ +.container { + padding-top: 20px; + min-height: 100%; +} + +.styled_form { + width: 500px; + margin: 0 auto; + :global(.arco-form-label-item > label) { + font-size: 12px; + white-space: normal; + color: var(--color-text-2); + display: flex; + align-items: center; + justify-content: right; + } +} + +.styled_alert { + margin-bottom: 20px; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/index.module.less new file mode 100644 index 000000000..ebea3e9e7 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/index.module.less @@ -0,0 +1,8 @@ +.step_container { + width: 350px; +} + +.form_area { + flex: 1; + margin-top: 12px; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/stores.ts b/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/stores.ts new file mode 100644 index 000000000..17e39335f --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateForm/stores.ts @@ -0,0 +1,165 @@ +import { clone, isEmpty, isNil, cloneDeep } from 'lodash-es'; +import { giveWeakRandomKey } from 'shared/helpers'; +import { JobType, Job, JobDependency } from 'typings/job'; +import { + Variable, + VariableAccessMode, + VariableComponent, + VariableValueType, +} from 'typings/variable'; +import { JobSlot } from 'typings/workflow'; +import { Overwrite } from 'utility-types'; +import jobTypeToMetaDatasMap from 'jobMetaDatas'; + +export type SlotName = string; +export type SlotEntry = [SlotName, JobSlot]; +export type SlotEntries = [SlotName, JobSlot][]; +export type VariableDefinitionForm = Variable & { _uuid: string }; +export type JobDefinitionForm = Overwrite< + Omit<Job, 'dependencies'>, + { variables: VariableDefinitionForm[] } +> & { _slotEntries: SlotEntries }; +export type JobDefinitionFormWithoutSlots = Omit<JobDefinitionForm, '_slotEntries'>; + +export const TPL_GLOBAL_NODE_SYMBOL = Symbol('Template-global-node'); +export const TPL_GLOBAL_NODE_UUID = giveWeakRandomKey(TPL_GLOBAL_NODE_SYMBOL); + +export const IS_DEFAULT_EASY_MODE = true; + +// You can note that we don't have `dependencies` field here +// since the job form doesn't decide the value, but the TemplateCanvas do +export const DEFAULT_JOB: JobDefinitionForm = { + name: '', + job_type: JobType.RAW_DATA, + is_federated: false, + easy_mode: IS_DEFAULT_EASY_MODE, + yaml_template: '{}', + variables: [], + _slotEntries: [], +}; + +export const DEFAULT_GLOBAL_VARS: { variables: VariableDefinitionForm[] } = { variables: [] }; + +export const DEFAULT_VARIABLE: VariableDefinitionForm = { + _uuid: '', + name: '', + value: '', + tag: '', + access_mode: VariableAccessMode.PEER_WRITABLE, + value_type: VariableValueType.STRING, + widget_schema: { + component: VariableComponent.Input, + required: true, + }, +}; + +export function giveDefaultVariable() { + const newVar = cloneDeep(DEFAULT_VARIABLE); + newVar._uuid = giveWeakRandomKey(); + return newVar; +} + +/** + * Create a stoire that contains a map with a <tpl-canvas-node-uuid, any values> struct, + * plus some helpers to control the map + */ +class TplNodeToAnyResourceStore<ResourceT> { + map = new Map<string, ResourceT>(); + + defaultResource: (id: string) => ResourceT; + + constructor(options: { defaultResource: (id: string) => ResourceT }) { + this.defaultResource = options.defaultResource; + } + + get entries() { + return Array.from(this.map.entries()); + } + + get size() { + return this.map.size; + } + + getValueById(id: string) { + if (isNil(id)) return undefined; + + return this.map.get(id)!; + } + + insertNewResource(id: string) { + const newResrc = this.defaultResource(id); + this.upsertValue(id, newResrc); + + return newResrc; + } + + removeValueById(id: string) { + return this.map.delete(id); + } + + upsertValue(id: string, val: ResourceT) { + return this.map.set(id, val); + } + + clearMap() { + return this.map.clear(); + } +} + +/** Store of job defintiions & variables */ +export const definitionsStore = new TplNodeToAnyResourceStore<JobDefinitionFormWithoutSlots>({ + defaultResource(id: string) { + return id === TPL_GLOBAL_NODE_UUID + ? (clone(DEFAULT_GLOBAL_VARS) as JobDefinitionForm) + : clone(DEFAULT_JOB); + }, +}); + +(window as any).definitionsStore = definitionsStore; + +export function mapUuidDepToJobName(dep: JobDependency): JobDependency { + return { + source: definitionsStore.getValueById(dep.source)?.name!, + }; +} + +/** + * 1. Fill empty widget_schema + * 2. Add _uuid to each varriable + */ +export function preprocessVariables(variable: Variable): VariableDefinitionForm { + const copy = clone(variable) as VariableDefinitionForm; + + if (!variable.widget_schema || isEmpty(variable.widget_schema)) { + copy.widget_schema = clone(DEFAULT_VARIABLE.widget_schema); + } + + copy._uuid = giveWeakRandomKey(); + + return copy; +} + +/** Store of job editor infos */ +export const editorInfosStore = new TplNodeToAnyResourceStore<{ + slotEntries: SlotEntries; + meta_yaml: string; +}>({ + defaultResource(id: string) { + if (id === TPL_GLOBAL_NODE_UUID) { + return null as never; + } + + const jobType = definitionsStore.getValueById(id)?.job_type; + + if (!jobType) return null as never; + + const jobMetaData = jobTypeToMetaDatasMap.get(jobType); + + if (!jobMetaData) return null as never; + + return { + slotEntries: Object.entries(jobMetaData.slots), + meta_yaml: jobMetaData.metaYamlString, + }; + }, +}); diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateList/CopyFormModal.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateList/CopyFormModal.tsx new file mode 100644 index 000000000..cce001630 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateList/CopyFormModal.tsx @@ -0,0 +1,122 @@ +import React, { FC, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Form, Input, Button, Message } from '@arco-design/web-react'; +import { + createTemplateRevision, + createWorkflowTemplate, + fetchTemplateById, +} from 'services/workflow'; +import ButtonWithPopconfirm from 'components/ButtonWithPopconfirm'; +import { WorkflowTemplate } from 'typings/workflow'; +import { validNamePattern } from 'shared/validator'; +import { to } from 'shared/helpers'; + +export interface Props { + visible: boolean; + initialValues?: any; + selectedWorkflowTemplate?: WorkflowTemplate; + onSuccess?: () => void; + onFail?: () => void; + onCancel?: () => void; +} + +const CopyFormModal: FC<Props> = ({ + selectedWorkflowTemplate, + visible, + onSuccess, + onFail, + onCancel, + initialValues, +}) => { + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + + const [formInstance] = Form.useForm<any>(); + + useEffect(() => { + if (visible && initialValues && formInstance) { + formInstance.setFieldsValue({ + ...initialValues, + }); + } + }, [visible, initialValues, formInstance]); + + return ( + <Modal + title={t('workflow.title_copy_template')} + visible={visible} + maskClosable={false} + maskStyle={{ backdropFilter: 'blur(4px)' }} + afterClose={afterClose} + onCancel={onCancel} + onOk={onSubmit} + footer={[ + <ButtonWithPopconfirm buttonText={t('cancel')} onConfirm={onCancel} />, + <Button type="primary" htmlType="submit" loading={isLoading} onClick={onSubmit}> + {t('confirm')} + </Button>, + ]} + > + <Form + labelCol={{ span: 6 }} + wrapperCol={{ span: 16 }} + style={{ width: '500px' }} + colon={true} + form={formInstance} + > + <Form.Item + field="name" + label={t('workflow.title_template_name')} + rules={[ + { required: true }, + { match: validNamePattern, message: t('valid_error.name_invalid') }, + ]} + > + <Input /> + </Form.Item> + </Form> + </Modal> + ); + + async function onSubmit() { + const templateName = formInstance.getFieldValue('name'); + if (!selectedWorkflowTemplate) { + return; + } + + const { id } = selectedWorkflowTemplate!; + setIsLoading(true); + const [res, err] = await to(fetchTemplateById(id)); + + if (err) { + setIsLoading(false); + onFail?.(); + return Message.error(t('workflow.msg_get_tpl_detail_failed')); + } + + const newTplPayload: WorkflowTemplate = res.data; + newTplPayload.name = templateName; + newTplPayload.kind = 0; + const [resp, error] = await to(createWorkflowTemplate(newTplPayload)); + + if (error) { + setIsLoading(false); + onFail?.(); + return Message.error(error.message); + } + + await to(createTemplateRevision(resp.data.id)); + + Message.success(t('app.copy_success')); + setIsLoading(false); + onSuccess?.(); + } + + function afterClose() { + // Clear all fields + formInstance.resetFields(); + } +}; + +export default CopyFormModal; diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateList/index.module.less b/web_console_v2/client/src/views/WorkflowTemplates/TemplateList/index.module.less new file mode 100644 index 000000000..cf7484f47 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateList/index.module.less @@ -0,0 +1,13 @@ +.list_contaienr { + display: flex; + flex: 1; + width: 100%; +} + +.upload_menu_item { + width: 150; +} + +.template_name { + font-size: 12px; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/routes.tsx b/web_console_v2/client/src/views/WorkflowTemplates/routes.tsx new file mode 100644 index 000000000..039ca9548 --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/routes.tsx @@ -0,0 +1,21 @@ +import { WorkflowTemplateMenuType } from 'typings/workflow'; + +const INDEX_PATH = '/workflow-center'; +const WorkflowTmplate = `${INDEX_PATH}/workflow-templates`; + +const routes: Record<string, string> = { + WorkflowTemplateDetail: `${WorkflowTmplate}/detail/:id/:tab(config|list)/:templateType?`, +}; + +export default routes; + +export enum WorkflowTemplateDetailTab { + Config = 'config', + List = 'list', +} + +export interface WorkflowTemplateDetailParams { + tab: WorkflowTemplateDetailTab; + id: string; + templateType: WorkflowTemplateMenuType; +} diff --git a/web_console_v2/client/src/views/WorkflowTemplates/shared.ts b/web_console_v2/client/src/views/WorkflowTemplates/shared.ts new file mode 100644 index 000000000..2a33c2b3d --- /dev/null +++ b/web_console_v2/client/src/views/WorkflowTemplates/shared.ts @@ -0,0 +1,28 @@ +/* istanbul ignore file */ + +import { useRecoilValue } from 'recoil'; +import i18n from 'i18n'; + +import { appFlag } from 'stores/app'; +import { useIsAdminRole } from 'hooks/user'; + +import { FlagKey } from 'typings/flag'; + +export function useGetIsCanEditTemplate(isPresetTemplate = false) { + const appFlagValue = useRecoilValue(appFlag); + const isAdminRole = useIsAdminRole(); + + const isCanEdit = + !isPresetTemplate || + (isPresetTemplate && + isAdminRole && + Boolean(appFlagValue[FlagKey.PRESET_TEMPLATE_EDIT_ENABLED])); + + const tip = isCanEdit + ? '' + : !appFlagValue[FlagKey.PRESET_TEMPLATE_EDIT_ENABLED] + ? i18n.t('workflow.msg_can_not_edit_preset_template') + : i18n.t('workflow.msg_only_admin_edit_preset_template'); + + return { isCanEdit, tip }; +} diff --git a/web_console_v2/client/src/views/Workflows/CreateWorkflow/StepOneBasic/index.module.less b/web_console_v2/client/src/views/Workflows/CreateWorkflow/StepOneBasic/index.module.less new file mode 100644 index 000000000..d2babadc8 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/CreateWorkflow/StepOneBasic/index.module.less @@ -0,0 +1,13 @@ +.container { + min-height: 100%; + padding-top: 20px; +} + +.form_container { + width: 500px; + margin: 0 auto; +} + +.no_available_tpl { + line-height: 32px; +} diff --git a/web_console_v2/client/src/views/Workflows/CreateWorkflow/SteptTwoConfig/index.module.less b/web_console_v2/client/src/views/Workflows/CreateWorkflow/SteptTwoConfig/index.module.less new file mode 100644 index 000000000..34ac197c9 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/CreateWorkflow/SteptTwoConfig/index.module.less @@ -0,0 +1,20 @@ +.container { + height: 100%; + .chart_header { + height: 48px; + padding: 13px 20px; + font-size: 14px; + line-height: 22px; + background-color: white; + } + .footer { + position: sticky; + bottom: 0; + z-index: 5; // just above react-flow' z-index + padding: 15px 36px; + background-color: white; + } + .chart_title { + margin-bottom: 0; + } +} diff --git a/web_console_v2/client/src/views/Workflows/CreateWorkflow/index.module.less b/web_console_v2/client/src/views/Workflows/CreateWorkflow/index.module.less new file mode 100644 index 000000000..9bd6a20a7 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/CreateWorkflow/index.module.less @@ -0,0 +1,8 @@ +.step_container { + width: 350px; +} +.form_area { + flex: 1; + margin-top: 12px; + background-color: white; +} diff --git a/web_console_v2/client/src/views/Workflows/EditWorkflow/StepOneBasic/index.module.less b/web_console_v2/client/src/views/Workflows/EditWorkflow/StepOneBasic/index.module.less new file mode 100644 index 000000000..106a4ace0 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/EditWorkflow/StepOneBasic/index.module.less @@ -0,0 +1,17 @@ +.container { + min-height: 100%; + padding-top: 20px; +} + +.form_container { + width: 500px; + margin: 0 auto; +} + +.local_alert { + margin-bottom: 20px; +} + +.no_available_tpl { + line-height: 32px; +} diff --git a/web_console_v2/client/src/views/Workflows/EditWorkflow/SteptTwoConfig/index.module.less b/web_console_v2/client/src/views/Workflows/EditWorkflow/SteptTwoConfig/index.module.less new file mode 100644 index 000000000..f1643be0d --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/EditWorkflow/SteptTwoConfig/index.module.less @@ -0,0 +1,33 @@ +.container { + height: 100%; + display: flex; +} + +.chart_container { + height: 100%; + flex: 1; + + & + & { + margin-left: 16px; + } +} + +.chart_header { + height: 48px; + padding: 13px 20px; + font-size: 14px; + line-height: 22px; + background-color: white; +} + +.chart_title { + margin-bottom: 0; +} + +.footer { + position: sticky; + bottom: 0; + z-index: 5; // just above react-flow' z-index + padding: 15px 36px; + background-color: white; +} diff --git a/web_console_v2/client/src/views/Workflows/EditWorkflow/index.module.less b/web_console_v2/client/src/views/Workflows/EditWorkflow/index.module.less new file mode 100644 index 000000000..9bd6a20a7 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/EditWorkflow/index.module.less @@ -0,0 +1,8 @@ +.step_container { + width: 350px; +} +.form_area { + flex: 1; + margin-top: 12px; + background-color: white; +} diff --git a/web_console_v2/client/src/views/Workflows/ForkWorkflow/StepOneBasic/index.module.less b/web_console_v2/client/src/views/Workflows/ForkWorkflow/StepOneBasic/index.module.less new file mode 100644 index 000000000..f1b6259aa --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/ForkWorkflow/StepOneBasic/index.module.less @@ -0,0 +1,8 @@ +.container { + padding-top: 20px; + min-height: 100%; +} +.styled_form { + width: 500px; + margin: 0 auto; +} diff --git a/web_console_v2/client/src/views/Workflows/ForkWorkflow/StepTwoConfig/index.module.less b/web_console_v2/client/src/views/Workflows/ForkWorkflow/StepTwoConfig/index.module.less new file mode 100644 index 000000000..7bed9b3aa --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/ForkWorkflow/StepTwoConfig/index.module.less @@ -0,0 +1,42 @@ +@import '~styles/mixins.less'; + +.loadind_container { + .MixinFlexAlignCenter(); + height: 100%; + display: flex; +} + +.chart_container { + height: 100%; + flex: 1; + + & + & { + margin-left: 16px; + } +} + +.chart_section { + position: relative; + display: flex; + height: 100%; +} + +.chart_header { + height: 48px; + padding: 0 20px; + font-size: 14px; + line-height: 22px; + background-color: white; +} + +.chart_title { + margin-bottom: 0; +} + +.footer { + position: sticky; + bottom: 0; + z-index: 5; // just above react-flow' z-index + padding: 15px 36px; + background-color: white; +} diff --git a/web_console_v2/client/src/views/Workflows/ForkWorkflow/index.module.less b/web_console_v2/client/src/views/Workflows/ForkWorkflow/index.module.less new file mode 100644 index 000000000..9bd6a20a7 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/ForkWorkflow/index.module.less @@ -0,0 +1,8 @@ +.step_container { + width: 350px; +} +.form_area { + flex: 1; + margin-top: 12px; + background-color: white; +} diff --git a/web_console_v2/client/src/views/Workflows/InspectPeerConfig.module.less b/web_console_v2/client/src/views/Workflows/InspectPeerConfig.module.less new file mode 100644 index 000000000..575e7865e --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/InspectPeerConfig.module.less @@ -0,0 +1,9 @@ +.inspect_modal { + top: 20%; +} +.modal_header { + font-size: 16px; +} +.no_job { + margin: 30px auto; +} diff --git a/web_console_v2/client/src/views/Workflows/JobFormDrawer.module.less b/web_console_v2/client/src/views/Workflows/JobFormDrawer.module.less new file mode 100644 index 000000000..0fd21f5f0 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/JobFormDrawer.module.less @@ -0,0 +1,33 @@ +.container { + padding-top: 0; +} + +.drawer_header { + height: 68px; + padding-left: 8px; + padding-right: 6px; +} + +.drawer_title { + margin-bottom: 0; +} + +.message { + display: block; + margin-top: -17px; + margin-bottom: 10px; + line-height: 1; + color: var(--textColorSecondary); +} + +.permission_display { + margin: 0 -16px 38px; + padding: 14px 0 14px 24px; + font-size: 12px; + background-color: rgb(var(--gray-1)); +} + +.form_container { + padding-right: 68px; + padding-bottom: 200px; +} diff --git a/web_console_v2/client/src/views/Workflows/LocalWorkflowNode.module.less b/web_console_v2/client/src/views/Workflows/LocalWorkflowNode.module.less new file mode 100644 index 000000000..d862b42a3 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/LocalWorkflowNode.module.less @@ -0,0 +1,5 @@ +.container { + margin-left: 5px; + font-size: 12px; + color: var(--textColorSecondary); +} diff --git a/web_console_v2/client/src/views/Workflows/LocalWorkflowNote.tsx b/web_console_v2/client/src/views/Workflows/LocalWorkflowNote.tsx new file mode 100644 index 000000000..671a5e83b --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/LocalWorkflowNote.tsx @@ -0,0 +1,14 @@ +import { IconExclamationCircle } from '@arco-design/web-react/icon'; +import React, { FC, memo } from 'react'; +import styled from './LocalWorkflowNode.module.less'; + +const LocalWorkflowNote: FC = () => { + return ( + <div className={styled.container}> + <IconExclamationCircle style={{ marginRight: 3 }} /> + 该任务为本地任务,故无对侧配置 + </div> + ); +}; + +export default memo(LocalWorkflowNote); diff --git a/web_console_v2/client/src/views/Workflows/ScheduledWorkflowRunning/index.module.less b/web_console_v2/client/src/views/Workflows/ScheduledWorkflowRunning/index.module.less new file mode 100644 index 000000000..1528b17d5 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/ScheduledWorkflowRunning/index.module.less @@ -0,0 +1,4 @@ +.switch_container { + margin-top: 5px; + margin-bottom: 15px; +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowAccessControl/index.module.less b/web_console_v2/client/src/views/Workflows/WorkflowAccessControl/index.module.less new file mode 100644 index 000000000..258540900 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowAccessControl/index.module.less @@ -0,0 +1,3 @@ +.resource_name { + font-size: 14px; +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/GlobalConfigDrawer.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/GlobalConfigDrawer.module.less new file mode 100644 index 000000000..9f7488da8 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/GlobalConfigDrawer.module.less @@ -0,0 +1,23 @@ +.container { + top: 0; + :global(.arco-drawer-content) { + padding-top: 0; + padding-bottom: 200px; + } +} + +.drawer_header { + position: sticky; + z-index: 2; + top: 0; + margin: 0 -16px 0; + padding: 20px 16px 20px 24px; + background-color: white; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); +} + +.drawer_title { + position: relative; + margin-bottom: 0; + margin-right: 10px; +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionDetailsDrawer.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionDetailsDrawer.module.less new file mode 100644 index 000000000..16eb2a94e --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionDetailsDrawer.module.less @@ -0,0 +1,56 @@ +.container { + top: 0; + :global(.arco-drawer-content) { + padding-top: 0; + padding-bottom: 200px; + } +} + +.drawer_header { + position: sticky; + z-index: 2; + top: 0; + margin: 0 -16px 0; + padding: 20px 16px 20px 24px; + background-color: white; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); +} + +.cover_header_shadow_if_not_sticky { + position: sticky; + bottom: 0; + z-index: 5; + top: 50px; + margin: 0 -16px 0; + height: 12px; + background-color: #fff; +} + +.drawer_title { + position: relative; + margin-bottom: 0; + margin-right: 10px; +} + +.id_text { + margin-left: 10px; + color: var(--textColorSecondary); +} + +.tab_panel { + display: none; + margin-top: 16px; + + &[data-visible='true'] { + display: block; + } +} + +.code_editor_container { + height: 550px; +} + +.check_text { + cursor: pointer; + color: var(--primaryColor); +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionLogs.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionLogs.module.less new file mode 100644 index 000000000..c4a1ae768 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionLogs.module.less @@ -0,0 +1,14 @@ +.container { + position: relative; + margin-top: 20px; + margin-bottom: 20px; +} + +.heading_row { + margin-bottom: 10px; +} + +.heading { + margin-bottom: 0; + margin-right: 10px; +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionMetrics.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionMetrics.module.less new file mode 100644 index 000000000..83fe705fd --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionMetrics.module.less @@ -0,0 +1,43 @@ +@import '~styles/mixins.less'; + +.container { + margin-top: 30px; +} + +.chart_container { + height: 480px; + overflow: hidden; + text-align: center; + line-height: 200px; +} + +.placeholder { + display: flex; + flex-direction: column; + width: 280px; + height: 400px; + margin: auto; + + > img { + width: 230px; + margin-top: auto; + } +} + +.metric_not_public { + .MixinFlexAlignCenter(); + display: flex; + height: 160px; +} + +.explaination { + margin-top: 10px; + font-size: 12px; + text-align: center; + color: var(--textColorSecondary); +} + +.cta_button { + display: block; + margin: 0 auto auto; +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionPods.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionPods.module.less new file mode 100644 index 000000000..13986a3ca --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobExecutionPods.module.less @@ -0,0 +1,3 @@ +.container { + margin-top: 30px; +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaChart/EmbeddedChart.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaChart/EmbeddedChart.module.less new file mode 100644 index 000000000..f3bf6795e --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaChart/EmbeddedChart.module.less @@ -0,0 +1,15 @@ +.embedded_frame { + width: 200%; + height: 600px; + border: none; + flex-shrink: 0; + transform: scale(0.5); + transform-origin: 0 0; +} + +.controls_container { + position: absolute; + z-index: 2; + right: 10px; + bottom: 20px; +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaChart/LineChart.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaChart/LineChart.module.less new file mode 100644 index 000000000..b067ec5ff --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaChart/LineChart.module.less @@ -0,0 +1,6 @@ +.controls_container { + position: absolute; + z-index: 2; + right: 10px; + bottom: 20px; +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaItem.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaItem.module.less new file mode 100644 index 000000000..13e92a78c --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/KibanaItem.module.less @@ -0,0 +1,39 @@ +.container { + position: relative; + padding: 20px 20px 10px; + border: 1px solid var(--lineColor); + border-radius: 4px; + margin-top: 20px; +} + +.not_loaded_placeholder { + position: absolute; + max-width: 50%; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + font-size: 12px; + color: var(--textColorSecondary); + text-align: center; +} + +.chart_container { + position: relative; + display: flex; + align-items: center; + flex-direction: column; + width: 100%; + height: 300px; + overflow: hidden; + margin-top: 15px; + background-color: var(--backgroundColor); + transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + &[data-is-fill='true'] { + > [role='kibana-iframe'] { + width: 100%; + height: 100%; + transform: none; + } + } +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/index.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/index.module.less new file mode 100644 index 000000000..df37b5a4c --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/index.module.less @@ -0,0 +1,13 @@ +@import '~styles/mixins.less'; + +.add_chart_button { + width: 250px; +} + +.metric_not_public { + .MixinFlexAlignCenter(); + display: flex; + height: 160px; + font-size: 12px; + color: var(--textColorSecondary); +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/index.tsx b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/index.tsx new file mode 100644 index 000000000..7774cafc0 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/JobKibanaMetrics/index.tsx @@ -0,0 +1,62 @@ +import React, { FC, useContext, useEffect, useState } from 'react'; +import styled from './index.module.less'; +import { useTranslation } from 'react-i18next'; +import { JobExecutionDetalis } from 'typings/job'; +import { Button } from '@arco-design/web-react'; +import { IconPlus } from '@arco-design/web-react/icon'; +import KibanaItem from './KibanaItem'; +import GridRow from 'components/_base/GridRow'; +import { giveWeakRandomKey } from 'shared/helpers'; +import { JobExecutionDetailsContext } from '../JobExecutionDetailsDrawer'; + +type Props = { + job: JobExecutionDetalis; + isPeerSide?: boolean; +}; + +const JobKibanaMetrics: FC<Props> = ({ job }) => { + const { t } = useTranslation(); + const [queryList, setQueryList] = useState([giveWeakRandomKey()]); + + const { isPeerSide, workflow } = useContext(JobExecutionDetailsContext); + + useEffect(() => { + setQueryList([giveWeakRandomKey()]); + }, [job?.id]); + + const isPeerMetricsPublic = (isPeerSide && workflow?.metric_is_public) || !isPeerSide; + + if (!isPeerMetricsPublic) { + return ( + <div className={styled.metric_not_public}>{t('workflow.placeholder_metric_not_public')}</div> + ); + } + + return ( + <section> + {queryList.map((key) => ( + <KibanaItem key={key} /> + ))} + + <GridRow justify="center" top="20"> + <Button + className={styled.add_chart_button} + type="primary" + icon={<IconPlus />} + size="large" + onClick={onAddClick} + > + {t('workflow.btn_add_kibana_chart')} + </Button> + </GridRow> + </section> + ); + + function onAddClick() { + const nextList = [...queryList, giveWeakRandomKey()]; + + setQueryList(nextList); + } +}; + +export default JobKibanaMetrics; diff --git a/web_console_v2/client/src/views/Workflows/WorkflowDetail/index.module.less b/web_console_v2/client/src/views/Workflows/WorkflowDetail/index.module.less new file mode 100644 index 000000000..5a2c033d7 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowDetail/index.module.less @@ -0,0 +1,72 @@ +@import '~styles/mixins.less'; + +.chart_section { + --marginBottom: 12px; + display: flex; + margin-bottom: var(--marginBottom); +} + +.chart_container { + --chartHeaderHeight: 48px; + height: calc(var(--contentMinHeight) - var(--chartHeaderHeight) + var(--marginBottom)); + flex: 1; + + & + & { + margin-left: 16px; + } +} + +.header_card { + width: 100%; + padding: 8px; +} + +.header_row { + margin-bottom: 15px; + + &[data-forked='true'] { + margin-bottom: 25px; + } +} + +.name { + .MixinEllipsis(); + + max-width: 400px; + margin-bottom: 0; + font-size: 20px; +} + +.forked_form { + margin-top: -20px; + font-size: 12px; +} + +.origin_workflow_link { + margin-left: 5px; +} + +.no_jobs { + display: flex; + height: calc(100% - 48px); + background-color: rgb(var(--gray-1)); +} + +.chart_header { + height: var(--chartHeaderHeight); + padding: 0 20px; + font-size: 14px; + line-height: 22px; + background-color: white; +} + +.chart_title { + margin-bottom: 0; + + &::after { + margin-left: 25px; + content: attr(data-note); + font-size: 12px; + color: rgb(var(--gray-6)); + } +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowList/List/index.module.less b/web_console_v2/client/src/views/Workflows/WorkflowList/List/index.module.less new file mode 100644 index 000000000..9b04d176e --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowList/List/index.module.less @@ -0,0 +1,25 @@ +.workflow_ist_form_item { + margin-bottom: 0 !important; +} + +.workflow_list_container { + display: flex; + flex: 1; + width: 100%; + .col_name_link { + display: block; + font-size: 12px; + + &[data-invalid='true'] { + color: var(--textColorDisabled); + + &:hover { + color: var(--primaryColor); + } + } + } + .col_uuid { + display: block; + color: var(--textColorSecondary); + } +} diff --git a/web_console_v2/client/src/views/Workflows/WorkflowList/List/index.tsx b/web_console_v2/client/src/views/Workflows/WorkflowList/List/index.tsx new file mode 100644 index 000000000..c2a8a57e1 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowList/List/index.tsx @@ -0,0 +1,371 @@ +import React, { FC, useMemo, useState } from 'react'; +import { Form, Grid, Button, Input, Table, Message, Spin } from '@arco-design/web-react'; +import { Link, useHistory } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { fetchWorkflowList, favourTheWorkFlow } from 'services/workflow'; +import styled from './index.module.less'; +import i18n from 'i18n'; +import { formatTimestamp } from 'shared/date'; +import { useTranslation } from 'react-i18next'; +import { Workflow, WorkflowState, WorkflowStateFilterParam, WorkflowType } from 'typings/workflow'; +import WorkflowStage from '../WorkflowStage'; +import WorkflowActions from '../../WorkflowActions'; +import WhichProject from 'components/WhichProject'; +import MultiSelect from 'components/MultiSelect'; +import { workflowStateOptionList } from 'shared/workflow'; +import { useUrlState, useTablePaginationWithUrlState, useGetCurrentProjectId } from 'hooks'; +import { TIME_INTERVAL } from 'shared/constants'; +import { Switch } from '@arco-design/web-react'; +import { FilterOp } from 'typings/filter'; +import { constructExpressionTree, expression2Filter } from 'shared/filter'; + +const Row = Grid.Row; +const Col = Grid.Col; + +type TableColumnsOptions = { + onSuccess?: () => void; + withoutActions?: boolean; + withoutFavour?: boolean; + defaultFavourFilteredValue?: string[]; + onForkableChange?: (record: Workflow, val: boolean) => void; + onFavourSwitchChange?: (record: Workflow) => void; +}; + +export const getWorkflowTableColumns = (options: TableColumnsOptions = {}) => { + const ret = [ + { + title: i18n.t('workflow.name'), + dataIndex: 'name', + key: 'name', + width: 300, + render: (name: string, record: Workflow) => { + const { state } = record; + const { INVALID } = WorkflowState; + return ( + <> + <Link + to={`/workflow-center/workflows/${record.id}`} + rel="nopener" + className={styled.col_name_link} + data-invalid={state === INVALID} + > + {name} + </Link> + <small className={styled.col_uuid}>uuid: {record.uuid}</small> + </> + ); + }, + }, + { + title: i18n.t('workflow.col_status'), + dataIndex: 'state', + width: 150, + render: (_: string, record: Workflow) => <WorkflowStage workflow={record} />, + }, + { + title: i18n.t('workflow.col_project'), + dataIndex: 'project_id', + width: 150, + render: (project_id: number) => <WhichProject id={project_id} />, + }, + { + title: i18n.t('workflow.col_date'), + dataIndex: 'created_at', + width: 200, + render: (date: number) => <div>{formatTimestamp(date)}</div>, + }, + { + title: i18n.t('workflow.col_favorite'), + dataIndex: 'favour', + defaultFilteredValue: options.defaultFavourFilteredValue, + width: 120, + filters: [ + { + text: i18n.t('term_favored'), + value: '1', + }, + { + text: i18n.t('term_unfavored'), + value: '0', + }, + ], + filterMultiple: false, + render(favorite: number, record: Workflow) { + return ( + <Switch + size="small" + onChange={() => options.onFavourSwitchChange?.(record)} + checked={Boolean(favorite)} + /> + ); + }, + }, + ]; + + if (options.withoutFavour) { + ret.splice(ret.length - 1, 1); + } + + if (!options.withoutActions) { + ret.push({ + title: i18n.t('workflow.col_actions'), + dataIndex: 'operation', + width: 400, + render: (_: any, record: Workflow) => ( + <WorkflowActions onSuccess={options.onSuccess} workflow={record} type="text" size="mini" /> + ), + }); + } + + return ret; +}; + +type QueryParams = { + project?: string; + keyword?: string; + uuid?: string; + states?: WorkflowStateFilterParam[]; + page?: number; + favour?: '0' | '1'; + system?: string; +}; + +type TWorkflowListRes = { + data: Workflow[]; +}; + +type ListProps = { + type: WorkflowType; +}; + +export const WORKFLOW_LIST_QUERY_KEY = 'fetchWorkflowList'; + +const List: FC<ListProps> = ({ type }) => { + const { t } = useTranslation(); + const [form] = Form.useForm<QueryParams>(); + const history = useHistory(); + const [urlState, setUrlState] = useUrlState({ + page: 1, + pageSize: 10, + keyword: '', + uuid: '', + states: [], + filter: initFilter(), + favour: undefined, + }); + const projectId = useGetCurrentProjectId(); + + const { urlState: pageInfoState, paginationProps } = useTablePaginationWithUrlState(); + + const initFilterParams = expression2Filter(urlState.filter); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [filterParams, setFilterParams] = useState<QueryParams>({ + states: initFilterParams.states || [], + keyword: initFilterParams.keyword || '', + uuid: initFilterParams.uuid || '', + system: initFilterParams.system || false, + }); + + const queryClient = useQueryClient(); + const listQueryKey = [ + WORKFLOW_LIST_QUERY_KEY, + urlState.keyword, + urlState.uuid, + urlState.states, + urlState.favour, + projectId, + pageInfoState.page, + pageInfoState.pageSize, + ]; + const listQuery = useQuery( + listQueryKey, + () => { + if (!projectId) { + Message.info(t('select_project_notice')); + } + return fetchWorkflowList({ + ...urlState, + project: projectId, + page: pageInfoState.page, + pageSize: pageInfoState.pageSize, + }); + }, + { + retry: 2, + refetchInterval: TIME_INTERVAL.LIST, + keepPreviousData: true, + }, + ); + const { isLoading, isError, data: res, error, refetch } = listQuery; + const favourMutation = useMutation( + async (workflow: Workflow) => { + await favourTheWorkFlow(projectId ?? 0, workflow.id, !Boolean(workflow.favour)); + }, + { + onMutate(workflow) { + // cancel ongoing list queries + queryClient.cancelQueries(listQueryKey); + const oldData = queryClient.getQueryData<TWorkflowListRes>(listQueryKey); + const operatingIndex = oldData?.data.findIndex((item) => item.id === workflow.id); + + if (operatingIndex === -1 || operatingIndex === undefined) { + return oldData; + } + + // temporarily update list. + queryClient.setQueryData<TWorkflowListRes>(listQueryKey, (oldData) => { + const copied = oldData?.data ? [...oldData.data] : []; + copied.splice(operatingIndex, 1, { + ...workflow, + favour: !workflow.favour, + }); + + return { + data: copied, + }; + }); + + return oldData; + }, + onSuccess() { + refetch(); + }, + onError(_: any, __: any, oldData) { + // if failed, reverse list data to the old one. + queryClient.setQueryData(listQueryKey, oldData); + }, + }, + ); + + if (isError && error) { + Message.error((error as Error).message); + } + + const workflowListShow = useMemo(() => { + const workflowList = res?.data ?? []; + return workflowList; + }, [res]); + + return ( + <> + <Row justify="space-between" align="center"> + <Col span={4}> + {type === WorkflowType.MY ? ( + <Button className={'custom-operation-button'} type="primary" onClick={goCreate}> + {t('workflow.create_workflow')} + </Button> + ) : ( + <></> + )} + </Col> + <Col span={20}> + <Form + initialValues={{ ...urlState }} + layout="inline" + form={form} + onChange={onParamsChange} + style={{ justifyContent: 'flex-end' }} + > + <Form.Item field="states" className={styled.workflow_list_form_item}> + <MultiSelect + isHideIndex={true} + placeholder="任务状态" + optionList={workflowStateOptionList || []} + onChange={form.submit} + allowClear + style={{ minWidth: '227px', maxWidth: '500px', fontSize: '12px' }} + /> + </Form.Item> + <Form.Item field="uuid" className={styled.workflow_list_form_item}> + <Input.Search + className={'custom-input'} + placeholder={t('workflow.placeholder_uuid_searchbox')} + onSearch={form.submit} + allowClear + /> + </Form.Item> + <Form.Item field="keyword" className={styled.workflow_list_form_item}> + <Input.Search + className={'custom-input'} + placeholder={t('workflow.placeholder_name_searchbox')} + onSearch={form.submit} + allowClear + /> + </Form.Item> + </Form> + </Col> + </Row> + <Spin loading={isLoading}> + <div className={styled.workflow_list_container}> + <Table + className="custom-table custom-table-left-side-filter" + data={workflowListShow} + columns={getWorkflowTableColumns({ + onSuccess, + onFavourSwitchChange: (workflow: Workflow) => favourMutation.mutate(workflow), + defaultFavourFilteredValue: urlState.favour ? [urlState.favour] : [], + })} + onChange={(_, sorter, filter, extra) => { + if (extra.action === 'filter') { + setUrlState({ + page: 1, + favour: filter.favour?.[0] ?? undefined, + }); + } + }} + scroll={{ x: '100%' }} + rowKey="id" + pagination={{ + ...paginationProps, + total: listQuery.data?.page_meta?.total_items ?? undefined, + }} + style={{ minWidth: '800px' }} + /> + </div> + </Spin> + </> + ); + function onParamsChange(values: QueryParams) { + // Set urlState will auto-trigger list query + setUrlState({ ...values, page: 1 }); + } + function onSuccess() { + refetch(); + } + + function goCreate() { + history.push('/workflow-center/workflows/initiate/basic'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function constructFilterArray(value: QueryParams) { + const expressionNodes = []; + expressionNodes.push({ + field: 'system', + op: FilterOp.EQUAL, + bool_value: type === WorkflowType.SYSTEM, + }); + + const serialization = constructExpressionTree(expressionNodes); + setFilterParams({ + system: value.system, + }); + setUrlState((prevState) => ({ + ...prevState, + filter: serialization, + page: 1, + })); + } + + function initFilter() { + const expressionNodes = []; + expressionNodes.push({ + field: 'system', + op: FilterOp.EQUAL, + bool_value: type === WorkflowType.SYSTEM, + }); + return constructExpressionTree(expressionNodes); + } +}; + +export default List; diff --git a/web_console_v2/client/src/views/Workflows/WorkflowList/index.module.less b/web_console_v2/client/src/views/Workflows/WorkflowList/index.module.less new file mode 100644 index 000000000..8e42ae6f5 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/WorkflowList/index.module.less @@ -0,0 +1,9 @@ +.workflow_tabs { + .arco-tabs-header-nav-horizontal { + padding-left: 4px; + } + .arco-tabs-header-title { + margin-top: 4px; + margin-bottom: 4px; + } +} diff --git a/web_console_v2/client/src/views/Workflows/shared.test.ts b/web_console_v2/client/src/views/Workflows/shared.test.ts new file mode 100644 index 000000000..dd9af5def --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/shared.test.ts @@ -0,0 +1,156 @@ +import { hydrate } from './shared'; + +import { Variable } from 'typings/variable'; + +import { + nameInput, + codeEditor, + featureSelect, + envsInput, + forceObjectInput, + forceListInput, +} from 'services/mocks/v2/variables/examples'; + +describe('hydrate', () => { + const testVariables: Variable[] = [ + nameInput, + codeEditor, + featureSelect, + envsInput, + forceObjectInput, + forceListInput, + ]; + it('normal', () => { + const testVariablesAllWithNewValue: Variable[] = [ + { ...nameInput, value: 'new value' }, + { ...codeEditor, value: { 'main.js': 'var a = 2;' } }, + { ...featureSelect, value: { a: 2 } }, + { + ...envsInput, + value: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + }, + { ...forceObjectInput, value: { a: 2 } }, + { ...forceListInput, value: [{ a: 2 }] }, + ]; + + const testVariablesSomeWithNewValue: Variable[] = [ + nameInput, + codeEditor, + { ...featureSelect, value: { a: 2 } }, + { + ...envsInput, + value: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + }, + { ...forceObjectInput, value: { a: 2 } }, + { ...forceListInput, value: [{ a: 2 }] }, + ]; + + expect(hydrate(testVariables, undefined)).toEqual([]); + expect(hydrate(testVariables, {})).toEqual(testVariables); + expect( + hydrate(testVariables, { + [nameInput.name]: 'new value', + [codeEditor.name]: { 'main.js': 'var a = 2;' }, + [featureSelect.name]: { a: 2 }, + [envsInput.name]: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + [forceObjectInput.name]: { a: 2 }, + [forceListInput.name]: [{ a: 2 }], + }), + ).toEqual(testVariablesAllWithNewValue); + expect( + hydrate(testVariables, { + [featureSelect.name]: { a: 2 }, + [envsInput.name]: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + [forceObjectInput.name]: { a: 2 }, + [forceListInput.name]: [{ a: 2 }], + }), + ).toEqual(testVariablesSomeWithNewValue); + }); + it('isStringifyVariableValue', () => { + expect(testVariables.every((item) => typeof item.value === 'string')).toBeFalsy(); + const finalVariables = hydrate( + testVariables, + { + [featureSelect.name]: { a: 2 }, + [envsInput.name]: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + [forceObjectInput.name]: { a: 2 }, + [forceListInput.name]: [{ a: 2 }], + }, + { + isStringifyVariableValue: true, + }, + ); + expect(finalVariables.every((item) => typeof item.value === 'string')).toBeTruthy(); + }); + it('isProcessVariableTypedValue', () => { + const finalVariables = hydrate( + testVariables, + { + [nameInput.name]: 'namename', + [featureSelect.name]: { a: 2 }, + [envsInput.name]: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + [forceObjectInput.name]: { a: 2 }, + [forceListInput.name]: [{ a: 2 }], + }, + { + isProcessVariableTypedValue: true, + }, + ); + + expect(finalVariables).toEqual([ + { ...nameInput, typed_value: 'namename', value: 'namename' }, + codeEditor, + { ...featureSelect, typed_value: { a: 2 }, value: { a: 2 } }, + { + ...envsInput, + typed_value: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + value: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + }, + { ...forceObjectInput, typed_value: { a: 2 }, value: { a: 2 } }, + { ...forceListInput, typed_value: [{ a: 2 }], value: [{ a: 2 }] }, + ]); + }); + it('isStringifyVariableWidgetSchema', () => { + expect(testVariables.every((item) => typeof item.widget_schema === 'string')).toBeFalsy(); + const finalVariables = hydrate( + testVariables, + { + [featureSelect.name]: { a: 2 }, + [envsInput.name]: [ + { name: 'nn1', value: 'nv1' }, + { name: 'nn2', value: 'nv2' }, + ], + [forceObjectInput.name]: { a: 2 }, + [forceListInput.name]: [{ a: 2 }], + }, + { + isStringifyVariableWidgetSchema: true, + }, + ); + expect(finalVariables.every((item) => typeof item.widget_schema === 'string')).toBeTruthy(); + }); +}); diff --git a/web_console_v2/client/src/views/Workflows/shared.ts b/web_console_v2/client/src/views/Workflows/shared.ts new file mode 100644 index 000000000..f89481a31 --- /dev/null +++ b/web_console_v2/client/src/views/Workflows/shared.ts @@ -0,0 +1,136 @@ +import { StateTypes } from 'components/StateIndicator'; +import i18n from 'i18n'; +import { Pod, PodState } from 'typings/job'; +import { cloneDeep } from 'lodash-es'; +import { processVariableTypedValue, stringifyVariableValue } from 'shared/formSchema'; +import { DataJobVariable } from 'typings/dataset'; +import { Variable } from 'typings/variable'; +import { TableColumnProps } from '@arco-design/web-react'; + +type TableFilterConfig = Pick<TableColumnProps, 'filters' | 'onFilter'>; + +/** + * @param variableShells Variable defintions without any user input value + * @param formValues User inputs + */ +export function hydrate( + variableShells: Array<Variable | DataJobVariable>, + formValues?: Record<string, any>, + options: { + isStringifyVariableValue?: boolean; + isProcessVariableTypedValue?: boolean; + isStringifyVariableWidgetSchema?: boolean; + } = { + isStringifyVariableValue: false, + isProcessVariableTypedValue: false, + isStringifyVariableWidgetSchema: false, + }, +): Array<Variable | DataJobVariable> { + if (!formValues) return []; + return variableShells.map((item) => { + const newVariable = cloneDeep({ ...item, value: formValues[item.name] ?? item.value }); + + if (options?.isStringifyVariableValue) { + stringifyVariableValue(newVariable as Variable); + } + if (options?.isProcessVariableTypedValue) { + processVariableTypedValue(newVariable as Variable); + } + if (options?.isStringifyVariableWidgetSchema) { + if (typeof newVariable.widget_schema === 'object') { + newVariable.widget_schema = JSON.stringify(newVariable.widget_schema); + } + } + + return newVariable; + }); +} + +export const podStateType: { [key: string]: StateTypes } = { + [PodState.SUCCEEDED]: 'success', + [PodState.RUNNING]: 'processing', + [PodState.FAILED]: 'error', + [PodState.PENDING]: 'warning', + [PodState.UNKNOWN]: 'default', + [PodState.FAILED_AND_FREED]: 'warning', + [PodState.SUCCEEDED_AND_FREED]: 'success', + // Deprecated state values + [PodState.SUCCEEDED__deprecated]: 'success', + [PodState.RUNNING__deprecated]: 'processing', + [PodState.FAILED__deprecated]: 'error', + [PodState.PENDING__deprecated]: 'warning', + [PodState.UNKNOWN__deprecated]: 'default', + [PodState.SUCCEEDED_AND_FREED__deprecated]: 'warning', + [PodState.FAILED_AND_FREED__deprecated]: 'success', +}; +export const podStateText: { [key: string]: string } = { + [PodState.SUCCEEDED]: i18n.t('workflow.job_node_success'), + [PodState.RUNNING]: i18n.t('workflow.job_node_running'), + [PodState.FAILED]: i18n.t('workflow.job_node_failed'), + [PodState.PENDING]: i18n.t('workflow.job_node_waiting'), + [PodState.UNKNOWN]: i18n.t('workflow.pod_unknown'), + [PodState.FAILED_AND_FREED]: i18n.t('workflow.pod_failed_cleared'), + [PodState.SUCCEEDED_AND_FREED]: i18n.t('workflow.pod_success_cleared'), + // Deprecated state values + [PodState.SUCCEEDED__deprecated]: i18n.t('workflow.job_node_success'), + [PodState.RUNNING__deprecated]: i18n.t('workflow.job_node_running'), + [PodState.FAILED__deprecated]: i18n.t('workflow.job_node_failed'), + [PodState.PENDING__deprecated]: i18n.t('workflow.job_node_waiting'), + [PodState.UNKNOWN__deprecated]: i18n.t('workflow.pod_unknown'), + [PodState.SUCCEEDED_AND_FREED__deprecated]: i18n.t('workflow.pod_failed_cleared'), + [PodState.FAILED_AND_FREED__deprecated]: i18n.t('workflow.pod_success_cleared'), +}; + +/* istanbul ignore next */ +export function getPodState(pod: Pod): { type: StateTypes; text: string; tip: string } { + let tip: string = ''; + if ([PodState.FAILED, PodState.PENDING].includes(pod.state)) { + tip = pod.message || ''; + } + return { + text: podStateText[pod.state], + type: podStateType[pod.state], + tip, + }; +} + +export const podStateOptions = [ + { + label: podStateText[PodState.SUCCEEDED], + value: PodState.SUCCEEDED, + }, + { + label: podStateText[PodState.RUNNING], + value: PodState.RUNNING, + }, + { + label: podStateText[PodState.FAILED], + value: PodState.FAILED, + }, + { + label: podStateText[PodState.PENDING], + value: PodState.PENDING, + }, + { + label: podStateText[PodState.UNKNOWN], + value: PodState.UNKNOWN, + }, + { + label: podStateText[PodState.FAILED_AND_FREED], + value: PodState.FAILED_AND_FREED, + }, + { + label: podStateText[PodState.SUCCEEDED_AND_FREED], + value: PodState.SUCCEEDED_AND_FREED, + }, +]; + +export const podStateFilters: TableFilterConfig = { + filters: podStateOptions.map((item) => ({ + text: item.label, + value: item.value, + })), + onFilter: (value: string, record: Pod) => { + return value === record.state; + }, +}; diff --git a/web_console_v2/client/src/views/index.module.less b/web_console_v2/client/src/views/index.module.less new file mode 100644 index 000000000..318c52a6f --- /dev/null +++ b/web_console_v2/client/src/views/index.module.less @@ -0,0 +1,6 @@ +.route_spin{ + height: 100%; + display: flex !important; + justify-content: center; + align-items: center; +} diff --git a/web_console_v2/inspection/BUILD.bazel b/web_console_v2/inspection/BUILD.bazel new file mode 100644 index 000000000..9bb322c5a --- /dev/null +++ b/web_console_v2/inspection/BUILD.bazel @@ -0,0 +1,67 @@ +load("@rules_python//python:defs.bzl", "py_library") + +package(default_visibility = [":data_inspection_package"]) + +package_group( + name = "data_inspection_package", + packages = ["//web_console_v2/inspection/..."], +) + +py_library( + name = "error_code_lib", + srcs = [ + "error_code.py", + ], + imports = ["."], + visibility = ["//visibility:public"], + deps = [ + ":envs_lib", + "@common_fsspec//:pkg", + ], +) + +py_library( + name = "dataset_directory_lib", + srcs = ["dataset_directory.py"], + imports = ["."], + visibility = ["//visibility:public"], +) + +py_library( + name = "envs_lib", + srcs = ["envs.py"], + imports = ["."], + visibility = ["//web_console_v2/inspection:data_inspection_package"], +) + +py_test( + name = "error_code_test", + size = "small", + srcs = [ + "error_code_test.py", + ], + visibility = ["//web_console_v2/inspection:data_inspection_package"], + deps = [ + ":error_code_lib", + "@common_fsspec//:pkg", + ], +) + +py_test( + name = "dataset_directory_test", + size = "small", + srcs = [ + "dataset_directory_test.py", + ], + visibility = ["//web_console_v2/inspection:data_inspection_package"], + deps = [ + ":dataset_directory_lib", + ], +) + +# TODO(liuhehan): bazelize this part after we copy two tfrecords jar and hist jars to docker +filegroup( + name = "inspection_srcs", + srcs = glob(["**"]), + visibility = ["//visibility:public"], +) diff --git a/web_console_v2/inspection/__init__.py b/web_console_v2/inspection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/web_console_v2/inspection/analyzer/analyzer_task.py b/web_console_v2/inspection/analyzer/analyzer_task.py new file mode 100644 index 000000000..4daf3b7c3 --- /dev/null +++ b/web_console_v2/inspection/analyzer/analyzer_task.py @@ -0,0 +1,229 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import json +import logging +from typing import Optional +import fsspec +import cv2 +import numpy as np +from numpy import array +from copy import deepcopy + +from pyspark import SparkContext +from pyspark.sql import SparkSession +from pyspark.sql import functions as spark_func +from pyspark.sql.dataframe import DataFrame +from pyspark.sql.column import _to_java_column, _to_seq, Column +from dataset_directory import DatasetDirectory + +from util import load_tfrecords, is_file_matched +from error_code import AreaCode, ErrorType, JobException + +_DEFAULT_BUCKETS_NUM = 10 +_DEFAULT_SAMPLES_NUM = 20 +_MAX_SIZE = (256, 256) +_THUMBNAIL_EXTENSION = '.png' +_METRICS_UNSUPPORT_COLUMN_TYPE = [ + 'binary', +] +_HIST_UNSUPPORT_COLUMN_TYPE = ['string', 'binary'] + + +def _is_metrics_support_type(column_type: str): + return column_type not in _METRICS_UNSUPPORT_COLUMN_TYPE and not column_type.startswith('array') + + +def _is_hist_support_type(column_type: str): + return column_type not in _HIST_UNSUPPORT_COLUMN_TYPE and not column_type.startswith('array') + + +def _hist_func(feat_col: Column, min_num: float, max_num: float, bins_num: int, interval: float, sc: SparkContext): + hist = sc._jvm.com.bytedance.aml.enterprise.sparkudaf.Hist.getFunc() # pylint: disable=protected-access + return Column( + hist.apply( + _to_seq(sc, [ + feat_col, + spark_func.lit(min_num), + spark_func.lit(max_num), + spark_func.lit(bins_num), + spark_func.lit(interval) + ], _to_java_column))) + + +def _decode_binary_to_array(data: bytearray, h: int, w: int, c: int) -> array: + return np.reshape(data, (h, w, c)) + + +def _get_thumbnail(img: array, height: int, width: int) -> array: + size = (min(height, _MAX_SIZE[0]), min(width, _MAX_SIZE[1])) + return cv2.resize(img, size, interpolation=cv2.INTER_AREA) + + +# TODO(liuhehan): seperate this analyzer task to meta_task and preview_task +class AnalyzerTask(object): + + def _extract_feature_metrics(self, df: DataFrame, dtypes_dict: dict) -> dict: + df_missing = df.select(*(spark_func.sum(spark_func.col(c).isNull().cast('int')).alias(c) + for c in df.columns + if _is_metrics_support_type(dtypes_dict[c]))).withColumn( + 'summary', spark_func.lit('missing_count')) + df_stats = df.describe().unionByName(df_missing) + df_stats = df_stats.toPandas().set_index('summary').transpose() + return df_stats.to_dict(orient='index') + + def _extract_metadata(self, df: DataFrame, is_image: bool) -> dict: + """ + meta = { + label_count: [dict] + count: int + dtypes: [{'key': feature_name, 'value': feature_type},..], + sample: [row], + } + """ + meta = {} + # dtypes + logging.info('### loading dtypes...') + dtypes = [] + for d in df.dtypes: + k, v = d # (feature, type) + dtypes.append({'key': k, 'value': v}) + # TODO(wangzeju): refactor the key names + meta['dtypes'] = deepcopy(dtypes) + # remove binary in image metadata, add label-count + if is_image: + meta['dtypes'] = [item for item in meta['dtypes'] if item['key'] != 'data'] + # TODO(wangzeju): hard code to count for each category + # need to support more data/label formats on more columns. + if 'label' in df.columns: + label_count_rows = df.groupBy('label').count().collect() + label_count = [row.asDict() for row in label_count_rows] + meta['label_count'] = label_count + # sample count + logging.info('### loading count...') + meta['count'] = df.count() + # sample and thumbnail + logging.info('### loading sample...') + rows = df.head(_DEFAULT_SAMPLES_NUM) + samples = [] + for row in rows: + sample = [row[col_map['key']] for col_map in meta['dtypes']] + samples.append(sample) + meta['sample'] = samples + return meta + + def _extract_thumbnail(self, df: DataFrame, thumbnail_path: str): + samples = df.head(_DEFAULT_SAMPLES_NUM) + for row in samples: + item = row.asDict() + h, w, c = item['height'], item['width'], item['nChannels'] + file_name, raw_data = item['file_name'], item['data'] + img_array = _decode_binary_to_array(raw_data, h, w, c) + logging.info(f'### process with {file_name}') + img = _get_thumbnail(img_array, h, w) + logging.info(f'### {file_name} shape is:{img.shape}') + # get thumbnail file_name in _THUMBNAIL_EXTENSION + thumbnail_file_name = file_name.split('.')[0] + _THUMBNAIL_EXTENSION + img_path = os.path.join(thumbnail_path, thumbnail_file_name) + success, encoded_image = cv2.imencode(_THUMBNAIL_EXTENSION, img) + bytes_content = encoded_image.tobytes() + logging.info(f'### will write bytes_content to {img_path}') + with fsspec.open(img_path, mode='wb') as f: + f.write(bytes_content) + + def _extract_feature_hist(self, spark: SparkSession, df: DataFrame, dtypes_dict: dict, buckets_num: int) -> dict: + # feature histogram + logging.info('### loading hist...') + hist = {} + feat_col_list = [(col_idx, col_name) + for col_idx, col_name in enumerate(df.columns) + if _is_hist_support_type(dtypes_dict[col_name])] + if feat_col_list: + # When there is a NaN value, spark's max function will get a NaN result + # so it needs to be filled with the default value of zero + filled_df = df.na.fill(0) + min_col_list = [ + spark_func.min(spark_func.col(col_name)).alias(f'{col_name}-min') + for (col_idx, col_name) in feat_col_list + ] + max_col_list = [ + spark_func.max(spark_func.col(col_name)).alias(f'{col_name}-max') + for (col_idx, col_name) in feat_col_list + ] + minmax_df = filled_df.select(*(min_col_list + max_col_list)) + minmax_row = minmax_df.collect()[0] + hist_args_map = {} + for col_idx, col_name in feat_col_list: + min_num = minmax_row[f'{col_name}-min'] + max_num = minmax_row[f'{col_name}-max'] + hist_args = (spark_func.col(col_name), min_num, max_num, buckets_num, (max_num - min_num) / buckets_num, + spark.sparkContext) + hist_args_map[col_name] = hist_args + logging.info(f'### will get the hist statistics for feat cols: {feat_col_list}') + hist_result = df.select( + *[_hist_func(*hist_args_map[col_name]).alias(col_name) for (_, col_name) in feat_col_list]).collect() + for (_, col_name), col_result in zip(feat_col_list, hist_result[0]): + hist[col_name] = {'x': col_result['bins'], 'y': col_result['counts']} + return hist + + def run(self, spark: SparkSession, dataset_path: str, wildcard: str, is_image: bool, batch_name: str, + buckets_num: Optional[int], thumbnail_path: Optional[str]): + """extract metadata, features' metrics, hists and thumbnail for image format + + Args: + spark: spark session + dataset_path: path of dataset. + wildcard: the wildcard to match all tfrecords + is_image: whether it is an image type + batch_name: name of target batch which need analyze + buckets_num: the number of bucket to extract feature hist + thumbnail_path: dir path to save the thumbnails + + Raises: + JobException: read data by pyspark failed + """ + if buckets_num is None: + buckets_num = _DEFAULT_BUCKETS_NUM + dataset_directory = DatasetDirectory(dataset_path=dataset_path) + files = dataset_directory.batch_path(batch_name=batch_name) + meta_path = dataset_directory.batch_meta_file(batch_name=batch_name) + if not is_file_matched(files): + # this is a hack to allow empty intersection dataset + # no file matched, just skip analyzer + logging.warning(f'input_dataset_path {files} matches 0 file, skip analyzer task') + return + # load data + try: + df = load_tfrecords(spark, files, dataset_path) + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.ANALYZER, ErrorType.DATA_LOAD_ERROR, + f'failed to read input data, err: {str(e)}') from e + if df.count() == 0: + # this is a hack to allow empty intersection dataset + # all files are empty, just skip analyzer + logging.warning(f'get 0 data item in {files}, skip analyzer task') + return + dtypes_dict = dict(df.dtypes) + # extract pipeline + meta = self._extract_metadata(df, is_image) + meta['features'] = self._extract_feature_metrics(df, dtypes_dict) + if is_image: + self._extract_thumbnail(df, thumbnail_path) + meta['hist'] = self._extract_feature_hist(spark, df, dtypes_dict, buckets_num) + # save metadata to file + logging.info(f'### writing meta, path is {meta_path}') + with fsspec.open(meta_path, mode='w') as f: + f.write(json.dumps(meta)) diff --git a/web_console_v2/inspection/analyzer/analyzer_task_test.py b/web_console_v2/inspection/analyzer/analyzer_task_test.py new file mode 100644 index 000000000..348ff4797 --- /dev/null +++ b/web_console_v2/inspection/analyzer/analyzer_task_test.py @@ -0,0 +1,533 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# pylint: disable=protected-access +import unittest +import json +import os +from unittest import mock +import fsspec + +from pyspark.sql.types import BinaryType, IntegerType, StructField, StructType, StringType + +from testing.spark_test_case import PySparkTestCase +from analyzer.analyzer_task import AnalyzerTask +from dataset_directory import DatasetDirectory + + +class AnalyzerTaskTest(PySparkTestCase): + + def setUp(self) -> None: + super().setUp() + self._data_path = os.path.join(self.tmp_dataset_path, 'test_dataset') + self._dataset_directory = DatasetDirectory(self._data_path) + self._batch_name = 'test_batch' + self._filesystem = fsspec.filesystem('file') + self.maxDiff = None + self.analyzer_task = AnalyzerTask() + + def tearDown(self) -> None: + self._clear_up() + return super().tearDown() + + def _generate_dataframe(self): + data = [ + (1, b'01001100111', 256, 256, 3, 'cat', 'image_1'), + (2, b'01001100111', 245, 246, None, 'dog', 'image_2'), + (3, b'01001100111', None, 314, 3, 'cat', 'image_3'), + ] + schema = StructType([ + StructField('raw_id', IntegerType(), False), + StructField('data', BinaryType(), False), + StructField('rows', IntegerType(), True), + StructField('cols', IntegerType(), False), + StructField('channel', IntegerType(), True), + StructField('label', StringType(), False), + StructField('file_name', StringType(), False), + ]) + return self.spark.createDataFrame(data=data, schema=schema) + + def _generate_all_string_and_binary_dataframe(self): + data = [ + ('1', b'01001100111', 'cat'), + ('2', b'01001100111', 'dog'), + ('3', b'01001100111', 'cat'), + ] + schema = StructType([ + StructField('raw_id', StringType(), False), + StructField('image', BinaryType(), False), + StructField('label', StringType(), False), + ]) + return self.spark.createDataFrame(data=data, schema=schema) + + def _generate_fake_image_dataframe(self): + data = [ + (1, b'010011001110', 2, 2, 3, 'cat', 'image_1.jpg'), + (2, b'010011', 1, 2, 3, 'dog', 'image_2.jpg'), + (3, b'010011001110100111', 3, 2, 3, 'cat', 'image_3.jpg'), + ] + schema = StructType([ + StructField('raw_id', IntegerType(), False), + StructField('data', BinaryType(), False), + StructField('height', IntegerType(), False), + StructField('width', IntegerType(), False), + StructField('nChannels', IntegerType(), False), + StructField('label', StringType(), False), + StructField('file_name', StringType(), False), + ]) + return self.spark.createDataFrame(data=data, schema=schema) + + def _generate_tfrecords_image(self): + df = self._generate_fake_image_dataframe() + df.repartition(3).write.format('tfrecords').option('compression', 'none').save( + self._dataset_directory.batch_path(self._batch_name), mode='overwrite') + with fsspec.open(self._dataset_directory.schema_file, mode='w') as f: + json.dump(df.schema.jsonValue(), f) + + def _generate_tfrecords_tabular(self): + data = [ + (1, 2, 2, 3, 'cat', 'image_1.jpg'), + (2, 1, 2, 3, 'dog', 'image_2.jpg'), + (3, 3, 2, 3, 'cat', 'image_3.jpg'), + ] + schema = StructType([ + StructField('raw_id', IntegerType(), False), + StructField('height', IntegerType(), False), + StructField('width', IntegerType(), False), + StructField('nChannels', IntegerType(), False), + StructField('label', StringType(), False), + StructField('file_name', StringType(), False), + ]) + df = self.spark.createDataFrame(data=data, schema=schema) + df.repartition(3).write.format('tfrecords').option('compression', 'none').save( + self._dataset_directory.batch_path(self._batch_name), mode='overwrite') + with fsspec.open(self._dataset_directory.schema_file, mode='w') as f: + json.dump(df.schema.jsonValue(), f) + + def _clear_up(self): + if self._filesystem.isdir(self.tmp_dataset_path): + self._filesystem.rm(self.tmp_dataset_path, recursive=True) + + def test_analyzer_image(self): + self._generate_tfrecords_image() + self.analyzer_task.run(spark=self.spark, + dataset_path=self._data_path, + wildcard='batch/test_batch/**', + is_image=True, + batch_name=self._batch_name, + buckets_num=10, + thumbnail_path=self._dataset_directory.thumbnails_path(self._batch_name)) + expected_meta = { + 'dtypes': [{ + 'key': 'raw_id', + 'value': 'int' + }, { + 'key': 'height', + 'value': 'int' + }, { + 'key': 'width', + 'value': 'int' + }, { + 'key': 'nChannels', + 'value': 'int' + }, { + 'key': 'label', + 'value': 'string' + }, { + 'key': 'file_name', + 'value': 'string' + }], + 'label_count': mock.ANY, + 'count': 3, + 'sample': mock.ANY, + 'features': { + 'raw_id': { + 'count': '3', + 'mean': '2.0', + 'stddev': '1.0', + 'min': '1', + 'max': '3', + 'missing_count': '0' + }, + 'height': { + 'count': '3', + 'mean': '2.0', + 'stddev': '1.0', + 'min': '1', + 'max': '3', + 'missing_count': '0' + }, + 'width': { + 'count': '3', + 'mean': '2.0', + 'stddev': '0.0', + 'min': '2', + 'max': '2', + 'missing_count': '0' + }, + 'nChannels': { + 'count': '3', + 'mean': '3.0', + 'stddev': '0.0', + 'min': '3', + 'max': '3', + 'missing_count': '0' + }, + 'label': { + 'count': '3', + 'mean': None, + 'stddev': None, + 'min': 'cat', + 'max': 'dog', + 'missing_count': '0' + }, + 'file_name': { + 'count': '3', + 'mean': None, + 'stddev': None, + 'min': 'image_1.jpg', + 'max': 'image_3.jpg', + 'missing_count': '0' + } + }, + 'hist': { + 'raw_id': { + 'x': [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4000000000000004, 2.6, 2.8, 3.0], + 'y': [1, 0, 0, 0, 0, 1, 0, 0, 0, 1] + }, + 'height': { + 'x': [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4000000000000004, 2.6, 2.8, 3.0], + 'y': [1, 0, 0, 0, 0, 1, 0, 0, 0, 1] + }, + 'width': { + 'x': [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0], + 'y': [0, 0, 0, 0, 0, 0, 0, 0, 0, 3] + }, + 'nChannels': { + 'x': [3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0], + 'y': [0, 0, 0, 0, 0, 0, 0, 0, 0, 3] + } + } + } + expected_sample = [[1, 2, 2, 3, 'cat', 'image_1.jpg'], [2, 1, 2, 3, 'dog', 'image_2.jpg'], + [3, 3, 2, 3, 'cat', 'image_3.jpg']] + expected_label_count = [{'label': 'dog', 'count': 1}, {'label': 'cat', 'count': 2}] + expected_file_names = ['image_1.png', 'image_2.png', 'image_3.png'] + files = self._filesystem.ls(self._dataset_directory.thumbnails_path(self._batch_name)) + file_names = [f.split('/')[-1] for f in files] + self.assertCountEqual(file_names, expected_file_names) + batch_level_meta_path = self._dataset_directory.batch_meta_file(batch_name=self._batch_name) + self.assertTrue(self._filesystem.isfile(batch_level_meta_path)) + with fsspec.open(batch_level_meta_path, 'r') as f: + batch_level_meta = json.load(f) + self.assertEqual(batch_level_meta, expected_meta) + self.assertCountEqual(batch_level_meta.get('sample'), expected_sample) + self.assertCountEqual(batch_level_meta.get('label_count'), expected_label_count) + + def test_analyzer_tabular(self): + self._generate_tfrecords_tabular() + self.analyzer_task.run(spark=self.spark, + dataset_path=self._data_path, + wildcard='batch/test_batch/**', + is_image=False, + batch_name=self._batch_name, + buckets_num=10, + thumbnail_path=self._dataset_directory.thumbnails_path(self._batch_name)) + expected_meta = { + 'dtypes': [{ + 'key': 'raw_id', + 'value': 'int' + }, { + 'key': 'height', + 'value': 'int' + }, { + 'key': 'width', + 'value': 'int' + }, { + 'key': 'nChannels', + 'value': 'int' + }, { + 'key': 'label', + 'value': 'string' + }, { + 'key': 'file_name', + 'value': 'string' + }], + 'count': 3, + 'sample': mock.ANY, + 'features': { + 'raw_id': { + 'count': '3', + 'mean': '2.0', + 'stddev': '1.0', + 'min': '1', + 'max': '3', + 'missing_count': '0' + }, + 'height': { + 'count': '3', + 'mean': '2.0', + 'stddev': '1.0', + 'min': '1', + 'max': '3', + 'missing_count': '0' + }, + 'width': { + 'count': '3', + 'mean': '2.0', + 'stddev': '0.0', + 'min': '2', + 'max': '2', + 'missing_count': '0' + }, + 'nChannels': { + 'count': '3', + 'mean': '3.0', + 'stddev': '0.0', + 'min': '3', + 'max': '3', + 'missing_count': '0' + }, + 'label': { + 'count': '3', + 'mean': None, + 'stddev': None, + 'min': 'cat', + 'max': 'dog', + 'missing_count': '0' + }, + 'file_name': { + 'count': '3', + 'mean': None, + 'stddev': None, + 'min': 'image_1.jpg', + 'max': 'image_3.jpg', + 'missing_count': '0' + } + }, + 'hist': { + 'raw_id': { + 'x': [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4000000000000004, 2.6, 2.8, 3.0], + 'y': [1, 0, 0, 0, 0, 1, 0, 0, 0, 1] + }, + 'height': { + 'x': [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4000000000000004, 2.6, 2.8, 3.0], + 'y': [1, 0, 0, 0, 0, 1, 0, 0, 0, 1] + }, + 'width': { + 'x': [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0], + 'y': [0, 0, 0, 0, 0, 0, 0, 0, 0, 3] + }, + 'nChannels': { + 'x': [3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0], + 'y': [0, 0, 0, 0, 0, 0, 0, 0, 0, 3] + } + } + } + batch_level_meta_path = self._dataset_directory.batch_meta_file(batch_name=self._batch_name) + self.assertTrue(self._filesystem.isfile(batch_level_meta_path)) + with fsspec.open(batch_level_meta_path, 'r') as f: + batch_level_meta = json.load(f) + self.assertEqual(batch_level_meta, expected_meta) + expected_sample = [[1, 2, 2, 3, 'cat', 'image_1.jpg'], [2, 1, 2, 3, 'dog', 'image_2.jpg'], + [3, 3, 2, 3, 'cat', 'image_3.jpg']] + self.assertCountEqual(batch_level_meta.get('sample'), expected_sample) + + def test_extract_feature_hist(self): + df = self._generate_dataframe() + hist = self.analyzer_task._extract_feature_hist(self.spark, df, dict(df.dtypes), 10) + expect_hist = { + 'raw_id': { + 'x': [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4000000000000004, 2.6, 2.8, 3.0], + 'y': [1, 0, 0, 0, 0, 1, 0, 0, 0, 1] + }, + 'rows': { + 'x': [ + 0.0, 25.6, 51.2, 76.80000000000001, 102.4, 128.0, 153.60000000000002, 179.20000000000002, 204.8, + 230.4, 256.0 + ], + 'y': [1, 0, 0, 0, 0, 0, 0, 0, 0, 2] + }, + 'cols': { + 'x': [246.0, 252.8, 259.6, 266.4, 273.2, 280.0, 286.8, 293.6, 300.4, 307.2, 314.0], + 'y': [1, 1, 0, 0, 0, 0, 0, 0, 0, 1] + }, + 'channel': { + 'x': [ + 0.0, 0.3, 0.6, 0.8999999999999999, 1.2, 1.5, 1.7999999999999998, 2.1, 2.4, 2.6999999999999997, 3.0 + ], + 'y': [1, 0, 0, 0, 0, 0, 0, 0, 0, 2] + } + } + self.assertDictEqual(hist, expect_hist) + + # this case is special designed for a corner case: + # if the input data column are all string/binary, hist should be empty + df = self._generate_all_string_and_binary_dataframe() + hist = self.analyzer_task._extract_feature_hist(self.spark, df, dict(df.dtypes), 10) + self.assertDictEqual(hist, {}) + + def test_extract_thumbnail(self): + df = self._generate_fake_image_dataframe() + self.analyzer_task._extract_thumbnail(df, self._dataset_directory.thumbnails_path(self._batch_name)) + files = self._filesystem.ls(self._dataset_directory.thumbnails_path(self._batch_name)) + file_names = [f.split('/')[-1] for f in files] + golden_file_names = ['image_1.png', 'image_2.png', 'image_3.png'] + self.assertCountEqual(file_names, golden_file_names) + + def test_extract_feature_metrics(self): + df = self._generate_dataframe() + df_stats_dict = self.analyzer_task._extract_feature_metrics(df, dict(df.dtypes)) + expect_stats_dict = { + 'raw_id': { + 'count': '3', + 'mean': '2.0', + 'stddev': '1.0', + 'min': '1', + 'max': '3', + 'missing_count': '0' + }, + 'rows': { + 'count': '2', + 'mean': '250.5', + 'stddev': mock.ANY, + 'min': '245', + 'max': '256', + 'missing_count': '1' + }, + 'cols': { + 'count': '3', + 'mean': '272.0', + 'stddev': mock.ANY, + 'min': '246', + 'max': '314', + 'missing_count': '0' + }, + 'channel': { + 'count': '2', + 'mean': '3.0', + 'stddev': '0.0', + 'min': '3', + 'max': '3', + 'missing_count': '1' + }, + 'label': { + 'count': '3', + 'mean': None, + 'stddev': None, + 'min': 'cat', + 'max': 'dog', + 'missing_count': '0' + }, + 'file_name': { + 'count': '3', + 'mean': None, + 'stddev': None, + 'min': 'image_1', + 'max': 'image_3', + 'missing_count': '0' + } + } + self.assertDictEqual(df_stats_dict, expect_stats_dict) + + def test_extract_metadata_image(self): + df = self._generate_fake_image_dataframe() + meta = self.analyzer_task._extract_metadata(df, True) + expect_meta = { + 'dtypes': [{ + 'key': 'raw_id', + 'value': 'int' + }, { + 'key': 'height', + 'value': 'int' + }, { + 'key': 'width', + 'value': 'int' + }, { + 'key': 'nChannels', + 'value': 'int' + }, { + 'key': 'label', + 'value': 'string' + }, { + 'key': 'file_name', + 'value': 'string' + }], + 'label_count': [{ + 'label': 'dog', + 'count': 1 + }, { + 'label': 'cat', + 'count': 2 + }], + 'count': + 3, + 'sample': [[1, 2, 2, 3, 'cat', 'image_1.jpg'], [2, 1, 2, 3, 'dog', 'image_2.jpg'], + [3, 3, 2, 3, 'cat', 'image_3.jpg']] + } + self.assertEqual(meta, expect_meta) + + def test_extract_metadata_tabular(self): + df = self._generate_dataframe() + meta = self.analyzer_task._extract_metadata(df, False) + expect_meta = { + 'dtypes': [{ + 'key': 'raw_id', + 'value': 'int' + }, { + 'key': 'data', + 'value': 'binary' + }, { + 'key': 'rows', + 'value': 'int' + }, { + 'key': 'cols', + 'value': 'int' + }, { + 'key': 'channel', + 'value': 'int' + }, { + 'key': 'label', + 'value': 'string' + }, { + 'key': 'file_name', + 'value': 'string' + }], + 'count': + 3, + 'sample': [[1, bytearray(b'01001100111'), 256, 256, 3, 'cat', 'image_1'], + [2, bytearray(b'01001100111'), 245, 246, None, 'dog', 'image_2'], + [3, bytearray(b'01001100111'), None, 314, 3, 'cat', 'image_3']] + } + self.assertEqual(meta, expect_meta) + + def test_empty_file(self): + batch_path = self._dataset_directory.batch_path(self._batch_name) + fs = fsspec.get_mapper(batch_path).fs + fs.mkdir(batch_path) + fs.touch(os.path.join(batch_path, 'empty_file')) + self.analyzer_task.run(spark=self.spark, + dataset_path=self._data_path, + wildcard='batch/test_batch/**', + is_image=False, + batch_name=self._batch_name, + buckets_num=10, + thumbnail_path=self._dataset_directory.thumbnails_path(self._batch_name)) + meta_path = self._dataset_directory.batch_meta_file(batch_name=self._batch_name) + self.assertFalse(self._filesystem.exists(meta_path)) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/analyzer_v2.py b/web_console_v2/inspection/analyzer_v2.py new file mode 100644 index 000000000..95c372971 --- /dev/null +++ b/web_console_v2/inspection/analyzer_v2.py @@ -0,0 +1,95 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +import argparse + +from pyspark.sql import SparkSession +from pyspark.conf import SparkConf + +from analyzer.analyzer_task import AnalyzerTask +from util import normalize_file_path, build_spark_conf +from error_code import AreaCode, ErrorType, JobException, write_termination_message + + +def get_args(args=None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description='analyzer scripts arguments') + subparsers = parser.add_subparsers(dest='command', help='sub-command help') + # image parser + image_parser = subparsers.add_parser('image', help='analyzer image format dataset') + image_parser.add_argument('--data_path', type=str, required=True, help='path of dataset') + image_parser.add_argument('--file_wildcard', type=str, required=True, help='file wildcard') + image_parser.add_argument('--buckets_num', '-n', type=int, required=False, help='the number of buckets for hist') + image_parser.add_argument('--thumbnail_path', type=str, required=True, help='dir path to save the thumbnails') + image_parser.add_argument('--batch_name', type=str, required=False, default='', help='batch_name of target batch') + image_parser.add_argument('--skip', action='store_true', help='skip analyzer task') + # tabular parser + tabular_parser = subparsers.add_parser('tabular', help='analyzer tabular format dataset') + tabular_parser.add_argument('--data_path', type=str, required=True, help='path of dataset') + tabular_parser.add_argument('--file_wildcard', type=str, required=True, help='file wildcard') + tabular_parser.add_argument('--buckets_num', '-n', type=int, required=False, help='the number of buckets for hist') + tabular_parser.add_argument('--batch_name', type=str, required=False, default='', help='batch_name of target batch') + tabular_parser.add_argument('--skip', action='store_true', help='skip analyzer task') + # none_structured parser + none_structured_parser = subparsers.add_parser('none_structured', help='analyzer none_structured format dataset') + none_structured_parser.add_argument('--skip', action='store_true', help='skip analyzer task') + # all needed args for both image/tabular will be given, so we use known_args to ignore unnecessary args + args, _ = parser.parse_known_args(args) + + return args + + +def analyzer(): + try: + args = get_args() + except SystemExit: + write_termination_message(AreaCode.ANALYZER, ErrorType.INPUT_PARAMS_ERROR, + 'input params error, check details in logs') + raise + logging.info(f'[analyzer]:\n' + '----------------------\n' + 'Input params:\n' + f'{args.__dict__}\n' + '----------------------\n') + if args.skip: + logging.info('[analyzer]: skip analyzer job [SKIP]') + return + conf: SparkConf = build_spark_conf() + spark = SparkSession.builder.config(conf=conf).getOrCreate() + thumbnail_path_parameter = None + if args.command == 'image': + thumbnail_path_parameter = args.thumbnail_path + try: + if not args.batch_name: + raise JobException(area_code=AreaCode.ANALYZER, + error_type=ErrorType.INPUT_PARAMS_ERROR, + message='failed to find batch_name') + AnalyzerTask().run(spark=spark, + dataset_path=normalize_file_path(args.data_path), + wildcard=args.file_wildcard, + is_image=(args.command == 'image'), + batch_name=args.batch_name, + buckets_num=int(args.buckets_num) if args.buckets_num else None, + thumbnail_path=normalize_file_path(thumbnail_path_parameter)) + except JobException as e: + write_termination_message(e.area_code, e.error_type, e.message) + raise + finally: + spark.stop() + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + analyzer() diff --git a/web_console_v2/inspection/analyzer_v2_test.py b/web_console_v2/inspection/analyzer_v2_test.py new file mode 100644 index 000000000..0d45f3131 --- /dev/null +++ b/web_console_v2/inspection/analyzer_v2_test.py @@ -0,0 +1,119 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import argparse +import unittest +from unittest.mock import ANY, MagicMock, patch + +from pyspark import SparkConf + +from analyzer_v2 import get_args, analyzer +from error_code import AreaCode, ErrorType, JobException + + +class AnalyzerTest(unittest.TestCase): + + def test_get_args(self): + data_path = '/data/fake_path' + file_wildcard = 'batch/**/**' + buckets_num = '10' + thumbnail_path = 'thumbnail' + batch_name = '20220101' + + # test image + args = get_args(['image', f'--data_path={data_path}', f'--file_wildcard={file_wildcard}', \ + f'--buckets_num={buckets_num}', f'--thumbnail_path={thumbnail_path}', '--skip', \ + f'--batch_name={batch_name}']) + self.assertEqual(args.command, 'image') + self.assertEqual(args.data_path, data_path) + self.assertEqual(args.file_wildcard, file_wildcard) + self.assertEqual(args.buckets_num, int(buckets_num)) + self.assertEqual(args.thumbnail_path, thumbnail_path) + self.assertTrue(args.skip) + self.assertEqual(args.batch_name, batch_name) + + # test tabular + args = get_args(['tabular', f'--data_path={data_path}', f'--file_wildcard={file_wildcard}', \ + f'--buckets_num={buckets_num}', f'--thumbnail_path={thumbnail_path}', f'--batch_name={batch_name}']) + self.assertEqual(args.command, 'tabular') + self.assertEqual(args.data_path, data_path) + self.assertEqual(args.file_wildcard, file_wildcard) + self.assertEqual(args.buckets_num, int(buckets_num)) + self.assertFalse(args.skip) + self.assertEqual(args.batch_name, batch_name) + + # test none_structured + args = get_args(['none_structured', '--skip']) + self.assertEqual(args.command, 'none_structured') + self.assertTrue(args.skip) + + # test no required args + with self.assertRaises(SystemExit): + get_args(['image', '--skip']) + + @patch('analyzer_v2.write_termination_message') + @patch('analyzer_v2.get_args') + @patch('analyzer_v2.build_spark_conf') + @patch('analyzer_v2.AnalyzerTask.run') + def test_analyzer(self, mock_run: MagicMock, mock_build_spark_conf: MagicMock, mock_get_args: MagicMock, + mock_write_termination_message: MagicMock): + # set local spark + mock_build_spark_conf.return_value = SparkConf().setMaster('local') + + # test skip + mock_get_args.return_value = argparse.Namespace(skip=True) + analyzer() + mock_build_spark_conf.assert_not_called() + + # test no batch_name + mock_get_args.reset_mock() + data_path = '/data/fake_path' + file_wildcard = 'batch/**/**' + buckets_num = 10 + thumbnail_path = 'thumbnail' + mock_get_args.return_value = argparse.Namespace(command='image', + data_path=data_path, + file_wildcard=file_wildcard, + buckets_num=buckets_num, + batch_name='', + thumbnail_path=thumbnail_path, + skip=False) + with self.assertRaises(JobException): + analyzer() + mock_write_termination_message.assert_called_once_with(AreaCode.ANALYZER, ErrorType.INPUT_PARAMS_ERROR, + 'failed to find batch_name') + + # test not skip + mock_get_args.reset_mock() + batch_name = '20220101' + mock_get_args.return_value = argparse.Namespace(command='image', + data_path=data_path, + file_wildcard=file_wildcard, + buckets_num=buckets_num, + batch_name=batch_name, + thumbnail_path=thumbnail_path, + skip=False) + analyzer() + mock_run.assert_called_once_with(spark=ANY, + dataset_path='file://' + data_path, + wildcard=file_wildcard, + is_image=True, + batch_name=batch_name, + buckets_num=buckets_num, + thumbnail_path=thumbnail_path) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/converter_v2.py b/web_console_v2/inspection/converter_v2.py new file mode 100644 index 000000000..1338095f6 --- /dev/null +++ b/web_console_v2/inspection/converter_v2.py @@ -0,0 +1,332 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import enum +import logging +import json +import argparse +import fsspec +import os + +from pyspark.conf import SparkConf +from pyspark.sql import SparkSession +from pyspark.sql import functions as spark_func +from pyspark.sql.types import StringType, ArrayType, StructType +from pyspark.sql.dataframe import DataFrame +from dataset_directory import DatasetDirectory + +from util import FileFormat, normalize_file_path, dataset_schema_path, build_spark_conf, is_file_matched, \ + load_by_file_format, EXAMPLE_ID +from error_code import AreaCode, ErrorType, JobException, write_termination_message + +DEFAULT_MANIFEST_PATH = 'manifest.json' +DEFAULT_IMAGES_PATH = 'images' +RAW_ID = 'raw_id' + + +class ImportType(enum.Enum): + COPY = 'COPY' + NO_COPY = 'NO_COPY' + + +@spark_func.udf(StringType()) +def get_file_basename(origin_name: str) -> str: + return os.path.basename(origin_name) + + +def flatten(schema, prefix=None): + fields = [] + for field in schema.fields: + name = prefix + '.' + field.name if prefix else field.name + dtype = field.dataType + if isinstance(dtype, ArrayType): + dtype = dtype.elementType + + if isinstance(dtype, StructType): + fields += flatten(dtype, prefix=name) + else: + fields.append(name) + + return fields + + +def convert_timestamp_cols_type_to_string(df: DataFrame) -> DataFrame: + dtypes_dict = dict(df.dtypes) + ts_col_list = [col_name for col_name in df.columns if dtypes_dict[col_name] == 'timestamp'] + logging.info(f'will convert the timestamp type columns to string type:{ts_col_list}') + for col_name in ts_col_list: + df = df.withColumn(col_name, spark_func.col(col_name).cast('string')) + return df + + +def convert_image(spark: SparkSession, output_dataset_path: str, output_batch_path: str, input_batch_path: str, + manifest_name: str, images_dir_name: str): + """convert source data to tfrecords format and save to output_batch_path. + + Args: + spark: spark session + output_dataset_path: path of output dataset. + output_batch_path: path of output data_batch in this dataset. + input_batch_path: input batch path of the image dataset directory. + manifest_name: relative path of manifest file in zip archive. + images_dir_name: relative path of images directory in zip archive + + Raises: + JobException: read/write data by pyspark failed + + manifest json schema: + { + images:[ + { + name: str + file_name: str + created_at: str + annotation:{ + label: str + } + }, + ... + ] + } + + saved tfrecord schema: + +----------+-----+------+---------+-----+------+------+-----------+------+ + |file_name |width|height|nChannels|mode |data |name |created_at |label | + +----------+-----+------+---------+-----+------+------+-----------+------+ + |string |int |int |int |int |binary|string|string |string| + +----------+-----+------+---------+-----+------+------+-----------+------+ + """ + images_path = os.path.join(input_batch_path, images_dir_name) + logging.info(f'### will load the images dir into dataframe:{images_path}') + # spark.read.format('image') schema: + # origin: StringType (represents the file path of the image) + # height: IntegerType (height of the image) + # width: IntegerType (width of the image) + # nChannels: IntegerType (number of image channels) + # mode: IntegerType (OpenCV-compatible type) + # data: BinaryType (Image bytes in OpenCV-compatible order: row-wise BGR in most cases) + # document ref: https://spark.apache.org/docs/latest/ml-datasource.html + try: + images_df = spark.read.format('image').load(images_path).select('image.origin', 'image.width', 'image.height', + 'image.nChannels', 'image.mode', 'image.data') + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.CONVERTER, ErrorType.DATA_LOAD_ERROR, + f'failed to read input data, err: {str(e)}') from e + images_df = images_df.withColumn('file_name', get_file_basename(spark_func.col('origin'))).drop('origin') + manifest_path = os.path.join(input_batch_path, manifest_name) + logging.info(f'### will load the manifest json file into dataframe:{manifest_path}') + manifest_df = spark.read.json(manifest_path, multiLine=True) + manifest_df.printSchema() + manifest_df = manifest_df.select(spark_func.explode('images')).toDF('images').select( + 'images.name', 'images.file_name', 'images.created_at', 'images.annotation') + manifest_df = manifest_df.select(flatten(manifest_df.schema)) + logging.info('### will join the images_df with the manifest_df on file_name column') + df = images_df.join(manifest_df, 'file_name', 'inner') + df = convert_timestamp_cols_type_to_string(df) + logging.info(f'### saving to {output_batch_path}, in tfrecords') + try: + df.write.format('tfrecords').option('compression', 'none').save(output_batch_path, mode='overwrite') + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.CONVERTER, ErrorType.DATA_WRITE_ERROR, + f'failed to write data, err: {str(e)}') from e + with fsspec.open(dataset_schema_path(output_dataset_path), mode='w') as f: + json.dump(df.schema.jsonValue(), f) + + +def convert_tabular(spark: SparkSession, output_dataset_path: str, output_batch_path: str, input_batch_path: str, + file_format: FileFormat): + """convert source data to tfrecords format and save to output_batch_path. + + Args: + spark: spark session + output_dataset_path: path of output dataset. + output_batch_path: path of output data_batch in this dataset. + input_batch_path: input batch path of the tabular dataset. + file_format: FileFormat['csv', 'tfrecords'] + + Raises: + JobException: read/write data by pyspark failed + """ + if not is_file_matched(input_batch_path): + # this is a hack to allow empty intersection dataset + # no file matched, just mkdir output_batch_path and skip converter + fsspec.get_mapper(output_batch_path).fs.mkdir(output_batch_path) + logging.warning(f'input_dataset_path {input_batch_path} matches 0 files, skip converter task') + return + try: + df = load_by_file_format(spark, input_batch_path, file_format) + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.CONVERTER, ErrorType.DATA_LOAD_ERROR, + f'failed to read input data, err: {str(e)}') from e + # force raw_id to string type as raw_id will be convert to bytes type in raw_data job + # bigint type may cause memoryError + if RAW_ID in df.columns: + df = df.withColumn(RAW_ID, df[RAW_ID].cast(StringType())) + # force example_id to string type as example_id will be read as stringType in training + if EXAMPLE_ID in df.columns: + df = df.withColumn(EXAMPLE_ID, df[EXAMPLE_ID].cast(StringType())) + logging.info(f'### saving to {output_batch_path}, in tfrecords') + try: + df.write.format('tfrecords').option('compression', 'none').save(output_batch_path, mode='overwrite') + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.CONVERTER, ErrorType.DATA_WRITE_ERROR, + f'failed to write data, err: {str(e)}') from e + with fsspec.open(dataset_schema_path(output_dataset_path), mode='w') as f: + json.dump(df.schema.jsonValue(), f) + + +def convert_no_copy(output_dataset_path: str, output_batch_path: str, input_batch_path: str): + """save source data path to file source_batch_path in output_batch_path. + + Args: + output_dataset_path: path of output dataset. + output_batch_path: path of output data_batch in this dataset. + input_batch_path: input batch path of the tabular dataset. + + """ + batch_name = os.path.basename(output_batch_path) + with fsspec.open(DatasetDirectory(output_dataset_path).source_batch_path_file(batch_name), mode='w') as f: + f.write(input_batch_path) + + +def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description='converter scripts arguments') + subparsers = parser.add_subparsers(dest='command', help='sub-command help') + # image parser + image_parser = subparsers.add_parser('image', help='converter image format dataset') + image_parser.add_argument('--output_dataset_path', required=True, type=str, help='path of the output dataset') + image_parser.add_argument('--output_batch_path', + required=True, + type=str, + help='path of output data_batch in this dataset') + image_parser.add_argument('--input_batch_path', + required=True, + type=str, + help='input batch path of the image dataset') + image_parser.add_argument('--manifest_name', + type=str, + required=False, + default=DEFAULT_MANIFEST_PATH, + help='manifest file name in image dataset directory') + image_parser.add_argument('--images_dir_name', + type=str, + required=False, + default=DEFAULT_IMAGES_PATH, + help='images directory name in image dataset directory') + image_parser.add_argument('--import_type', + type=str, + choices=[import_type.value for import_type in ImportType], + required=False, + default=ImportType.COPY.value, + help='import type') + # tabular parser + tabular_parser = subparsers.add_parser('tabular', help='converter tabular format dataset') + tabular_parser.add_argument('--output_dataset_path', type=str, required=True, help='path of output dataset') + tabular_parser.add_argument('--output_batch_path', + type=str, + required=True, + help='path of output data_batch in this dataset') + tabular_parser.add_argument('--input_batch_path', + type=str, + required=True, + help='input batch path of the tabular dataset') + tabular_parser.add_argument('--format', + type=FileFormat, + choices=list(FileFormat), + required=True, + help='file format') + tabular_parser.add_argument('--import_type', + type=str, + choices=[import_type.value for import_type in ImportType], + required=False, + default=ImportType.COPY.value, + help='import type') + # none_structured parser + none_structured_parser = subparsers.add_parser('none_structured', help='converter none_structured format dataset') + none_structured_parser.add_argument('--output_dataset_path', type=str, required=True, help='path of output dataset') + none_structured_parser.add_argument('--output_batch_path', + type=str, + required=True, + help='path of output data_batch in this dataset') + none_structured_parser.add_argument('--input_batch_path', + type=str, + required=True, + help='input batch path of the none_structured dataset') + none_structured_parser.add_argument('--import_type', + type=str, + choices=[import_type.value for import_type in ImportType], + required=False, + default=ImportType.COPY.value, + help='import type') + # all needed args for both image/tabular/none_structured will be given, + # so we use known_args to ignore unnecessary args + args, _ = parser.parse_known_args() + + return args + + +def converter_task(): + try: + args = get_args() + except SystemExit: + write_termination_message(AreaCode.CONVERTER, ErrorType.INPUT_PARAMS_ERROR, + 'input params error, check details in logs') + raise + logging.info(f'[converter]:\n' + '----------------------\n' + 'Input params:\n' + f'{args.__dict__}\n' + '----------------------\n') + conf: SparkConf = build_spark_conf() + spark = SparkSession.builder.config(conf=conf).getOrCreate() + output_dataset_path = normalize_file_path(args.output_dataset_path) + output_batch_path = normalize_file_path(args.output_batch_path) + input_batch_path = normalize_file_path(args.input_batch_path) + try: + if args.import_type == ImportType.NO_COPY.value: + convert_no_copy(output_dataset_path=output_dataset_path, + output_batch_path=output_batch_path, + input_batch_path=input_batch_path) + else: + if args.command == 'none_structured': + raise JobException(AreaCode.CONVERTER, ErrorType.INPUT_PARAMS_ERROR, + 'none_structured dataset only supports no_copy import') + if args.command == 'image': + # image data + logging.info('will convert image format dataset') + convert_image(spark=spark, + output_dataset_path=output_dataset_path, + output_batch_path=output_batch_path, + input_batch_path=input_batch_path, + manifest_name=args.manifest_name, + images_dir_name=args.images_dir_name) + else: + # tabular data + logging.info('will convert tabular format dataset') + convert_tabular(spark=spark, + output_dataset_path=output_dataset_path, + output_batch_path=output_batch_path, + input_batch_path=input_batch_path, + file_format=args.format) + except JobException as e: + write_termination_message(e.area_code, e.error_type, e.message) + raise + finally: + spark.stop() + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + converter_task() diff --git a/web_console_v2/inspection/converter_v2_test.py b/web_console_v2/inspection/converter_v2_test.py new file mode 100644 index 000000000..d071a102f --- /dev/null +++ b/web_console_v2/inspection/converter_v2_test.py @@ -0,0 +1,272 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest +import os +import fsspec +import json + +from pyspark.sql.dataframe import DataFrame +from pyspark.sql import functions as spark_func +from pyspark.sql.types import StringType, StructType, Row + +from testing.spark_test_case import PySparkTestCase + +from converter_v2 import convert_image, convert_no_copy, convert_tabular +from dataset_directory import DatasetDirectory +from util import FileFormat, dataset_schema_path, load_tfrecords + + +@spark_func.udf(StringType()) +def get_file_basename(origin_name: str) -> str: + return os.path.basename(origin_name) + + +class ConverterTest(PySparkTestCase): + + def tearDown(self) -> None: + self._clear_up() + return super().tearDown() + + def _clear_up(self): + fs = fsspec.filesystem('file') + if fs.isdir(self.tmp_dataset_path): + fs.rm(self.tmp_dataset_path, recursive=True) + + def assertDataframeEqual(self, df1: DataFrame, df2: DataFrame): + self.assertEqual(df1.subtract(df2).count(), 0) + self.assertEqual(df2.subtract(df1).count(), 0) + + def test_convert_image(self): + input_batch_path = os.path.join(self.test_data, 'image') + output_dataset_path = os.path.join(self.tmp_dataset_path, 'output_dataset') + batch_name = 'batch_test' + output_batch_path = DatasetDirectory(output_dataset_path).batch_path(batch_name) + manifest_name = 'manifest.json' + images_dir_name = 'images' + convert_image(self.spark, output_dataset_path, output_batch_path, input_batch_path, manifest_name, + images_dir_name) + fs = fsspec.filesystem('file') + files = fs.ls(output_batch_path) + file_names = {f.split('/')[-1] for f in files} + expect_file_names = {'part-r-00000', 'part-r-00001', 'part-r-00002', '_SUCCESS'} + self.assertTrue(expect_file_names.issubset(file_names)) + with fsspec.open(dataset_schema_path(output_dataset_path), 'r') as f: + output_schema = json.load(f) + expect_schema = { + 'type': + 'struct', + 'fields': [{ + 'name': 'file_name', + 'type': 'string', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'width', + 'type': 'integer', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'height', + 'type': 'integer', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'nChannels', + 'type': 'integer', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'mode', + 'type': 'integer', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'data', + 'type': 'binary', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'name', + 'type': 'string', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'created_at', + 'type': 'string', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'caption', + 'type': 'string', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'label', + 'type': 'string', + 'nullable': True, + 'metadata': {} + }] + } + self.assertEqual(output_schema, expect_schema) + image_df = self.spark.read.format('image').load(os.path.join(input_batch_path, images_dir_name)) + converter_df = load_tfrecords(self.spark, output_batch_path, output_dataset_path) + self.assertDataframeEqual(image_df.select('image.data'), converter_df.select('data')) + converter_struct_df = converter_df.select('file_name', 'width', 'height', 'nChannels', 'mode', 'name', + 'created_at', 'caption', 'label') + expect_struct = [ + Row(file_name='000000005756.jpg', + width=640, + height=361, + nChannels=3, + mode=16, + name='000000005756.jpg', + created_at='2021-08-30T16:52:15.501516', + caption='A group of people holding umbrellas looking at graffiti.', + label='A'), + Row(file_name='000000018425.jpg', + width=640, + height=480, + nChannels=3, + mode=16, + name='000000018425.jpg', + created_at='2021-08-30T16:52:15.501516', + caption='Two giraffe grazing on tree leaves under a hazy sky.', + label='B'), + Row(file_name='000000008181.jpg', + width=640, + height=480, + nChannels=3, + mode=16, + name='000000008181.jpg', + created_at='2021-08-30T16:52:15.501516', + caption='A motorcycle is parked on a gravel lot', + label='C') + ] + self.assertCountEqual(converter_struct_df.collect(), expect_struct) + + def test_convert_tabular(self): + input_batch_path = os.path.join(self.test_data, 'csv/medium_csv') + output_dataset_path = os.path.join(self.tmp_dataset_path, 'output_dataset') + batch_name = 'batch_test' + output_batch_path = DatasetDirectory(output_dataset_path).batch_path(batch_name) + file_format = FileFormat.CSV + convert_tabular(self.spark, output_dataset_path, output_batch_path, input_batch_path, file_format) + fs = fsspec.filesystem('file') + files = fs.ls(output_batch_path) + file_names = {f.split('/')[-1] for f in files} + expect_file_names = {'part-r-00000', '_SUCCESS'} + self.assertTrue(expect_file_names.issubset(file_names)) + with fsspec.open(dataset_schema_path(output_dataset_path), 'r') as f: + output_schema = json.load(f) + expect_schema = { + 'type': + 'struct', + 'fields': [{ + 'name': 'example_id', + 'type': 'string', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'raw_id', + 'type': 'string', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'event_time', + 'type': 'integer', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x0', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x1', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x2', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x3', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x4', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x5', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x6', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x7', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x8', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }, { + 'name': 'x9', + 'type': 'double', + 'nullable': True, + 'metadata': {} + }] + } + self.assertEqual(output_schema, expect_schema) + raw_df = self.spark.read.format('csv').option('header', 'true').schema( + StructType.fromJson(expect_schema)).load(input_batch_path) + converter_df = load_tfrecords(self.spark, output_batch_path, output_dataset_path) + self.assertEqual(raw_df.schema, converter_df.schema) + # TODO(liuhehan): add df content check + self.assertEqual(raw_df.count(), converter_df.count()) + + def test_convert_empty(self): + input_batch_path = os.path.join(self.test_data, 'csv/*.fake') + output_dataset_path = os.path.join(self.tmp_dataset_path, 'output_dataset') + batch_name = 'batch_test' + output_batch_path = DatasetDirectory(output_dataset_path).batch_path(batch_name) + file_format = FileFormat.CSV + convert_tabular(self.spark, output_dataset_path, output_batch_path, input_batch_path, file_format) + self.assertTrue(fsspec.get_mapper(output_batch_path).fs.isdir(output_batch_path)) + + def test_converter_no_copy(self): + input_batch_path = os.path.join(self.test_data, 'csv/medium_csv') + output_dataset_path = os.path.join(self.tmp_dataset_path, 'output_dataset') + batch_name = 'batch_test' + output_batch_path = DatasetDirectory(output_dataset_path).batch_path(batch_name) + convert_no_copy(output_dataset_path, output_batch_path, input_batch_path) + with fsspec.open(DatasetDirectory(output_dataset_path).source_batch_path_file(batch_name), 'r') as f: + self.assertEqual(f.read(), input_batch_path) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/dataset_alignment.py b/web_console_v2/inspection/dataset_alignment.py new file mode 100644 index 000000000..1dc59afc7 --- /dev/null +++ b/web_console_v2/inspection/dataset_alignment.py @@ -0,0 +1,152 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import logging +import argparse +from typing import List, Dict +import json +import fsspec + +from pyspark.rdd import RDD +from pyspark.sql import SparkSession +from pyspark.conf import SparkConf +from pyspark.sql.types import Row, StringType, StructType, StructField +from util import build_spark_conf, load_tfrecords, dataset_schema_path +from json_schema_checker import SchemaChecker + +from error_code import AreaCode, ErrorType, JobException, write_termination_message + +_DATASET_ALIGNMENT_LOG = 'dataset_alignment' + + +class DatasetAlignment(object): + """ + ProcessedDataset struct + | + |--- batch ---- batch_name_1 --- real data files + | | + | |- batch_name_2 --- real data files + | | + | |- batch_name_3 --- real data files + | + |--- errors --- batch_name_1 --- error message files (.csv) + | | + | |- batch_name_2 --- error message files (.csv) + | | + | |- batch_name_3 --- error message files (.csv) + | + |--- _META + """ + + def __init__(self, spark: SparkSession, input_dataset_path: str, input_batch_path: str, wildcard: str, + output_batch_path: str, json_schema: str, output_dataset_path: str, output_error_path: str): + self._spark = spark + self._input_dataset_path = input_dataset_path + self._input_batch_path = input_batch_path + self._wildcard = wildcard + self._output_batch_path = output_batch_path + self._json_schema = json.loads(json_schema) + self._output_dataset_path = output_dataset_path + self._output_error_path = output_error_path + + def _dump_error_msgs(self, rdd_errors: RDD): + deptSchema = StructType([ + StructField('field', StringType(), True), + StructField('message', StringType(), True), + ]) + df_error_msgs = self._spark.createDataFrame(rdd_errors, schema=deptSchema) + logging.info(f'[{_DATASET_ALIGNMENT_LOG}]: start to dump error message') + df_error_msgs.coalesce(1).write.format('json').save(self._output_error_path, mode='overwrite') + logging.info(f'[{_DATASET_ALIGNMENT_LOG}]: dump error message finished') + + def run(self): + try: + checker = SchemaChecker(self._json_schema) + except RuntimeError as e: + raise JobException(AreaCode.ALIGNMENT, ErrorType.INPUT_PARAMS_ERROR, 'json_schema is illegal') from e + + files = os.path.join(self._input_batch_path, self._wildcard) + try: + df = load_tfrecords(spark=self._spark, files=files, dataset_path=self._input_dataset_path) + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.ALIGNMENT, ErrorType.DATA_LOAD_ERROR, + f'failed to read input data, err: {str(e)}') from e + # broadcast has better performence by keeping read-only variable cached on each machine, + # rather than shipping a copy of it with tasks + # Ref: https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.Broadcast.html + broadcast_vals = self._spark.sparkContext.broadcast({'checker': checker}) + + def check_schema(row: Row) -> List[Dict[str, str]]: + data = row.asDict() + checker = broadcast_vals.value['checker'] + error_message = checker.check(data) + return error_message + + # use flatMap to flat result from list[dict] to dict like {'filed': 'xxx', 'message': 'xxx'} + rdd_errors = df.rdd.flatMap(check_schema) + if rdd_errors.count() > 0: + self._dump_error_msgs(rdd_errors) + message = f'[{_DATASET_ALIGNMENT_LOG}]: schema check failed!' + logging.error(message) + raise JobException(AreaCode.ALIGNMENT, ErrorType.SCHEMA_CHECK_ERROR, message) + logging.info(f'[{_DATASET_ALIGNMENT_LOG}]: schema check succeeded!') + df.write.format('tfrecords').option('compression', 'none').save(self._output_batch_path, mode='overwrite') + with fsspec.open(dataset_schema_path(self._output_dataset_path), mode='w') as f: + json.dump(df.schema.jsonValue(), f) + logging.info(f'[{_DATASET_ALIGNMENT_LOG}]: dataset alignment task is finished!') + + +def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description='dataset alignment task') + + parser.add_argument('--input_dataset_path', type=str, required=True, help='path of input dataset') + parser.add_argument('--input_batch_path', type=str, required=True, help='path of input databatch') + parser.add_argument('--json_schema', type=str, required=True, help='json schema in string type') + parser.add_argument('--wildcard', type=str, required=True, help='wildcard') + parser.add_argument('--output_dataset_path', type=str, required=True, help='path of output dataset') + parser.add_argument('--output_batch_path', type=str, required=True, help='path of output databatch') + parser.add_argument('--output_error_path', type=str, required=True, help='path of output error') + + return parser.parse_args() + + +def alignment_task(): + try: + args = get_args() + except SystemExit: + write_termination_message(AreaCode.ALIGNMENT, ErrorType.INPUT_PARAMS_ERROR, + 'input params error, check details in logs') + raise + logging.info(f'[{_DATASET_ALIGNMENT_LOG}]:\n' + '----------------------\n' + 'Input params:\n' + f'{args.__dict__}\n' + '----------------------\n') + conf: SparkConf = build_spark_conf() + spark = SparkSession.builder.config(conf=conf).getOrCreate() + try: + DatasetAlignment(spark, args.input_dataset_path, args.input_batch_path, args.wildcard, args.output_batch_path, + args.json_schema, args.output_dataset_path, args.output_error_path).run() + except JobException as e: + write_termination_message(e.area_code, e.error_type, e.message) + raise + finally: + spark.stop() + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + alignment_task() diff --git a/web_console_v2/inspection/dataset_alignment_test.py b/web_console_v2/inspection/dataset_alignment_test.py new file mode 100644 index 000000000..5ae4ac0d9 --- /dev/null +++ b/web_console_v2/inspection/dataset_alignment_test.py @@ -0,0 +1,166 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import unittest +import os +import fsspec + +from pyspark.sql.types import BinaryType, IntegerType, StructField, StructType, StringType +from error_code import JobException + +from testing.spark_test_case import PySparkTestCase +from dataset_alignment import DatasetAlignment +from util import dataset_schema_path + + +class DatasetAlignmentTest(PySparkTestCase): + + def setUp(self) -> None: + super().setUp() + self._generate_tfrecords() + + def tearDown(self) -> None: + self._clear_up() + return super().tearDown() + + def _generate_tfrecords(self): + data = [ + (1, b'01001100111', 256, 256, 3, 'cat'), + (2, b'01001100111', 244, 246, 3, 'dog'), + (3, b'01001100111', 255, 312, 3, 'cat'), + (4, b'01001100111', 256, 255, 3, 'cat'), + (5, b'01001100111', 201, 241, 3, 'cat'), + (6, b'01001100111', 255, 221, 3, 'dog'), + (7, b'01001100111', 201, 276, 3, 'dog'), + (8, b'01001100111', 258, 261, 3, 'dog'), + (9, b'01001100111', 198, 194, 3, 'cat'), + (10, b'01001100111', 231, 221, 3, 'cat'), + ] + schema = StructType([ + StructField('raw_id', IntegerType(), False), + StructField('image', BinaryType(), False), + StructField('rows', IntegerType(), False), + StructField('cols', IntegerType(), False), + StructField('channel', IntegerType(), False), + StructField('label', StringType(), False), + ]) + df = self.spark.createDataFrame(data=data, schema=schema) + df.repartition(3).write.format('tfrecords').option('compression', 'none').save(os.path.join( + self.tmp_dataset_path, 'input_dataset/batch/batch_test'), + mode='overwrite') + with fsspec.open(dataset_schema_path(os.path.join(self.tmp_dataset_path, 'input_dataset')), mode='w') as f: + json.dump(df.schema.jsonValue(), f) + + def _clear_up(self): + fs = fsspec.filesystem('file') + if fs.isdir(self.tmp_dataset_path): + fs.rm(self.tmp_dataset_path, recursive=True) + + def test_dataset_alignment(self): + input_dataset_path = os.path.join(self.tmp_dataset_path, 'input_dataset') + input_batch_path = os.path.join(input_dataset_path, 'batch/batch_test') + wildcard = '**' + output_dataset_path = os.path.join(self.tmp_dataset_path, 'output_dataset') + output_batch_patch = os.path.join(output_dataset_path, 'batch/batch_test') + input_schema_path = os.path.join(self.test_data, 'alignment_schema.json') + output_error_path = os.path.join(output_dataset_path, 'errors/batch_test') + with fsspec.open(input_schema_path, 'r') as f: + json_schema = f.read() + # test passed while no exception raise + DatasetAlignment(self.spark, input_dataset_path, input_batch_path, wildcard, output_batch_patch, json_schema, + output_dataset_path, output_error_path).run() + fs = fsspec.filesystem('file') + self.assertFalse(fs.isdir(output_error_path)) + files = fs.ls(output_batch_patch) + file_names = [] + for file in files: + # skip Cyclic Redundancy Check file + if file.endswith('.crc'): + continue + file_name = file.split('/')[-1] + if file_name != '_SUCCESS': + print(file_name) + self.assertNotEqual(fs.size(file), 0) + file_names.append(file_name) + golden_file_names = ['part-r-00000', 'part-r-00001', 'part-r-00002', '_SUCCESS'] + self.assertCountEqual(file_names, golden_file_names) + input_schema_path = dataset_schema_path(input_dataset_path) + self.assertTrue(fs.isfile(input_schema_path)) + with fsspec.open(input_schema_path, 'r') as f: + input_schema = f.read() + output_schema_path = dataset_schema_path(output_dataset_path) + self.assertTrue(fs.isfile(output_schema_path)) + with fsspec.open(output_schema_path, 'r') as f: + output_schema = f.read() + self.assertEqual(input_schema, output_schema) + + def test_dataset_alignment_error(self): + input_dataset_path = os.path.join(self.tmp_dataset_path, 'input_dataset') + input_batch_path = os.path.join(input_dataset_path, 'batch/batch_test') + wildcard = '**' + output_dataset_path = os.path.join(self.tmp_dataset_path, 'output_dataset') + output_batch_patch = os.path.join(output_dataset_path, 'batch/batch_test') + input_schema_path = os.path.join(self.test_data, 'alignment_schema_error.json') + output_error_path = os.path.join(output_dataset_path, 'errors/batch_test') + with fsspec.open(input_schema_path, 'r') as f: + json_schema = f.read() + # test passed while no exception raise + with self.assertRaises(JobException): + DatasetAlignment(self.spark, input_dataset_path, input_batch_path, wildcard, output_batch_patch, + json_schema, output_dataset_path, output_error_path).run() + fs = fsspec.filesystem('file') + self.assertTrue(fs.isdir(output_error_path)) + golden_data = [{ + 'field': 'raw_id', + 'message': '3 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '8 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '10 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '4 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '6 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '7 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '9 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '1 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '2 is not of type \'string\'' + }, { + 'field': 'raw_id', + 'message': '5 is not of type \'string\'' + }] + error_file = fs.glob(output_error_path + '/part-*.json')[0] + error_msgs = [] + with fs.open(error_file) as f: + for line in f: + error_msgs.append(json.loads(line)) + self.assertCountEqual(error_msgs, golden_data) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/dataset_directory.py b/web_console_v2/inspection/dataset_directory.py new file mode 100644 index 000000000..1eec014f9 --- /dev/null +++ b/web_console_v2/inspection/dataset_directory.py @@ -0,0 +1,99 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os + + +class DatasetDirectory(object): + """ + Dataset struct + | + |--- batch ---- batch_name_1 --- real data files + | | + | |- batch_name_2 --- real data files + | | + | |- batch_name_3 --- real data files + | + |--- meta --- batch_name_1 --- thumbnails (only for image) --- preview image (.png) + | | | + | | |- _META + | | + | |- batch_name_2 --- thumbnails (only for image) --- preview image (.png) + | | | + | | |- _META + | | + | |- batch_name_3 --- thumbnails (only for image) --- preview image (.png) + | | | + | | |- _META + | + |--- errors --- batch_name_1 --- error message files (.csv) + | | + | |- batch_name_2 --- error message files (.csv) + | | + | |- batch_name_3 --- error message files (.csv) + | + |--- side_output --- batch_name_1 --- intermedia data + | | + | |- batch_name_2 --- intermedia data + | | + | |- batch_name_3 --- intermedia data + | + |--- _META (now move to meta/batch_name, delete in future) + | + |--- schema.json + + """ + _BATCH_DIR = 'batch' + _META_DIR = 'meta' + _ERRORS_DIR = 'errors' + _SIDE_OUTPUT_DIR = 'side_output' + _THUMBNAILS_DIR = 'thumbnails' + _META_FILE = '_META' + _SCHEMA_FILE = 'schema.json' + _SOURCE_BATCH_PATH_FILE = 'source_batch_path' + + def __init__(self, dataset_path: str): + self._dataset_path = dataset_path + + @property + def dataset_path(self) -> str: + return self._dataset_path + + def batch_path(self, batch_name: str) -> str: + return os.path.join(self._dataset_path, self._BATCH_DIR, batch_name) + + def errors_path(self, batch_name: str) -> str: + return os.path.join(self._dataset_path, self._ERRORS_DIR, batch_name) + + def thumbnails_path(self, batch_name: str) -> str: + return os.path.join(self._dataset_path, self._META_DIR, batch_name, self._THUMBNAILS_DIR) + + def side_output_path(self, batch_name: str) -> str: + return os.path.join(self._dataset_path, self._SIDE_OUTPUT_DIR, batch_name) + + def source_batch_path_file(self, batch_name: str) -> str: + return os.path.join(self.batch_path(batch_name), self._SOURCE_BATCH_PATH_FILE) + + def batch_meta_file(self, batch_name) -> str: + return os.path.join(self._dataset_path, self._META_DIR, batch_name, self._META_FILE) + + @property + def schema_file(self) -> str: + return os.path.join(self._dataset_path, self._SCHEMA_FILE) + + # TODO(liuhehan): remove it in future + @property + def meta_file(self) -> str: + return os.path.join(self._dataset_path, self._META_FILE) diff --git a/web_console_v2/inspection/dataset_directory_test.py b/web_console_v2/inspection/dataset_directory_test.py new file mode 100644 index 000000000..b1ff39ac8 --- /dev/null +++ b/web_console_v2/inspection/dataset_directory_test.py @@ -0,0 +1,64 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest + +from dataset_directory import DatasetDirectory + + +class UtilTest(unittest.TestCase): + _DATASET_PATH = '/fakepath/test_dataset' + _BATCH_NAME = 'test_batch_name' + + def setUp(self) -> None: + super().setUp() + self._dataset_dir = DatasetDirectory(dataset_path=self._DATASET_PATH) + + def test_dataset_path(self): + self.assertEqual(self._dataset_dir.dataset_path, self._DATASET_PATH) + + def test_batch_path(self): + self.assertEqual(self._dataset_dir.batch_path(self._BATCH_NAME), + f'{self._DATASET_PATH}/batch/{self._BATCH_NAME}') + + def test_errors_path(self): + self.assertEqual(self._dataset_dir.errors_path(self._BATCH_NAME), + f'{self._DATASET_PATH}/errors/{self._BATCH_NAME}') + + def test_thumbnails_path(self): + self.assertEqual(self._dataset_dir.thumbnails_path(self._BATCH_NAME), + f'{self._DATASET_PATH}/meta/{self._BATCH_NAME}/thumbnails') + + def test_batch_meta_file(self): + self.assertEqual(self._dataset_dir.batch_meta_file(self._BATCH_NAME), + f'{self._DATASET_PATH}/meta/{self._BATCH_NAME}/_META') + + def test_tmp_path(self): + self.assertEqual(self._dataset_dir.side_output_path(self._BATCH_NAME), + f'{self._DATASET_PATH}/side_output/{self._BATCH_NAME}') + + def test_schema_file(self): + self.assertEqual(self._dataset_dir.schema_file, f'{self._DATASET_PATH}/schema.json') + + def test_meta_file(self): + self.assertEqual(self._dataset_dir.meta_file, f'{self._DATASET_PATH}/_META') + + def test_source_batch_path_file(self): + self.assertEqual(self._dataset_dir.source_batch_path_file(self._BATCH_NAME), + f'{self._DATASET_PATH}/batch/{self._BATCH_NAME}/source_batch_path') + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/dataset_format_checker.py b/web_console_v2/inspection/dataset_format_checker.py new file mode 100644 index 000000000..2902c9c77 --- /dev/null +++ b/web_console_v2/inspection/dataset_format_checker.py @@ -0,0 +1,135 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import logging +import argparse +from typing import List + +from pyspark.conf import SparkConf +from pyspark.sql import SparkSession +from pyspark.sql.dataframe import DataFrame +from error_code import AreaCode, ErrorType, JobException, write_termination_message + +from util import FileFormat, normalize_file_path, build_spark_conf, load_by_file_format, is_file_matched + +RAW_ID_COLUMN = 'raw_id' +DEFAULT_IGNORED_NUMERIC_COLUMNS = frozenset(['raw_id', 'example_id', 'event_time']) +NUMERIC_TYPES = frozenset(['bigint', 'int', 'smallint', 'tinyint', 'double', 'float']) + +RAW_ID_CHECKER = 'RAW_ID_CHECKER' +NUMERIC_COLUMNS_CHECKER = 'NUMERIC_COLUMNS_CHECKER' + + +def check_raw_id(df: DataFrame): + if RAW_ID_COLUMN not in df.columns: + raise JobException(AreaCode.FORMAT_CHECKER, ErrorType.NO_KEY_COLUMN_ERROR, + f'[check_raw_id] failed to find {RAW_ID_COLUMN} in dataset') + + df_count = df.count() + distinct_count = df.dropDuplicates([RAW_ID_COLUMN]).count() + if df_count != distinct_count: + raise JobException(AreaCode.FORMAT_CHECKER, ErrorType.DATA_FORMAT_ERROR, + f'[check_raw_id] find {df_count - distinct_count} duplicated items in raw_id') + + +def check_numeric_columns(df: DataFrame): + illegal_columns = [] + for column_name, column_type in df.dtypes: + if column_name in DEFAULT_IGNORED_NUMERIC_COLUMNS: + continue + if column_type not in NUMERIC_TYPES: + illegal_column_msg = f'[column]: {column_name}, [type]: {column_type}' + illegal_columns.append(illegal_column_msg) + if len(illegal_columns) > 0: + raise JobException(AreaCode.FORMAT_CHECKER, ErrorType.DATA_FORMAT_ERROR, + f'[check_numeric_columns] find {len(illegal_columns)} illegal columns: {illegal_columns}') + + +def check_format(df: DataFrame, checkers: List[str]): + if RAW_ID_CHECKER in checkers: + check_raw_id(df) + if NUMERIC_COLUMNS_CHECKER in checkers: + check_numeric_columns(df) + + +def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description='dataset checker task') + subparsers = parser.add_subparsers(dest='command', help='sub-command help') + + # image parser + image_parser = subparsers.add_parser('image', help='check image format dataset') + + # tabular parser + tabular_parser = subparsers.add_parser('tabular', help='check tabular format dataset') + + tabular_parser.add_argument('--input_batch_path', + type=str, + required=True, + help='input batch path of the tabular dataset') + tabular_parser.add_argument('--format', + type=FileFormat, + choices=list(FileFormat), + required=True, + help='file format') + tabular_parser.add_argument('--checkers', type=str, required=True, help='checkers') + + # image parser + none_structured_parser = subparsers.add_parser('none_structured', help='check none_structured format dataset') + + # all needed args for both image/tabular will be given, so we use known_args to ignore unnecessary args + known_args, _ = parser.parse_known_args() + return known_args + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + try: + args = get_args() + except SystemExit: + write_termination_message(AreaCode.FORMAT_CHECKER, ErrorType.INPUT_PARAMS_ERROR, + 'input params error, check details in logs') + raise + logging.info(f'[format checker]:\n' + '----------------------\n' + 'Input params:\n' + f'{args.__dict__}\n' + '----------------------\n') + conf: SparkConf = build_spark_conf() + spark = SparkSession.builder.config(conf=conf).getOrCreate() + try: + if args.command == 'image': + # image data + logging.info('[format checker]: image type has no checker now, [SKIP]') + elif args.command == 'none_structured': + # none_structured data + logging.info('[format checker]: none_structured type has no checker now, [SKIP]') + else: + input_batch_path = normalize_file_path(args.input_batch_path) + # tabular data + if not is_file_matched(input_batch_path): + logging.warning(f'input_dataset_path {input_batch_path} matches 0 files') + sys.exit() + try: + dataframe = load_by_file_format(spark, input_batch_path, args.format) + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.FORMAT_CHECKER, ErrorType.DATA_LOAD_ERROR, + f'failed to read input data, err: {str(e)}') from e + check_format(dataframe, args.checkers.split(',')) + except JobException as e: + write_termination_message(e.area_code, e.error_type, e.message) + raise + finally: + spark.stop() diff --git a/web_console_v2/inspection/dataset_format_checker_test.py b/web_console_v2/inspection/dataset_format_checker_test.py new file mode 100644 index 000000000..758fcd334 --- /dev/null +++ b/web_console_v2/inspection/dataset_format_checker_test.py @@ -0,0 +1,104 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest +import os +import fsspec + +from testing.spark_test_case import PySparkTestCase +from pyspark.sql.types import IntegerType, StructField, StructType, StringType, LongType, DoubleType +from pyspark.sql.dataframe import DataFrame + +from dataset_format_checker import check_format, JobException +from dataset_directory import DatasetDirectory +from util import FileFormat, load_by_file_format + + +class FormatCheckerTest(PySparkTestCase): + + def setUp(self) -> None: + super().setUp() + self._data_path = os.path.join(self.tmp_dataset_path, 'test_dataset') + self._dataset_directory = DatasetDirectory(self._data_path) + self._batch_name = 'test_batch' + self.maxDiff = None + + def tearDown(self) -> None: + fs = fsspec.filesystem('file') + if fs.isdir(self._data_path): + fs.rm(self._data_path, recursive=True) + return super().tearDown() + + def _generate_tfrecords_tabular(self) -> DataFrame: + data = [ + (1, 2, 2, 3, 'cat', 'image_1.jpg', 3.21), + (2, 1, 2, 3, 'dog', 'image_2.jpg', 3.23), + (3, 3, 2, 3, 'cat', 'image_3.jpg', 3.26), + ] + schema = StructType([ + StructField('example_id', IntegerType(), False), + StructField('height', LongType(), False), + StructField('width', IntegerType(), False), + StructField('nChannels', IntegerType(), False), + StructField('label', StringType(), False), + StructField('file_name', StringType(), False), + StructField('score', DoubleType(), False), + ]) + return self.spark.createDataFrame(data=data, schema=schema) + + def _generate_tfrecords_tabular_duplicate_raw_id(self) -> DataFrame: + data = [ + (1, 2, 2, 3, 3.21), + (1, 1, 2, 3, 3.23), + (3, 3, 2, 3, 3.26), + ] + schema = StructType([ + StructField('raw_id', IntegerType(), False), + StructField('height', LongType(), False), + StructField('width', IntegerType(), False), + StructField('nChannels', IntegerType(), False), + StructField('score', DoubleType(), False), + ]) + return self.spark.createDataFrame(data=data, schema=schema) + + def test_format_checker(self): + # check succeeded + input_batch_path = os.path.join(self.test_data, 'csv/medium_csv') + file_format = FileFormat.CSV + checkers = ['RAW_ID_CHECKER', 'NUMERIC_COLUMNS_CHECKER'] + df = load_by_file_format(self.spark, input_batch_path, file_format) + check_format(df, checkers) + + def test_no_raw_id(self): + df = self._generate_tfrecords_tabular() + checkers = ['RAW_ID_CHECKER'] + with self.assertRaises(JobException): + check_format(df, checkers) + + def test_duplicated_raw_id(self): + df = self._generate_tfrecords_tabular_duplicate_raw_id() + checkers = ['RAW_ID_CHECKER'] + with self.assertRaises(JobException): + check_format(df, checkers) + + def test_numeric_check_failed(self): + df = self._generate_tfrecords_tabular() + checkers = ['NUMERIC_COLUMNS_CHECKER'] + with self.assertRaises(JobException): + check_format(df, checkers) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/debug.py b/web_console_v2/inspection/debug.py new file mode 100644 index 000000000..daf1f57e4 --- /dev/null +++ b/web_console_v2/inspection/debug.py @@ -0,0 +1,18 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time + +time.sleep(86400 * 7) diff --git a/web_console_v2/inspection/envs.py b/web_console_v2/inspection/envs.py new file mode 100644 index 000000000..9bd9b820a --- /dev/null +++ b/web_console_v2/inspection/envs.py @@ -0,0 +1,18 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os + +TERMINATION_LOG_PATH = os.getenv('TERMINATION_LOG_PATH', '/dev/termination-log') diff --git a/web_console_v2/inspection/error_code.py b/web_console_v2/inspection/error_code.py new file mode 100644 index 000000000..350011eff --- /dev/null +++ b/web_console_v2/inspection/error_code.py @@ -0,0 +1,79 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import enum +import fsspec + +import envs + + +@enum.unique +class AreaCode(enum.IntEnum): + UNKNOWN = 0 + PARTITION = 1 + FEATURE_EXTRACTION = 2 + FORMAT_CHECKER = 3 + CONVERTER = 4 + ANALYZER = 5 + ALIGNMENT = 6 + PSI_OT = 7 + PSI_RSA = 8 + PSI_HASH = 9 + TRAINER = 10 + EXPORT_DATASET = 11 + + +@enum.unique +class ErrorType(enum.IntEnum): + # system error + OUT_OF_MEMORY = 1 + CHANNEL_ERROR = 2 + # params error + DATA_FORMAT_ERROR = 1001 + NO_KEY_COLUMN_ERROR = 1002 + DATA_NOT_FOUND = 1003 + DATA_LOAD_ERROR = 1004 + INPUT_PARAMS_ERROR = 1005 + DATA_WRITE_ERROR = 1006 + SCHEMA_CHECK_ERROR = 1007 + + # other error + RESULT_ERROR = 2001 + + +def build_full_error_code(area_code: AreaCode, error_type: ErrorType) -> str: + return str(area_code.value).zfill(4) + str(error_type.value).zfill(4) + + +class JobException(Exception): + + def __init__(self, area_code: AreaCode, error_type: ErrorType, message: str): + super().__init__(message) + self.area_code = area_code + self.error_type = error_type + self.message = message + + def __repr__(self): + return f'{type(self).__name__}({build_full_error_code(self.area_code, self.error_type)}-{self.message})' + + def __str__(self) -> str: + return f'{build_full_error_code(self.area_code, self.error_type)}-{self.message}' + + +def write_termination_message(area_code: AreaCode, error_type: ErrorType, error_message: str): + error_code = build_full_error_code(area_code, error_type) + termitation_message = f'{error_code}-{error_message}' + with fsspec.open(envs.TERMINATION_LOG_PATH, 'w') as f: + f.write(termitation_message) diff --git a/web_console_v2/inspection/error_code_test.py b/web_console_v2/inspection/error_code_test.py new file mode 100644 index 000000000..c0a7ca979 --- /dev/null +++ b/web_console_v2/inspection/error_code_test.py @@ -0,0 +1,38 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest +import os +import tempfile +import fsspec + +import envs +from error_code import AreaCode, ErrorType, write_termination_message + + +class ErrorCodeTest(unittest.TestCase): + + def test_write_terminaton_message(self): + with tempfile.TemporaryDirectory() as tmp_dir: + envs.TERMINATION_LOG_PATH = os.path.join(tmp_dir, 'log_file') + write_termination_message(AreaCode.FORMAT_CHECKER, ErrorType.DATA_FORMAT_ERROR, 'format check failed') + expected_errors = '00031001-format check failed' + with fsspec.open(envs.TERMINATION_LOG_PATH, mode='r') as f: + errors = f.read() + self.assertEqual(expected_errors, errors) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/export_dataset.py b/web_console_v2/inspection/export_dataset.py new file mode 100644 index 000000000..3057ffb42 --- /dev/null +++ b/web_console_v2/inspection/export_dataset.py @@ -0,0 +1,103 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import logging +import argparse +import fsspec + +from pyspark.sql import SparkSession +from pyspark.conf import SparkConf +from dataset_directory import DatasetDirectory +from error_code import AreaCode, ErrorType, JobException, write_termination_message +from util import FileFormat, build_spark_conf, load_by_file_format + + +def export_dataset_for_structured_type(input_path: str, export_path: str, file_format: FileFormat): + try: + conf: SparkConf = build_spark_conf() + spark = SparkSession.builder.config(conf=conf).getOrCreate() + try: + df = load_by_file_format(spark=spark, input_batch_path=input_path, file_format=file_format) + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.EXPORT_DATASET, ErrorType.DATA_LOAD_ERROR, + f'failed to read input data, err: {str(e)}') from e + try: + df.write.format('csv').option('compression', 'none').option('header', 'true').save(path=export_path, + mode='overwrite') + except Exception as e: # pylint: disable=broad-except + raise JobException(AreaCode.EXPORT_DATASET, ErrorType.DATA_WRITE_ERROR, + f'failed to write data, err: {str(e)}') from e + finally: + spark.stop() + + +def export_dataset_for_unknown_type(input_path: str, export_path: str): + fs: fsspec.spec.AbstractFileSystem = fsspec.get_mapper(export_path).fs + if fs.exists(export_path): + fs.rm(export_path, recursive=True) + fs.copy(input_path, export_path, recursive=True) + + +def get_args(args=None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description='export dataset') + parser.add_argument('--data_path', type=str, help='path of intput') + parser.add_argument('--export_path', type=str, help='path of output') + parser.add_argument('--batch_name', type=str, required=False, help='batch need to export') + parser.add_argument('--file_format', + type=FileFormat, + choices=list(FileFormat), + default=FileFormat.TFRECORDS, + required=False, + help='file format') + # TODO(liuhehan): delete file_wildcard input after export job support batch + parser.add_argument('--file_wildcard', type=str, required=False, help='input file wildcard') + + return parser.parse_args(args) + + +def export_dataset(): + try: + args = get_args() + except SystemExit: + write_termination_message(AreaCode.EXPORT_DATASET, ErrorType.INPUT_PARAMS_ERROR, + 'input params error, check details in logs') + raise + logging.info(f'[export_dataset]:\n' + '----------------------\n' + 'Input params:\n' + f'{args.__dict__}\n' + '----------------------\n') + + if args.batch_name: + input_path = DatasetDirectory(dataset_path=args.data_path).batch_path(batch_name=args.batch_name) + else: + # TODO(liuhehan): delete after export job support batch + input_path = os.path.join(args.data_path, args.file_wildcard) + try: + if args.file_format == FileFormat.UNKNOWN: + export_dataset_for_unknown_type(input_path=input_path, export_path=args.export_path) + else: + export_dataset_for_structured_type(input_path=input_path, + export_path=args.export_path, + file_format=args.file_format) + except JobException as e: + write_termination_message(e.area_code, e.error_type, e.message) + raise + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + export_dataset() diff --git a/web_console_v2/inspection/export_dataset_test.py b/web_console_v2/inspection/export_dataset_test.py new file mode 100644 index 000000000..489ed15a6 --- /dev/null +++ b/web_console_v2/inspection/export_dataset_test.py @@ -0,0 +1,122 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import argparse +import os +import fsspec +import unittest +from unittest.mock import MagicMock, patch + +from pyspark import SparkConf + +from export_dataset import get_args, export_dataset, export_dataset_for_unknown_type, export_dataset_for_structured_type +from testing.spark_test_case import PySparkTestCase +from util import FileFormat + + +class AnalyzerTest(PySparkTestCase): + + def test_get_args(self): + data_path = '/data/fake_path' + file_wildcard = 'batch/**/**' + batch_name = '20220101' + export_path = '/data/export' + file_format_unknown = 'unknown' + + # test file_wildcard + args = get_args([f'--data_path={data_path}', f'--file_wildcard={file_wildcard}', \ + f'--export_path={export_path}']) + self.assertEqual(args.data_path, data_path) + self.assertEqual(args.file_wildcard, file_wildcard) + self.assertEqual(args.export_path, export_path) + self.assertEqual(args.file_format, FileFormat.TFRECORDS) + self.assertIsNone(args.batch_name) + + # test batch_name + args = get_args([f'--data_path={data_path}', f'--batch_name={batch_name}', f'--export_path={export_path}', \ + f'--file_format={file_format_unknown}']) + self.assertEqual(args.data_path, data_path) + self.assertIsNone(args.file_wildcard) + self.assertEqual(args.export_path, export_path) + self.assertEqual(args.file_format, FileFormat.UNKNOWN) + self.assertEqual(args.batch_name, batch_name) + + @patch('export_dataset.export_dataset_for_unknown_type') + @patch('export_dataset.export_dataset_for_structured_type') + @patch('export_dataset.get_args') + def test_export_dataset(self, mock_get_args: MagicMock, mock_export_dataset_for_structured_type: MagicMock, + mock_export_dataset_for_unknown_type: MagicMock): + data_path = '/data/fake_path' + file_wildcard = 'batch/**/**' + batch_name = '20220101' + export_path = '/data/export/20220101' + file_format_tf = 'tfrecords' + file_format_unknown = 'unknown' + + # test use spark + mock_get_args.return_value = argparse.Namespace(data_path=data_path, + file_wildcard=file_wildcard, + export_path=export_path, + batch_name=None, + file_format=file_format_tf) + export_dataset() + mock_export_dataset_for_structured_type.assert_called_once_with(input_path='/data/fake_path/batch/**/**', + export_path='/data/export/20220101', + file_format=FileFormat.TFRECORDS) + mock_export_dataset_for_unknown_type.assert_not_called() + + mock_get_args.reset_mock() + mock_export_dataset_for_structured_type.reset_mock() + mock_export_dataset_for_unknown_type.reset_mock() + + # test use fsspec + mock_get_args.return_value = argparse.Namespace(data_path=data_path, + export_path=export_path, + batch_name=batch_name, + file_format=file_format_unknown) + export_dataset() + mock_export_dataset_for_unknown_type.assert_called_once_with(input_path='/data/fake_path/batch/20220101', + export_path='/data/export/20220101') + mock_export_dataset_for_structured_type.assert_not_called() + + @patch('export_dataset.build_spark_conf') + def test_export_dataset_for_structured_type(self, mock_build_spark_conf: MagicMock): + # set local spark + mock_build_spark_conf.return_value = SparkConf().setMaster('local') + + input_path = os.path.join(self.test_data, 'csv/medium_csv') + export_path = self.tmp_dataset_path + + export_dataset_for_structured_type(input_path=input_path, export_path=export_path, file_format=FileFormat.CSV) + fs = fsspec.filesystem('file') + files = fs.ls(export_path) + file_names = {f.split('/')[-1] for f in files} + expect_file_names = {'_SUCCESS'} + self.assertTrue(expect_file_names.issubset(file_names)) + + def test_export_dataset_for_unknown_type(self): + input_path = os.path.join(self.test_data, 'csv/medium_csv') + export_path = self.tmp_dataset_path + + export_dataset_for_unknown_type(input_path=input_path, export_path=export_path) + fs = fsspec.filesystem('file') + files = fs.ls(export_path) + file_names = {f.split('/')[-1] for f in files} + expect_file_names = {'default_credit_hetero_guest.csv'} + self.assertEqual(expect_file_names, file_names) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/fake_data.py b/web_console_v2/inspection/fake_data.py new file mode 100644 index 000000000..0bcc2ec10 --- /dev/null +++ b/web_console_v2/inspection/fake_data.py @@ -0,0 +1,87 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# pylint: skip-file +import logging +import argparse + +from pyspark.sql import SparkSession +from pyspark.sql.types import StringType +from pyspark.mllib.random import RandomRDDs +import pyspark.sql.functions as f + +_RAW_ID = 'raw_id' +_DEFAULT_PARTITIONS = 16 + +DISTRIBUTION_FUNC_MAP = { + 'normal': RandomRDDs.normalVectorRDD, + 'uniform': RandomRDDs.uniformVectorRDD, + 'exponential': RandomRDDs.exponentialVectorRDD, + 'log': RandomRDDs.logNormalVectorRDD, + 'gamma': RandomRDDs.gammaVectorRDD +} + + +def make_data(output_dir_path: str, + items_num: int, + features_num: int, + partitions_num: int = _DEFAULT_PARTITIONS, + distribution: str = 'uniform'): + logging.info('========start========') + output_dir_path = output_dir_path.strip() + spark = SparkSession.builder.getOrCreate() + sc = spark.sparkContext + if distribution in DISTRIBUTION_FUNC_MAP: + feature_df = DISTRIBUTION_FUNC_MAP[distribution](sc, items_num, features_num, + partitions_num).map(lambda a: a.tolist()).toDF() + else: + logging.error(f'### no valid distribution: {distribution}') + return + df = feature_df.withColumn(_RAW_ID, f.md5(feature_df._1.cast(StringType()))) + df.write.format('csv').option('compression', 'none').option('header', 'true').save(path=output_dir_path, + mode='overwrite') + spark.stop() + logging.info('========done========') + + +def get_args(): + parser = argparse.ArgumentParser(description='convert bio dataset to coco format dataset.') + parser.add_argument('--output_dir_path', + '-o', + required=True, + type=str, + dest='output_dir_path', + help='dir of output') + parser.add_argument('--items', '-i', type=int, required=True, dest='items_num', help='number of items') + parser.add_argument('--features', '-f', type=int, required=True, dest='features_num', help='number of features') + parser.add_argument('--partitions_num', '-p', type=int, required=False, dest='partitions_num') + parser.add_argument('--distribution', + '-d', + required=False, + type=str, + dest='distribution', + help='the distribution of the feature. could be: normal/uniform/exponential/log/gamma') + return parser.parse_args() + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') + args = get_args() + logging.info(f'\toutput dir: {args.output_dir_path}\n' + f'\titems_num is: {args.items_num}\n' + f'\tfeature_num is: {args.features_num}\n' + f'\tpartitions_num is: {args.partitions_num if args.partitions_num else _DEFAULT_PARTITIONS}\n' + f'\tdistribution is: {args.distribution}') + make_data(args.output_dir_path, args.items_num, args.features_num, args.partitions_num) diff --git a/web_console_v2/inspection/feature_extraction_v2.py b/web_console_v2/inspection/feature_extraction_v2.py new file mode 100644 index 000000000..98b44f96d --- /dev/null +++ b/web_console_v2/inspection/feature_extraction_v2.py @@ -0,0 +1,93 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# pylint: disable=redefined-outer-name +import json +import fsspec +import logging +import argparse +from cityhash import CityHash64 # pylint: disable=no-name-in-module + +from pyspark.conf import SparkConf +from pyspark.sql import SparkSession +from pyspark.sql.types import StringType + +from dataset_directory import DatasetDirectory +from util import build_spark_conf, FileFormat, EXAMPLE_ID, is_file_matched + + +# TODO(hangweiqiang): implement partition-wise join in parallel +def feature_extraction(spark: SparkSession, original_data_path: str, joined_data_path: str, part_num: int, + part_key: str, file_format: FileFormat, output_file_format: FileFormat, output_batch_name: str, + output_dataset_path: str): + dataset_dir = DatasetDirectory(output_dataset_path) + output_batch_path = dataset_dir.batch_path(output_batch_name) + if not is_file_matched(joined_data_path): + # this is a hack to allow empty intersection dataset + # no file matched, just mkdir output_batch_path and skip converter + fs: fsspec.AbstractFileSystem = fsspec.get_mapper(output_batch_path).fs + fs.mkdir(output_batch_path) + logging.warning(f'[feature_extraction]: joined_dataset_path {joined_data_path} matches 0 files, [SKIP]') + return + joined_df = spark.read.format(FileFormat.CSV.value).option('header', 'true').load(joined_data_path).toDF(part_key) + original_df = spark.read.format(file_format.value).option('header', 'true').load(original_data_path) + df = joined_df.join(original_df, on=part_key) + # use customized partition method to guarantee consistency of sample between parties + # TODO(liuhehan): unify partitioning method of output batch + sorted_df = df.rdd.keyBy(lambda v: v[part_key]) \ + .partitionBy(part_num, CityHash64) \ + .map(lambda v: v[1]) \ + .toDF(schema=df.schema) \ + .sortWithinPartitions(part_key) + sorted_df = sorted_df.withColumn(part_key, sorted_df[part_key].cast(StringType())) + # a hack to generate example_id column if not exist as training need example id + if EXAMPLE_ID not in sorted_df.columns: + sorted_df = sorted_df.withColumn(EXAMPLE_ID, sorted_df[part_key]) + sorted_df.write.format(output_file_format.value).option('compression', + 'none').option('header', 'true').save(output_batch_path, + mode='overwrite') + with fsspec.open(dataset_dir.schema_file, mode='w') as f: + json.dump(sorted_df.schema.jsonValue(), f) + spark.stop() + + +def get_args(): + parser = argparse.ArgumentParser(description='extract feature from dataset') + parser.add_argument('--original_data_path', type=str, help='original data path') + parser.add_argument('--joined_data_path', type=str, help='path of joined id') + parser.add_argument('--part_key', type=str, help='partition key') + parser.add_argument('--part_num', type=int, help='patition number') + parser.add_argument('--file_format', type=FileFormat, choices=list(FileFormat), help='format of original file') + parser.add_argument('--output_file_format', type=FileFormat, choices=list(FileFormat), help='format of output file') + parser.add_argument('--output_batch_name', type=str, help='name of output batch') + parser.add_argument('--output_dataset_path', type=str, help='path of output dataset') + return parser.parse_args() + + +if __name__ == '__main__': + args = get_args() + for arg, value in vars(args).items(): + logging.info(f'Arg: {arg}, value: {value}') + conf: SparkConf = build_spark_conf() + spark = SparkSession.builder.config(conf=conf).getOrCreate() + feature_extraction(spark=spark, + original_data_path=args.original_data_path, + joined_data_path=args.joined_data_path, + part_key=args.part_key, + part_num=args.part_num, + file_format=args.file_format, + output_file_format=args.output_file_format, + output_batch_name=args.output_batch_name, + output_dataset_path=args.output_dataset_path) diff --git a/web_console_v2/inspection/feature_extraction_v2_test.py b/web_console_v2/inspection/feature_extraction_v2_test.py new file mode 100644 index 000000000..b3015fcbd --- /dev/null +++ b/web_console_v2/inspection/feature_extraction_v2_test.py @@ -0,0 +1,87 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import csv +import unittest + +from cityhash import CityHash64 # pylint: disable=no-name-in-module + +from testing.spark_test_case import PySparkTestCase +from dataset_directory import DatasetDirectory +from feature_extraction_v2 import feature_extraction, FileFormat +from util import EXAMPLE_ID + + +class FeatureExtractionV2Test(PySparkTestCase): + + def test_feature_extract(self): + part_num = 3 + file_format = FileFormat.CSV + dataset_dir = DatasetDirectory(self.tmp_dataset_path) + batch_name = '20220331-1200' + side_output_path = dataset_dir.side_output_path(batch_name) + raw_data_path = os.path.join(side_output_path, 'raw') + joined_data_path = os.path.join(side_output_path, 'joined') + output_batch_path = dataset_dir.batch_path(batch_name) + # test no data + os.makedirs(joined_data_path, exist_ok=True) + feature_extraction(self.spark, + original_data_path=raw_data_path, + joined_data_path=joined_data_path, + part_num=part_num, + part_key='raw_id', + file_format=file_format, + output_file_format=FileFormat.CSV, + output_batch_name=batch_name, + output_dataset_path=self.tmp_dataset_path) + self.assertTrue(os.path.exists(output_batch_path)) + # write raw data + data = [(str(i), f'x{str(i)}', i + 1) for i in range(1000)] + df = self.spark.createDataFrame(data=data, schema=['raw_id', 'name', 'age']) + df.write.format(file_format.value).option('compression', 'none').option('header', 'true').save(raw_data_path, + mode='overwrite') + # write joined id + joined_id = [(str(i)) for i in range(0, 1000, 4)] + os.makedirs(joined_data_path, exist_ok=True) + expected_ids_list = [] + for part_id in range(part_num): + expected_ids_list.append([i for i in joined_id if CityHash64(i) % part_num == part_id]) + with open(os.path.join(joined_data_path, f'partition_{part_id}'), 'w', encoding='utf-8') as f: + f.write('raw_id\n') + f.write('\n'.join(expected_ids_list[part_id])) + + feature_extraction(self.spark, + original_data_path=raw_data_path, + joined_data_path=joined_data_path, + part_num=part_num, + part_key='raw_id', + file_format=file_format, + output_file_format=FileFormat.CSV, + output_batch_name=batch_name, + output_dataset_path=self.tmp_dataset_path) + # check raw_id and example_id from extracted data + filenames = list(filter(lambda file: file.startswith('part'), sorted(os.listdir(output_batch_path)))) + for part_id, file in enumerate(filenames): + with open(os.path.join(output_batch_path, file), 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + self.assertEqual([row['raw_id'] for row in reader], sorted(expected_ids_list[part_id])) + with open(os.path.join(output_batch_path, file), 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + self.assertEqual([row[EXAMPLE_ID] for row in reader], sorted(expected_ids_list[part_id])) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/json_schema_checker.py b/web_console_v2/inspection/json_schema_checker.py new file mode 100644 index 000000000..5b2d88e6a --- /dev/null +++ b/web_console_v2/inspection/json_schema_checker.py @@ -0,0 +1,46 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from typing import Any, Dict, List +import logging + +import jsonschema + +_SCHEMA_CHECK_LOG = 'schema check' + + +class SchemaChecker(object): + + def __init__(self, schema: Dict[Any, Any]): + self._schema = schema + try: + # spark broadcast use pickle serialization, cannot store draft7validator as a broadcast value + jsonschema.Draft7Validator(self._schema) + except Exception as e: # pylint: disable=broad-except + message = f'[{_SCHEMA_CHECK_LOG}] schema format error: schema format is invalid' + logging.error(message) + raise RuntimeError(message) from e + + def check(self, data: Dict) -> List[Dict[str, str]]: + error_msgs = [] + # convert bytearray to string as json_schema only support string + check_data = data.copy() + for key, value in check_data.items(): + if isinstance(value, (bytes, bytearray)): + check_data[key] = str(value) + for error in jsonschema.Draft7Validator(self._schema).iter_errors(check_data): + # TODO(lhh): schema check add example id to location row number + error_msgs.append({'field': '.'.join(error.absolute_path), 'message': error.message}) + return error_msgs diff --git a/web_console_v2/inspection/json_schema_checker_test.py b/web_console_v2/inspection/json_schema_checker_test.py new file mode 100644 index 000000000..789b041dc --- /dev/null +++ b/web_console_v2/inspection/json_schema_checker_test.py @@ -0,0 +1,94 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest + +from json_schema_checker import SchemaChecker + + +class JsonSchemaCheckerTest(unittest.TestCase): + + def test_schema_check(self): + json_schema = { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + }, + 'age': { + 'type': 'integer', + }, + 'father': { + 'type': 'string', + }, + 'son': { + 'type': 'null', + }, + 'is_student': { + 'type': 'boolean', + }, + 'video': { + 'type': 'string' + } + }, + 'additionalProperties': False, + 'required': [ + 'name', + 'age', + 'father', + 'son', + 'is_student', + ] + } + checker = SchemaChecker(json_schema) + + data_1 = { + 'age': 10, + 'name': 'xiaoming', + 'father': 'old xiaoming', + 'son': None, + 'is_student': True, + 'video': bytearray([1, 2, 3]), + } + self.assertEqual(checker.check(data_1), []) + + data_2 = { + 'age': '10', + 'name': 'xiaoming', + 'father': 'old xiaoming', + 'mother': 'old xiaoming', + 'son': None, + 'video': b'test video', + } + error_msgs = [{ + 'field': 'age', + 'message': '\'10\' is not of type \'integer\'' + }, { + 'field': '', + 'message': 'Additional properties are not allowed (\'mother\' was unexpected)' + }, { + 'field': '', + 'message': '\'is_student\' is a required property' + }] + self.assertEqual(checker.check(data_2), error_msgs) + + def test_init_exception(self): + json_schema_error_format = {'this is illegal format'} + with self.assertRaises(RuntimeError): + SchemaChecker(json_schema_error_format) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/partition.py b/web_console_v2/inspection/partition.py new file mode 100644 index 000000000..610a069d9 --- /dev/null +++ b/web_console_v2/inspection/partition.py @@ -0,0 +1,102 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import logging +import argparse + +from cityhash import CityHash64 # pylint: disable=no-name-in-module +from pyspark.conf import SparkConf +from pyspark.sql import SparkSession +from pyspark.sql.types import StringType +import fsspec + +from util import FileFormat, build_spark_conf, is_file_matched + + +# pylint: disable=redefined-outer-name +def partition(spark: SparkSession, input_path: str, file_format: FileFormat, output_file_format: FileFormat, + output_dir: str, part_num: int, part_key: str, write_raw_data: bool): + logging.info(f'[Partition] loading df..., input files path: {input_path}') + raw_path = os.path.join(output_dir, 'raw') + id_path = os.path.join(output_dir, 'ids') + if not is_file_matched(input_path): + # this is a hack to allow empty intersection dataset + # no file matched, just mkdir output_batch_path and skip converter + fs: fsspec.AbstractFileSystem = fsspec.get_mapper(output_dir).fs + if write_raw_data: + fs.mkdir(raw_path) + fs.mkdir(id_path) + logging.warning(f'[partition]: input_dataset_path {input_path} matches 0 files, [SKIP]') + return + df = spark.read.format(file_format.value).load(input_path, header=True, inferSchema=True) + if part_key not in df.columns: + raise ValueError(f'[Partition] error: part_id {part_key} not in df columns') + df = df.dropDuplicates([part_key]) + df = df.withColumn(part_key, df[part_key].cast(StringType())) + df.printSchema() + + logging.info('[Partition] start partitioning') + # spark operation steps explanations + # keyBy : use specified column as the index key used in partition, + # partitionBy(partition_num, func) : pass index key to func, and use the func result mod partition_num, + # map: remove the index key, + # toDF: change to dataframe by origin df schema + # partitionBy code ref: + # https://github.com/apache/spark/blob/master/python/pyspark/rdd.py#L2114 + # https://github.com/apache/spark/blob/7d88f1c5c7f38c0f1a2bd5e3116c668d9cbd98b1/python/pyspark/rdd.py#L251 + # define a customized partition method to ensure partition consistency between two party + # missing value when writing df as tfrecord if without sortWithinPartitions and the reason is unknown + df = df.rdd.keyBy(lambda v: v[part_key]) \ + .partitionBy(part_num, CityHash64) \ + .map(lambda v: v[1]) \ + .toDF(schema=df.schema) \ + .sortWithinPartitions(part_key) + if write_raw_data: + logging.info(f'[Partition] writing to raw path: {raw_path}') + df.write.format(output_file_format.value).option('compression', 'none').option('header', + 'true').save(raw_path, + mode='overwrite') + id_df = df.select(part_key) + id_df.write.format('csv').option('compression', 'none').option('header', 'true').save(id_path, mode='overwrite') + + +def get_args(): + parser = argparse.ArgumentParser(description='Partition the dataset') + parser.add_argument('--input_path', type=str, help='input data path with wildcard') + parser.add_argument('--file_format', type=FileFormat, choices=list(FileFormat), help='format of input data') + parser.add_argument('--part_num', type=int, help='patition number') + parser.add_argument('--part_key', type=str, help='patition key') + parser.add_argument('--output_file_format', type=FileFormat, choices=list(FileFormat), help='format of output file') + parser.add_argument('--output_dir', type=str, help='name of output batch') + parser.add_argument('--write_raw_data', type=bool, default=True, help='whether to write partitioned raw data') + return parser.parse_args() + + +if __name__ == '__main__': + args = get_args() + for arg, value in vars(args).items(): + logging.info(f'Arg: {arg}, value: {value}') + conf: SparkConf = build_spark_conf() + spark = SparkSession.builder.config(conf=conf).getOrCreate() + partition(spark, + input_path=args.input_path, + file_format=args.file_format, + output_file_format=args.output_file_format, + output_dir=args.output_dir, + part_num=args.part_num, + part_key=args.part_key, + write_raw_data=args.write_raw_data) + spark.stop() diff --git a/web_console_v2/inspection/partition_test.py b/web_console_v2/inspection/partition_test.py new file mode 100644 index 000000000..e217f9718 --- /dev/null +++ b/web_console_v2/inspection/partition_test.py @@ -0,0 +1,104 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import csv +import shutil +import unittest +import tempfile + +from cityhash import CityHash64 # pylint: disable=no-name-in-module + +from testing.spark_test_case import PySparkTestCase +from dataset_directory import DatasetDirectory +from partition import partition, FileFormat + + +class FeatureExtractionV2Test(PySparkTestCase): + + def test_partition(self): + part_num = 3 + file_format = FileFormat.CSV + data = [(str(i), f'x{str(i)}', i + 1) for i in range(1000)] + df = self.spark.createDataFrame(data=data, schema=['raw_id', 'name', 'age']) + expected_ids_list = [] + for part_id in range(part_num): + expected_ids_list.append([d[0] for d in data if CityHash64(d[0]) % part_num == part_id]) + # test write raw data + with tempfile.TemporaryDirectory() as input_path: + df.write.format(file_format.value).option('header', 'true').save(input_path, mode='overwrite') + dataset_dir = DatasetDirectory(self.tmp_dataset_path) + batch_name = '20220331-1200' + side_output_path = dataset_dir.side_output_path(batch_name) + partition(spark=self.spark, + input_path=input_path, + file_format=file_format, + output_file_format=FileFormat.CSV, + output_dir=side_output_path, + part_num=part_num, + part_key='raw_id', + write_raw_data=True) + raw_data_path = os.path.join(side_output_path, 'raw') + filenames = list(filter(lambda file: file.startswith('part'), sorted(os.listdir(raw_data_path)))) + for part_id, file in enumerate(filenames): + with open(os.path.join(raw_data_path, file), 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + self.assertEqual(sorted([row['raw_id'] for row in reader]), sorted(expected_ids_list[part_id])) + ids_data_path = os.path.join(side_output_path, 'ids') + filenames = list(filter(lambda file: file.startswith('part'), sorted(os.listdir(ids_data_path)))) + for part_id, file in enumerate(filenames): + with open(os.path.join(ids_data_path, file), 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + self.assertEqual(sorted([row['raw_id'] for row in reader]), sorted(expected_ids_list[part_id])) + shutil.rmtree(self.tmp_dataset_path) + # test not write raw data + with tempfile.TemporaryDirectory() as input_path: + df.write.format(file_format.value).option('header', 'true').save(input_path, mode='overwrite') + dataset_dir = DatasetDirectory(self.tmp_dataset_path) + batch_name = '20220331-1200' + side_output_path = dataset_dir.side_output_path(batch_name) + partition(spark=self.spark, + input_path=input_path, + file_format=file_format, + output_file_format=FileFormat.CSV, + output_dir=side_output_path, + part_num=part_num, + part_key='raw_id', + write_raw_data=False) + raw_data_path = os.path.join(side_output_path, 'raw') + self.assertFalse(os.path.exists(raw_data_path)) + shutil.rmtree(self.tmp_dataset_path) + # test no data + with tempfile.TemporaryDirectory() as input_path: + dataset_dir = DatasetDirectory(self.tmp_dataset_path) + batch_name = '20220331-1200' + side_output_path = dataset_dir.side_output_path(batch_name) + partition(spark=self.spark, + input_path=input_path, + file_format=file_format, + output_file_format=FileFormat.CSV, + output_dir=side_output_path, + part_num=part_num, + part_key='raw_id', + write_raw_data=True) + raw_data_path = os.path.join(side_output_path, 'raw') + self.assertTrue(os.path.exists(raw_data_path)) + ids_data_path = os.path.join(side_output_path, 'ids') + self.assertTrue(os.path.exists(ids_data_path)) + shutil.rmtree(self.tmp_dataset_path) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/psi.py b/web_console_v2/inspection/psi.py new file mode 100644 index 000000000..00cdd8ea6 --- /dev/null +++ b/web_console_v2/inspection/psi.py @@ -0,0 +1,153 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# pylint: skip-file +import os +import logging +from enum import Enum + +import rsa +import fsspec +from cityhash import CityHash64 +from gmpy2 import powmod +from pyspark.sql import SparkSession, DataFrame +from pyspark.sql.types import StringType +from pyspark.sql.functions import udf + +from util import getenv, FileFormat + + +class PSI(object): + + def __init__(self, + input_dir: str, + wildcard: str, + file_format: FileFormat, + output_dir: str, + part_num: int, + part_key: str, + rsa_key_path: str, + rsa_key_bits: int = 2048): + """ + Args: + input_dir: origin raw data + wildcard: use wildcard rules to match multiple files + file_format: specific read file format + output_dir: signed data save path + part_num: partition need to set + part_key: partition used key column + rsa_key_path: the private key path, may not exist, if not exist, create one + rsa_key_bits: if need to generate a key, use the argument to specific key bits + """ + self._input_dir = input_dir + self._wildcard = wildcard + self._file_format = file_format + self._output_dir = output_dir + self._part_num = part_num + self._part_key = part_key + self._rsa_key_path = rsa_key_path + self._rsa_key_bits = rsa_key_bits + + self._rsa = None + + def run(self, spark: SparkSession): + self._load_rsa_key() + self._partition_and_sign(spark) + + def _partition_and_sign(self, spark: SparkSession): + files = os.path.join(self._input_dir, self._wildcard) + logging.info(f'### loading df..., input files path: {files}') + df = spark.read.format(self._file_format).load(files, header=True, inferSchema=True) + if self._part_key not in df.columns: + raise ValueError(f'### error: part_id {self._part_key} not in df columns') + df = df.dropDuplicates([self._part_key]) + df = df.withColumn(self._part_key, df[self._part_key].cast(StringType())) + df.printSchema() + + logging.info(f'### partition and sorting') + part_idx = df.columns.index(self._part_key) + + sorted_df = df.rdd.keyBy(lambda v: v[part_idx]) \ + .partitionBy(self._part_num, self.partition_fn) \ + .map(lambda v: v[1]) \ + .toDF(schema=df.schema).sortWithinPartitions(self._part_key) + raw_path = os.path.join(self._output_dir, 'raw') + logging.info(f'### writing to raw path: {raw_path}') + self.write_df(sorted_df, raw_path) + d, n = self._rsa.d, self._rsa.n + + @udf() + def sign(v: str): + s = powmod(self.partition_fn(v), d, n).digits() + # hash and hex to save space + return hex(self.partition_fn(s))[2:] + + logging.info(f'### signing') + part_df = sorted_df.select(self._part_key) + sign_df = part_df.withColumn('signed', sign(part_df[self._part_key])) + sign_path = os.path.join(self._output_dir, 'signed') + logging.info(f'### writing to sign path: {sign_path}') + self.write_df(sign_df, sign_path) + + def _load_rsa_key(self): + fs = fsspec.filesystem('hdfs') + if not fs.exists(self._rsa_key_path): + logging.info('[Signer] key does not exist, generate one') + _, private_key = rsa.newkeys(self._rsa_key_bits) + with fs.open(self._rsa_key_path, 'wb') as f: + f.write(private_key.save_pkcs1(format='PEM')) + + with fs.open(self._rsa_key_path) as f: + logging.info('[Signer] Reading private key.') + self._rsa = rsa.PrivateKey.load_pkcs1(f.read()) + + @staticmethod + def partition_fn(v: str) -> int: + return CityHash64(v) + + @staticmethod + def write_df(data: 'DataFrame', path: str, fmt: str = 'csv'): + data.write.format(fmt).option('compression', 'none').option('header', 'true').save(path, mode='overwrite') + + +def main(): + input_dir = getenv('INPUT_DIR') + wildcard = getenv('WILDCARD', '*') + file_format = FileFormat(os.getenv('FILE_FORMAT', 'tfrecords').lower()) + output_dir = getenv('OUTPUT_DIR') + part_num = int(getenv('PART_NUM', '8')) + part_key = getenv('PART_KEY', 'raw_id') + rsa_key_path = getenv('RSA_KEY_PATH') + rsa_key_bits = int(getenv('RSA_KEY_BITS')) + + logging.info(f'preparing psi, input_dir: {input_dir}, wildcard: {wildcard} output_dir: {output_dir}, ' + f'part_num: {part_num}, part_key: {part_key}, rsa_key_path: {rsa_key_path}, ' + f'rsa_key_bits: {rsa_key_bits}') + + spark = SparkSession.builder.getOrCreate() + PSI(input_dir=input_dir, + wildcard=wildcard, + file_format=file_format, + output_dir=output_dir, + part_num=part_num, + part_key=part_key, + rsa_key_path=rsa_key_path, + rsa_key_bits=rsa_key_bits).run(spark) + spark.stop() + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + main() diff --git a/web_console_v2/inspection/requirements.txt b/web_console_v2/inspection/requirements.txt new file mode 100644 index 000000000..3e18b5d8f --- /dev/null +++ b/web_console_v2/inspection/requirements.txt @@ -0,0 +1,10 @@ +pandas==1.1.5 +fsspec==2022.1.0 +pyarrow==6.0.0 +jsonschema==3.2.0 +rsa==4.7.2 +cityhash==0.2.3 +gmpy2==2.0.8 +gmssl==3.2.1 +# opencv-python cannot use in docker, use this Pre-built CPU-only OpenCV package +opencv-python-headless==4.5.5.62 diff --git a/web_console_v2/inspection/sm4_encrypt.py b/web_console_v2/inspection/sm4_encrypt.py new file mode 100644 index 000000000..57854a30e --- /dev/null +++ b/web_console_v2/inspection/sm4_encrypt.py @@ -0,0 +1,72 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +import argparse + +from gmssl.sm4 import CryptSM4, SM4_ENCRYPT +from pyspark.sql import SparkSession +from pyspark.sql.functions import udf +from pyspark.sql.types import StringType +from pyspark.conf import SparkConf +from util import build_spark_conf + +# initial verctor for cbc encrypt +INITIAL_VECTOR = '00000000000000000000000000000000' + + +def sm4_encrypt(input_path: str, output_path: str, key_str: str): + key = bytes.fromhex(key_str) + iv = bytes.fromhex(INITIAL_VECTOR) + crypt_sm4 = CryptSM4() + crypt_sm4.set_key(key, SM4_ENCRYPT) + + conf: SparkConf = build_spark_conf() + spark = SparkSession.builder.config(conf=conf).getOrCreate() + broadcast_vals = spark.sparkContext.broadcast({'crypt_sm4': crypt_sm4, 'iv': iv}) + + @udf(StringType()) + def sm4(value_string: str) -> str: + crypt_sm4 = broadcast_vals.value['crypt_sm4'] + iv = broadcast_vals.value['iv'] + encrypt_value = crypt_sm4.crypt_cbc(iv, value_string.encode('utf-8')) + return encrypt_value.hex() + + df = spark.read.format('csv').load(input_path) + df = df.withColumnRenamed('_c0', 'raw_id') + df = df.withColumn('raw_id', sm4(df['raw_id'])) + df.write.format('csv').option('compression', 'none').option('header', 'true').save(path=output_path, + mode='overwrite') + + +def get_args(): + parser = argparse.ArgumentParser(description='esm4 ncrypt') + parser.add_argument('--input', type=str, help='path of intput sha256') + parser.add_argument('--output', type=str, help='path of output sm4') + return parser.parse_args() + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + args = get_args() + logging.info('Input Params:\n' + '--------------------------------\n' + f'\tinput: {args.input}\n' + f'\toutput: {args.output}\n' + '--------------------------------') + # key for cbc encrypt + enc_string = '64EC7C763AB7BF64E2D75FF83A319910' + + sm4_encrypt(args.input.strip(), args.output.strip(), enc_string) diff --git a/web_console_v2/inspection/testing/__init__.py b/web_console_v2/inspection/testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/web_console_v2/inspection/testing/spark_test_case.py b/web_console_v2/inspection/testing/spark_test_case.py new file mode 100644 index 000000000..ff53ba834 --- /dev/null +++ b/web_console_v2/inspection/testing/spark_test_case.py @@ -0,0 +1,38 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest +import tempfile +import os +from pathlib import Path + +from pyspark import SparkConf +from pyspark.sql import SparkSession + + +class PySparkTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # Uses threads to run spark + # Ref: https://spark.apache.org/docs/latest/submitting-applications.html#master-urls + conf = SparkConf().setMaster('local[*]').setAppName('test') + cls.spark = SparkSession.builder.config(conf=conf).getOrCreate() + cls.tmp_dataset_path = os.path.join(tempfile.gettempdir(), 'tmp_data') + cls.test_data = str(Path(__file__, '../test_data').resolve()) + + @classmethod + def tearDownClass(cls): + cls.spark.stop() diff --git a/web_console_v2/inspection/testing/spark_test_case_test.py b/web_console_v2/inspection/testing/spark_test_case_test.py new file mode 100644 index 000000000..4ea5eb901 --- /dev/null +++ b/web_console_v2/inspection/testing/spark_test_case_test.py @@ -0,0 +1,30 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest + +from testing.spark_test_case import PySparkTestCase + + +class SparkDemoTest(PySparkTestCase): + + def test_with_df(self): + df = self.spark.createDataFrame(data=[('Alice', 10), ('Bob', 21)], schema=['name', 'age']) + self.assertEqual(df.count(), 2) + self.assertEqual(df.agg({'age': 'sum'}).collect()[0][0], 31) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/inspection/testing/test_data/alignment_schema.json b/web_console_v2/inspection/testing/test_data/alignment_schema.json new file mode 100644 index 000000000..f13e7131c --- /dev/null +++ b/web_console_v2/inspection/testing/test_data/alignment_schema.json @@ -0,0 +1,32 @@ +{ + "additionalProperties": false, + "properties": { + "channel": { + "type": "integer" + }, + "cols": { + "type": "integer" + }, + "image": { + "type": "string" + }, + "label": { + "type": "string" + }, + "raw_id": { + "type": "integer" + }, + "rows": { + "type": "integer" + } + }, + "required": [ + "raw_id", + "image", + "rows", + "cols", + "channel", + "label" + ], + "type": "object" +} \ No newline at end of file diff --git a/web_console_v2/inspection/testing/test_data/alignment_schema_error.json b/web_console_v2/inspection/testing/test_data/alignment_schema_error.json new file mode 100644 index 000000000..43c778b7e --- /dev/null +++ b/web_console_v2/inspection/testing/test_data/alignment_schema_error.json @@ -0,0 +1,32 @@ +{ + "additionalProperties": false, + "properties": { + "channel": { + "type": "integer" + }, + "cols": { + "type": "integer" + }, + "image": { + "type": "string" + }, + "label": { + "type": "string" + }, + "raw_id": { + "type": "string" + }, + "rows": { + "type": "integer" + } + }, + "required": [ + "raw_id", + "image", + "rows", + "cols", + "channel", + "label" + ], + "type": "object" +} \ No newline at end of file diff --git a/web_console_v2/inspection/testing/test_data/csv/medium_csv/default_credit_hetero_guest.csv b/web_console_v2/inspection/testing/test_data/csv/medium_csv/default_credit_hetero_guest.csv new file mode 100644 index 000000000..68ef0352b --- /dev/null +++ b/web_console_v2/inspection/testing/test_data/csv/medium_csv/default_credit_hetero_guest.csv @@ -0,0 +1,102 @@ +example_id,raw_id,event_time,x0,x1,x2,x3,x4,x5,x6,x7,x8,x9 +1,1,20210621,-1.13672,0.810161,0.185828,-1.057295,-1.24602,1.794564,1.782348,-0.696663,-0.666599,-1.530046 +2,2,20210621,-0.365981,0.810161,0.185828,0.858557,-1.029047,-0.874991,1.782348,0.138865,0.188746,0.234917 +3,3,20210621,-0.597202,0.810161,0.185828,0.858557,-0.161156,0.014861,0.111736,0.138865,0.188746,0.234917 +4,4,20210621,-0.905498,0.810161,0.185828,-1.057295,0.164303,0.014861,0.111736,0.138865,0.188746,0.234917 +5,5,20210621,-0.905498,-1.234323,0.185828,-1.057295,2.334029,-0.874991,0.111736,-0.696663,0.188746,0.234917 +6,6,20210621,-0.905498,-1.234323,-1.079457,0.858557,0.164303,0.014861,0.111736,0.138865,0.188746,0.234917 +7,7,20210621,2.56283,-1.234323,-1.079457,0.858557,-0.703588,0.014861,0.111736,0.138865,0.188746,0.234917 +8,8,20210621,-0.520128,0.810161,0.185828,0.858557,-1.354506,0.014861,-0.72357,-0.696663,0.188746,0.234917 +9,9,20210621,-0.211833,0.810161,1.451114,-1.057295,-0.812074,0.014861,0.111736,1.809921,0.188746,0.234917 +10,10,20210621,-1.13672,-1.234323,1.451114,0.858557,-0.05267,-1.764843,-1.558876,-1.532192,-1.521944,-0.647565 +11,11,20210621,0.250611,0.810161,1.451114,0.858557,-0.161156,0.014861,0.111736,1.809921,0.188746,0.234917 +12,12,20210621,0.713055,0.810161,-1.079457,0.858557,1.683111,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +13,13,20210621,3.564792,0.810161,0.185828,0.858557,0.598248,-0.874991,0.111736,-0.696663,-0.666599,-0.647565 +14,14,20210621,-0.75135,-1.234323,0.185828,0.858557,-0.595102,0.904712,1.782348,1.809921,0.188746,0.234917 +15,15,20210621,0.635981,-1.234323,-1.079457,0.858557,-0.703588,0.014861,0.111736,0.138865,0.188746,0.234917 +16,16,20210621,-0.905498,0.810161,1.451114,2.77441,-1.354506,0.904712,1.782348,0.138865,0.188746,0.234917 +17,17,20210621,-1.13672,-1.234323,-1.079457,0.858557,-1.24602,0.014861,0.111736,1.809921,1.899436,1.999879 +18,18,20210621,1.175499,-1.234323,-1.079457,-1.057295,1.466139,0.014861,0.111736,0.138865,-0.666599,-0.647565 +19,19,20210621,1.483795,0.810161,-1.079457,-1.057295,1.466139,0.904712,-1.558876,-1.532192,-1.521944,-1.530046 +20,20,20210621,0.096463,0.810161,-1.079457,0.858557,-0.703588,0.904712,-1.558876,-1.532192,-1.521944,-1.530046 +21,21,20210621,-0.288907,0.810161,1.451114,0.858557,0.381275,0.014861,0.111736,0.138865,0.188746,0.234917 +22,22,20210621,-0.365981,0.810161,0.185828,-1.057295,0.381275,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +23,23,20210621,-0.75135,0.810161,0.185828,0.858557,-1.029047,1.794564,0.111736,0.138865,1.899436,1.999879 +24,24,20210621,2.17746,0.810161,-1.079457,-1.057295,0.489762,-1.764843,-1.558876,-1.532192,-1.521944,-1.530046 +25,25,20210621,-0.597202,-1.234323,-1.079457,0.858557,-1.354506,0.014861,0.111736,0.138865,-0.666599,0.234917 +26,26,20210621,-0.905498,-1.234323,1.451114,0.858557,-1.354506,0.014861,0.111736,0.138865,0.188746,0.234917 +27,27,20210621,-0.828424,-1.234323,-1.079457,0.858557,-0.920561,0.904712,-1.558876,-0.696663,-0.666599,-0.647565 +28,28,20210621,-0.905498,0.810161,1.451114,0.858557,-0.595102,0.014861,0.111736,0.138865,0.188746,0.234917 +29,29,20210621,-0.905498,0.810161,1.451114,-1.057295,1.249166,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +30,30,20210621,-0.905498,-1.234323,-1.079457,0.858557,-1.029047,0.014861,0.111736,0.138865,0.188746,0.234917 +31,31,20210621,0.481833,0.810161,-1.079457,0.858557,-0.920561,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +32,32,20210621,-0.905498,-1.234323,0.185828,0.858557,-0.269643,1.794564,0.111736,0.138865,0.188746,0.234917 +33,33,20210621,-0.520128,-1.234323,-1.079457,0.858557,-0.378129,0.014861,0.111736,0.138865,0.188746,0.234917 +34,34,20210621,2.56283,0.810161,0.185828,-1.057295,2.00857,-1.764843,-1.558876,-1.532192,-1.521944,-1.530046 +35,35,20210621,2.56283,-1.234323,-1.079457,-1.057295,2.442516,-1.764843,-1.558876,-1.532192,-1.521944,-1.530046 +36,36,20210621,-0.057685,-1.234323,-1.079457,0.858557,-0.595102,-0.874991,-0.72357,-1.532192,-1.521944,-1.530046 +37,37,20210621,0.867203,-1.234323,0.185828,-1.057295,0.489762,0.014861,0.111736,0.138865,0.188746,0.234917 +38,38,20210621,-0.828424,0.810161,0.185828,0.858557,-1.462993,0.014861,0.111736,0.138865,0.188746,0.234917 +39,39,20210621,-0.905498,-1.234323,-1.079457,0.858557,-1.137534,0.904712,-0.72357,-0.696663,-1.521944,-1.530046 +40,40,20210621,0.867203,-1.234323,-1.079457,0.858557,-0.486615,-0.874991,-0.72357,1.809921,-0.666599,0.234917 +41,41,20210621,1.483795,-1.234323,-1.079457,0.858557,-0.269643,0.014861,0.111736,0.138865,0.188746,0.234917 +42,42,20210621,-0.75135,0.810161,-1.079457,0.858557,-1.137534,0.014861,0.111736,0.138865,0.188746,0.234917 +43,43,20210621,-1.213794,-1.234323,0.185828,0.858557,-1.462993,0.014861,0.111736,0.138865,0.188746,0.234917 +44,44,20210621,-0.211833,0.810161,0.185828,-1.057295,0.164303,0.014861,0.111736,0.138865,0.188746,0.234917 +45,45,20210621,-0.982572,0.810161,-1.079457,0.858557,-0.595102,0.014861,0.111736,0.138865,1.899436,0.234917 +46,46,20210621,0.327685,-1.234323,-1.079457,0.858557,-0.703588,-1.764843,-1.558876,-1.532192,-1.521944,-1.530046 +47,47,20210621,-1.13672,0.810161,-1.079457,0.858557,-1.462993,0.014861,0.111736,1.809921,-0.666599,0.234917 +48,48,20210621,-0.134759,0.810161,3.981685,0.858557,1.14068,0.014861,0.111736,-0.696663,0.188746,0.234917 +49,49,20210621,1.637943,-1.234323,0.185828,0.858557,-0.378129,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +50,50,20210621,-1.13672,-1.234323,-1.079457,0.858557,-1.24602,0.014861,0.111736,0.138865,0.188746,0.234917 +51,51,20210621,-0.75135,-1.234323,1.451114,0.858557,0.706734,0.904712,1.782348,1.809921,1.899436,1.999879 +52,52,20210621,-0.520128,0.810161,1.451114,2.77441,0.815221,0.014861,0.111736,0.138865,0.188746,0.234917 +53,53,20210621,1.098425,0.810161,0.185828,-1.057295,1.466139,-1.764843,-1.558876,-1.532192,-1.521944,-1.530046 +54,54,20210621,0.096463,0.810161,-1.079457,0.858557,-1.137534,0.904712,1.782348,0.138865,0.188746,0.234917 +55,55,20210621,-0.134759,0.810161,-1.079457,0.858557,-0.703588,1.794564,0.111736,0.138865,0.188746,0.234917 +56,56,20210621,2.56283,0.810161,-1.079457,-1.057295,1.032193,-1.764843,-1.558876,-1.532192,-1.521944,-1.530046 +57,57,20210621,0.096463,0.810161,1.451114,-1.057295,-0.161156,0.014861,0.111736,0.138865,-0.666599,-0.647565 +58,58,20210621,0.096463,0.810161,0.185828,-1.057295,-0.161156,0.014861,0.111736,0.138865,0.188746,0.234917 +59,59,20210621,0.250611,0.810161,-1.079457,0.858557,-0.161156,-0.874991,2.617654,1.809921,1.899436,1.999879 +60,60,20210621,1.792091,0.810161,0.185828,-1.057295,-0.703588,0.014861,0.111736,0.138865,0.188746,0.234917 +61,61,20210621,2.56283,0.810161,1.451114,-1.057295,-0.812074,0.014861,0.111736,0.138865,0.188746,0.234917 +62,62,20210621,-0.75135,-1.234323,0.185828,-1.057295,0.381275,0.014861,0.111736,0.138865,0.188746,0.234917 +63,63,20210621,-0.905498,-1.234323,-1.079457,0.858557,-0.703588,1.794564,1.782348,1.809921,1.899436,1.999879 +64,64,20210621,-0.905498,0.810161,0.185828,-1.057295,1.14068,0.014861,0.111736,0.138865,-1.521944,-1.530046 +65,65,20210621,-0.288907,0.810161,0.185828,-1.057295,1.683111,-0.874991,-0.72357,-1.532192,-1.521944,-0.647565 +66,66,20210621,0.250611,-1.234323,-1.079457,-1.057295,2.334029,-1.764843,-1.558876,-1.532192,-0.666599,1.999879 +67,67,20210621,-1.213794,-1.234323,0.185828,-1.057295,2.225543,1.794564,1.782348,1.809921,0.188746,0.234917 +68,68,20210621,0.327685,0.810161,-1.079457,0.858557,-0.595102,1.794564,-0.72357,-0.696663,-0.666599,-0.647565 +69,69,20210621,-0.288907,0.810161,1.451114,0.858557,-0.703588,0.904712,-1.558876,-1.532192,-0.666599,1.999879 +70,70,20210621,-1.13672,-1.234323,3.981685,0.858557,-1.462993,1.794564,0.111736,0.138865,0.188746,0.234917 +71,71,20210621,-0.674276,-1.234323,-1.079457,0.858557,-0.486615,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +72,72,20210621,1.175499,-1.234323,0.185828,0.858557,-0.703588,1.794564,1.782348,1.809921,1.899436,1.999879 +73,73,20210621,0.250611,0.810161,0.185828,-1.057295,-0.378129,-0.874991,-0.72357,-0.696663,-0.666599,1.999879 +74,74,20210621,0.944277,0.810161,-1.079457,0.858557,0.164303,0.904712,-1.558876,-0.696663,-0.666599,-0.647565 +75,75,20210621,1.329647,-1.234323,-1.079457,0.858557,-0.378129,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +76,76,20210621,-1.13672,-1.234323,0.185828,0.858557,-1.24602,0.014861,0.111736,1.809921,0.188746,0.234917 +77,77,20210621,-0.905498,-1.234323,1.451114,0.858557,-1.137534,-0.874991,0.111736,0.138865,0.188746,0.234917 +78,78,20210621,1.021351,0.810161,-1.079457,-1.057295,1.032193,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +79,79,20210621,-1.059646,0.810161,0.185828,0.858557,-1.462993,0.014861,0.111736,0.138865,0.188746,0.234917 +80,80,20210621,0.558907,0.810161,0.185828,0.858557,0.923707,0.904712,-1.558876,-1.532192,-1.521944,-1.530046 +81,81,20210621,2.331608,0.810161,1.451114,2.77441,-0.269643,0.014861,0.111736,0.138865,0.188746,0.234917 +82,82,20210621,1.483795,0.810161,-1.079457,0.858557,-1.029047,0.014861,0.111736,0.138865,0.188746,0.234917 +83,83,20210621,-0.828424,-1.234323,1.451114,0.858557,-0.595102,0.014861,0.111736,0.138865,0.188746,0.234917 +84,84,20210621,1.792091,0.810161,0.185828,-1.057295,0.923707,0.014861,0.111736,1.809921,0.188746,0.234917 +85,85,20210621,-0.905498,0.810161,1.451114,0.858557,1.466139,0.014861,0.111736,0.138865,0.188746,0.234917 +86,86,20210621,-0.057685,-1.234323,0.185828,0.858557,-0.269643,0.014861,0.111736,0.138865,0.188746,0.234917 +87,87,20210621,1.483795,0.810161,-1.079457,-1.057295,1.032193,-0.874991,-0.72357,1.809921,0.188746,-0.647565 +88,88,20210621,-0.057685,0.810161,0.185828,0.858557,-0.378129,0.014861,0.111736,0.138865,0.188746,0.234917 +89,89,20210621,-0.288907,0.810161,-1.079457,-1.057295,-0.05267,0.014861,0.111736,0.138865,-0.666599,-0.647565 +90,90,20210621,-1.13672,-1.234323,1.451114,0.858557,0.923707,1.794564,1.782348,0.138865,0.188746,0.234917 +91,91,20210621,0.250611,-1.234323,-1.079457,-1.057295,1.900084,1.794564,1.782348,1.809921,1.899436,1.999879 +92,92,20210621,0.867203,0.810161,-1.079457,0.858557,0.381275,-0.874991,-0.72357,-0.696663,0.188746,0.234917 +93,93,20210621,-0.520128,0.810161,-1.079457,0.858557,-0.920561,-1.764843,-1.558876,-1.532192,-1.521944,-1.530046 +94,94,20210621,-0.057685,0.810161,0.185828,-1.057295,0.164303,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +95,95,20210621,-0.828424,0.810161,0.185828,0.858557,-1.354506,0.014861,0.111736,0.138865,0.188746,0.234917 +96,96,20210621,-0.597202,-1.234323,0.185828,0.858557,-0.05267,0.014861,0.111736,0.138865,0.188746,0.234917 +97,97,20210621,1.483795,-1.234323,-1.079457,-1.057295,0.815221,-0.874991,-0.72357,-0.696663,-0.666599,-0.647565 +98,98,20210621,-0.134759,-1.234323,-1.079457,0.858557,-0.920561,0.014861,0.111736,0.138865,0.188746,0.234917 +99,99,20210621,-0.905498,0.810161,1.451114,-1.057295,-1.462993,0.014861,0.111736,0.138865,0.188746,0.234917 +100,100,20210621,-1.13672,-1.234323,0.185828,-1.057295,0.272789,0.014861,0.111736,0.138865,0.188746,0.234917 +101,101,20210621,-0.211833,-1.234323,-1.079457,0.858557,-0.378129,-1.764843,-1.558876,-1.532192,-1.521944,-1.530046 \ No newline at end of file diff --git a/web_console_v2/inspection/testing/test_data/csv/small_csv/default_small_data.csv b/web_console_v2/inspection/testing/test_data/csv/small_csv/default_small_data.csv new file mode 100644 index 000000000..f011e7c32 --- /dev/null +++ b/web_console_v2/inspection/testing/test_data/csv/small_csv/default_small_data.csv @@ -0,0 +1,6 @@ +example_id,raw_id,event_time,x0,x1,x2,label +1,1,20210621,-1.13672,0.810161,0.185828,cat +2,2,20210621,-0.365981,0.810161,0.185828,dog +3,3,20210621,-0.597202,0.810161,0.185828,frog +4,4,20210621,-0.905498,0.810161,0.185828,cat +5,5,20210621,-0.905498,-1.234323,0.185828,dog \ No newline at end of file diff --git a/web_console_v2/inspection/testing/test_data/image/images/000000005756.jpg b/web_console_v2/inspection/testing/test_data/image/images/000000005756.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5ef11dfe79540aa1a21e8267ce9be0f7c34b97b7 GIT binary patch literal 215758 zcmb4pbx<5n)a~N#65QPq+}$DAvN!~{O>hnF1cEN^y0~j_2n4q*?hte#K=9x%zgMs7 zegA%UrlzWEy6@?ks@r|<>GQh$x(&crQ&Lp|ARr(BRQ_Fn*EN9rf35#-h=@pt{~0n8 z5+V``G71U`GBPp>DjF&Z3K}XhGAaft8v1{Rf{KBOfsXlK<3A_=IrwkXzZ?DEsQ=sL zfAx6n2N0kmI3k81ArJr%2@sG75MGA>v;Y7CGSYv}0sqfXkx|gl5s@%35dc{KI_TpA z5D}36%@r956#)$$4G|9k5eXTE0F{s)fW}KCi_V~9NzCVlLBbfCTqMU2bZ;E^`G=HA zK+wvgX$MnC-qWl3`^6hF<}eXmNbw+xuyweCH|(EZY6JiR%KyX#ApiRvsAvf20E~aH z-xB~35Rw03BBT5RM+N}?!4d$F=y{O|Wpz+2-B20+!TmuaY8)Wu`?+(0&L~I1543U* z`-j{FDgKT@Ca<6y{)=4Dx_R(*6@ZO|@b8n62msQ6Tis&@%iL2gVV*N#ym~2Rw4$=u zhA7!pexsd>V<q&f(cPj+cFg$VV*1f^D*r1OjB1sXlhW!_XdK^js3=_FgxWr`F(wAR z<*MtxU+LmmOfIzBH4mG^*!UOd<@{U2uy5#eaC<_Y+Iv=!H}S84@Oo{swKD*obbml_ zHMB8-eC!QxxVUnnA-c>f;JScEy55`<x4kjqg-5%{@CtuRsJKC=y4C3wp!G9yKW>SP z5$oa1Ezvm#)apRrYPEM<big2b_SXN=zv!B#V<Fo7i2b<MTReW6jImFrW#KIfS5wvS zS9UwAQ2>BYYYAEmob@0;wDOIBgr(LpCEHWLB$i(Kvo|*!#-y0Ve{4Jl7K#T}f_K{u zNxV>QAzNHYZQv^AcrcTat=+YD!zh}M=oLm}|D-;*u6u5!ii*G+9(7-YY3=~?_#NmD z^UlIMQ5}}De&o<eT$7O^@sVNS?{$f_{!>0bSO+78GI*`p5UNiv)itE{Ko#-USut{@ zOkaqTTbZcPP;Gr}4=mWv8jUlC8cuN2!eNLfuK>r4-!CnCBR&PYM2%&uW6V@jI8%p8 zS?FG(3H%U*RJVcA2i#swTuZYe>BMyTXqQL52bmwcx2-SJZ)z!m%6|>ErwLpt)eEFv zx8C4SMkH>ds#%d|2HCy>-n{}=`wezLb*B=~5|%SROd~4VpIJ2vjsNhXou)kJ3vu4) z=hgg8oNIEN4=pYHHelbW(vUe`4Fj>2tvBI}|CGWU4IQBWMc<jrKB=STaXaLQ@#JPt zxcJ$Y?y@)cv`a4Q72wqVd!=j(e)q8B7O!Mf=~?@;|LVmP>&5Y9gC0nHU9T+d0CZD( z``n7UE<8UZS^WxdKL34R-DSen*5k23Qp&af{7?cTV))M6GC+_vTQW-dR8slX!B$$- zl-<`Iz|Q06*lyx0Kl5b~7y=zveg&lU#Wc%3vp@RwVw~x#je!}?uAA1r4>G0t88Jqc zqU`*NuZmIp{sJ7redjhe_I+PQM=E-2%lFs8lnN%OX(aPGz=~>V>p?acxmA_qL(W_< zVXAR<HaieAalP#VIe9dbT^DHyC0nAYsU!_yczYr|U5_q<srjRvL^B?NW<yp9fquq8 zq11SN=01UQ{wn|`_uDzAX`M?iF!XHUa$m!>s=S47Nk_L<53{ocM}P8?xBzC4Yv7k2 zhacpw_cLm=2KtP)z9>>8!bC`9{t$oL5Y##RMfy5PoXzD9YkdnZaHTf$=Ku-`S9WY9 zdu0qt8RwTZ?4ha*Q6?p6HYU%M=(o9`zqruN$IHYr245!Rvec*XV1)GbryWh`zv-yL z^t)<bVh8^;dv|H;ER1QU=~1-EpA6T?q|6aV&e1so{@%eaqE)$~B?bi_{)Ob@z~&k6 zE1-8BzN2A?9(_~(0?`|PbRrfy51-<@&M(T>+VpFy(o*@s<U~~s*QGa5rffo|i=<1) z`9NVr4r2SfX64|uiaof~h=q5BU+@I-GmV$9LO<6!o&FoKl$W@OKeCoEXn0CJ)b`=J zdv@)N#7p?26W?4d<%Bjg3^R;_x;QHdO|~`lR38o=U>cvgSL;|@YG0Moq*lwhEPLN@ z?qdu+5+4Egbc_h}BKJ8Icn8~mdj1*R@}Si&_VS~8s>2!dkey$q$B?*qt>A*3+yvn; zai{Lzb~`iDCt!l0z|E=U&2BDZc<5L(=u?5<LC)DrCX@a%>yo?rXQsqB){??RE${N3 z+xed|q^?d23pnCu);;oi0U=SC1_%2-NLNnCYDu@0=jP^yQ6CUJAolo3{PfbV0RH?` zClo88o>u^z=*e@v@%>_Ow3a5TRf;_Hz$-1JA0h-!8V`zal6ds2%(RgI7EHS|u=z)( z9~totukrxnD`2eed>jef=i+jT0_K#omFha}o^Kn~7iDLeOP*}q?k64-g~(mu4~klc z%p~vDO{J5Ec<cswMJEp?LEjixPJYHcUpVrq5&ZeG{0d0We9{HKO!yFWKKNC)`$4jc z*IdiXVJ+I?gM3O938EmhFos$d^!5G_3ErU~KJE7qhpF}u@Z67`-%{8-Rvg9Vt_YHR z9e)^)855rGx+(I!f*c*`;(Et*@u`$%tJpY{uO9bqa0XM4R8b4+o<)J&&8+Mm{6rnK z8n=`;<s#8XXM}?9Nq8=%cr*Eh86B}8cw31l^AQVFrP)~KftF;powCB;BtqJGl*HKA zTvjzM`c`SS3?DUC3u1!y9Hds^kCGCkU(I?Q3KL=xb(UEe*G;_SA%+l-+(T+D%hGSi zphSN~@fekizmatx=R0gUXYDNakr%gCzdkB?-M~`RbOS(7@?grI?+!<BhoiSdemB5g zkTS7rg`NF)q#jlBgV)#~hZ)mf!6IompS~O@Zm~m0;zlAN=Q5=8-(rb((dPj<Xnz~4 z`QkUMPr`g-2C}uYT1&0^Hb@wU3mABy$=9^A(5fwB-yu_E<&{%!1#e$674T~m@@og! zKgpYO-!fM8(sljx{icYb;3n}<+!NuJ)uX8U^g-!1V@$drbhjMXrekHCopI^=ON52o za)Dd4BP0NfCm9Y$A~pYVw!7&}VsCVp(K!#&o?X<_6*E2lNTW6%8ZRiSpXdF9shUC} zODelrYH4`xk{PKsz^Qe&1SwL+jhkr}llA9t#*rZcV8Lu_Q4b}Uq_&kA%s?2=JZ=y` z)Dfj_KYCJTIBDqjc<`rSPz{4C;z-%^(?fhtx!CY(o`3x?B=%0P`fZaDQ^%MVyeFVO z28ciOdxqTT%P7^~8EQ8+C97hjmxZnY%v3_4_DZ+|4fHAZGTwOgw7Mk^R-n0Ae=WF# z3N5W-NH}QK38L|{sffpJYy=)Rqu8>t4OI@H)t7CLl4Gh!^grq@+1I#h@|eQZ^m5<e zQuhV+ScrVY3jblIv@ook&o)b|>n!xnCDq?GB3LT50f3?k$1OH?%K5lD+=J~>zF?KT z5KNNN<7IjsLyhzhP5hfBCadl7EZ#=zaCco$NXONv0D6DzH{yEy!6KEy=tTbPjN2|W zC8#pF3?kNGo9H;K%H4lQdog30!398Q^}5@^RMRYq0l(WSIR29_sbQclVpy6h5LBcK zd51ng{?aS;3ecUa$kYV~-Ve55a+>hEo2`wlO*l+DX-I-%;7u%g#VAcAOXO(MzaJ7h z=ST4*0~gU!iK!}AV(OQ^hff#^!B!`<;F2C?)q-|8iKd1QoG=q;UNyAn4O@ozjv#?E zgzgpa_JWuY)eQ#xp6yi|KqD?D_q9@hA<Y`BmvxULVIdZFIm0N=Y(3RVp|I+X^?Qs1 zEljO&#*}Y0hUd=jBi;l(JG)Nr$VdM_i|-QlJC*ZnZX9DiK{UOSbA!3?P7>1PwR41* z{QXyHnKA$E+M25-Ie(pSJ?~Um{h=g^1A2s+16XhF-C;zRac&U)m$Mvp3ptePvsZvD zQp|h81!Sk&b@BcilAm|F1iEBsbG+dLNpG-{Q*IdZeP_(sEsyso8>Fsh6NMwyAxSGL zuFp;_>eqT1W?CEL&-~C|`6q#$$1Lq8r$5{7IXGA<@ka>RUjala$0btyGpeepD$-dy z9iw#E5ACwMr@IFg&~HX>AIepam7Zq;TJ<!Xs!LM-mQj2=<W<K#8q;a1&bwdrPQu_4 z`xQndy7zKh3#T$t<xv4d<Ct6I`Mk4#{Lb!N>mF5Sgf*g;q|8_?;l_lS9414~Xve2a zmmnfZ2SMt1R_?PS`f?sNO$!!I{LskYP^+UZ(9UUo&Y^mko|_&C1@5H!0;qO#t;_9- zxF!eg=WZUAnQ91Ap-P<;2F{8{!m)qzZqGC$77emb87nwH>EXXfg)*0ELlm{RfVxuz z$uds$#7*=A`({0fYUzzh6?*XVbL5$%$dE#+Eq{kKKM8B+#pVxTK^NCY`&yc!#(W^+ zwYW<aDvzg;gkB&JV^Z|1oL-EIXsE~D;7wroF<o6#of_ERERgmWG}bU<F2m9$Yl(8f z<g!tbz*d=q%zktnr0Omq6q+o^p1M67^04+n-hWew`*=kedZxQzB-1mdr!L^bTIB#s z&zVbA#V=cH&Dw*Y5~fos*vb`XC&hp<%P;ENqm%0XybXQ;zXXNfD`K}S#Y_zPM5(Q? ztdQgONy!ryCSa_iy#i_^qs<8ClgJEQ>`2618@X0{J2%gsC3Z4ijAs?nytLfgRRY=R zAOsv;WlBO-l}zpVk`Jy)<P$8}PPNYTP;PY;^lvAlM;n^?u2P+s*+~l#VTam#RTT-j zJ2^CayK(>O0r;GQu?qFMVIW@y*S;P_XT0QUgG*uS5bv^AkDwEZZReX}qn~SBrssf4 zo$wb@>52R66cKz(@GGDzs9|Z$7CGI@lYTQp?9;DW51hG;4@Xh{W&+S`k=<au4Eh0S z-BG%?b0bTV0$D}a7_I_$tpPi_W8Pv5*YFOiQ=F-4HiwhUg@q}~FHjJ{H?$x!l711K zt{M1T<@lMbbE4%T49oP;z)M)n`3x(HLLRi*mR-!2oyNhDI(95HdXY%JcQ491GIzeD zp9BFA+ij9%z00!I9P#mJ#ZmXiZ}sxzxm3g!RGfvxS<x6c4lCzU4<hA0{)M-)5d@>T z1X?;60uypqi{gJ%IkV$wRUa|54QSio=rZ*-0Fas?WJJ5ecr<)cc}%3*Xj{z~3YXpc z%RlR!Ob~)f-02mC-9BkgXmOFaFb|j@vJDFTBDM^>cm=o=)N9T9EXbe){0RQq$7biq z>{Z*}bKGyDMT;r-xpgB9Edv(zndP%>9OPZ$oA0NG;m@6q`{kF_x@PLfcnQ4}^7m@W z-oK_XxQoDf#uRm8y(<Rl8b)f@=jPpW%J|m3!pJuGh50^I_G5d#aA*Y@2TIS<MlHHb zR%Syj85&gd6liW{S&aL8lPmdy0~_bZ3N{`PT-`N+B3-dIK{Q=2!Inylt8s)*H;J0~ zUT2BFfiCA^k#b0#2oV%E<UIeP@wD?YQN`^OU74>`QC^PS_zEBt><<87h*JGgl6v-z zL?)VF(sbFsd3VIGVqqGOP#EatXXw{LEbl)jh=<Jk!9j*e_+F8PkMAA-&4W8yU&6P- za!kfMCzq1+$O%9NlbFnfQ+v_!%56f$%cL?aIY4enL)N|OTMtiK=bHk~r?=We9akZx zWw>tl-|j{uM>)KE>(<a8Dc{9KFy|iOeeF7@DU-syF=IY&u4NKPS*~Bis5<qT((?0c zpEoe$kg2HRTO9q;IQN5TGq#1336g0P`t^VtIjvG<p1}Q7zxLChH*2Z#uQzF*)seG9 zN4ta<N@)I4)7EwAy#lseT>PUy_HDHUWpr#--t<U8D~ie?dTZ~0>UMTFjVW!Wefkgs zWX^EpnYr?$5tExwEMPX<D@~HCF9B2fym7L4A^)P}lGM0v1h_NL3H7-wYk2;nym9u& z{2D&{Jkqwxyx6!%nqJqo%NWGM6ejXR07Wl%3R`=K()UwCX7d@@u{uI5sDI_-RP~<3 zeQy}@@Xj2ECA;LD)3fKQ*c))4?grQyL>paG+@aIrsEgg0gx`4vOuV-NqRO)~j*8HQ z+*+tod>zD8#rAx6ITWTO{Xyd95AP3S0f@Azdtm3I;emw%qmDBg9o6om*MP4MH|@0X z*rVZ#l)y&sjTy9;)j!!0T2u3+=ku$wvO2kHPt#QJJ)a-*5ZHU9H3%s4G*Zd?#kh>H z|8i|8@Tu_ab)~8txX28{X?bZVyPb`y`njQ?rMS3`U+*GteYuiQw!?z-!P_!<*t(1i zE&22(ksVYc4dNF#$xJ0?l#Usj6EKpurm4{)xEQqHvP%28DnTqb9`_P9r~^!L{<!XW zp_~4R=dv+AmN&}fz;_JZmk)GL+B@C8yW2tC`D>F_G}l<kbiBDt<bc!{^z%F+vaZ9# zxsS}8z>DN3{|~ya#(GZJk)doCqewjR?jjbl<h#Xk8JUM*>N;<1L*ElaXLIx<-8?{& zQzikFsw=i9G6Gfg;`AcLL$SEvSN$)ZvNuU8j80DO15Qw7hEH)<$-BS(EOlkhndb60 zUy^@*FMVof!_g3!?^<%x5~1n~_z)m>jGCafdFK;q0;X47xYK~B6><_%6eSo|3(t8_ z0p1}EyKlfeDdwHV?jjOMBDFlBg5GE6Z9PJZ2=xW{8`Jaeh8Lbk&X_@Cnd|DoHLgT- z2;uBA$Z@n%&t5E6V8`>BlvQF^7^-$E6P6TcUOxfluGKQ)xX0eiMHmC@MXlFbZfuZO zWe;K7=ODLo#9(yOvm)NkD-Dfgt*dgR3pv<anR?E2|A1-&wZzcC>2JQ4YWF3}(KV=* z3u4&@sdgQtgbb2vuk51HyaMj8VX3On9xv}Odnf`E^o!X3b5Z88n8UGMSu$O5H8RN! zqjq(_b$DFp403Q#79ip-H+<)OoQUBge<OE0%jy!b06kpA@F7PFWPF7DZXRp;?#X%# zo@vI{n-oXTVUi^rrXtNEL6aT=)t-Ou?JE_@+L$dg;_mqfpT)fYa?exB3p24V%d*NU z6(6UrQ)?f04IEk8l6nz4LIZXACSuCHxPQR}ZYbN6l8`Lx6&q6YyfsEO2rVpOca6)m zWY#hHvxi>@QSx?STu(`~AZ-beL2SEw=!>9(@D-s9pmujUo7IkHVwTOHYbP*4ylp9U z?h<##La*@Mg|=;8vL0@Jkn#jd7ZmRMl3D>+(gKxhD`A7B1dCgo>^x~NMd^@^6>>)L zOsBw(oesJ%L)w7w^ZXQUXVa6ZKJUwma1bdLB{bo5VT>Ae9gdsrp1xlRCS0*C>G*JN z2Q1EyMa^2n3jb~!$~r=W@!**vU^l5}45TJZ(pkrrKois1vTwbCCRF})J-0GA7CR+b zOE_kMSidWKhRd{EPAPU1lK^Pd=hsPME9u^$1SY<p8cMj-2*Aw^X%+3SN+s0Ilc6S8 zjLy%-^i`NyGmm%$#Hj1_X`66%8PqsH9d6@KF-N{*0}kZym=gDbdg!b$L|10iZmhlk zT+CHvhJ=I_O1SqZ6&HR|z%}W`>E&`-!gBgGRS4!XST#-@Qk2NZM&Cq?PyHlOjvP>E zL(q>_H#rmmP*eSrz`B>DZ3jU5nzRN#CRrqa1)x7Ej=43-y3dK(O(!1v$(cMeq`zmg zzU<;v90T@S1bv%9<<&$Ym6A3gJD?g<!^Ce86Al6U*IH?}zfAKyu7i)@c7a4HX(%fM z^^UB61IiYdN3jw5x2=c$j2mtA*tzxVSDqz36QP>v9IZYfi+vv{&#os>6Cb4?BTdXc zWmlVd_eM4Ol2Swny7&FU8fNI%OFXcWPf})OJe@rm;TEJdU&_!wq|UUd2`iL$53;zA z|NIfTCAeAl+s&$2*_ckHxQDqrUs&te$E)`*4S^RVaj0B5#TLhMyPPwULcY<ysb!@u zTHJo5iKUa6ID&yj3_Vz|rO@&E75^>UNca)wb$(%4l)j4+=KZ$CRM;|~Y`gcrGC}s? z=FhM=R5qIB@%}1$?u!f~U!4~H_!I&4)~)B$`_;-a@7Y#i7X|)`;>rZiY5w19?TF|j zkA6(nz$%Ec4z8xLG>3|%Q+Gm%hS-mw`CuH$JD4{2^!}QYhf^^xKNn=pmSA{CMXDq; z1VJ~sz@=U>4%8Y~sY@HsV+my{^>OyJP^^c>)R!v0apL{31RiZU&90jPZ}!Fuj`tGj zP&CykBq+!(c^{4{BB+e+y4`!os;apJb*)MbP6KUAc)?drGGIhXgN-T+eHAa{cXMa$ zv;B^{zQ;E9BFR3Y*uPhRN;ld{sI%RlS!3zVerhIs4vO3GN(@zDnv|qM@aNw+J1m>E zFZ{S)v>=!ryF@6DLrtHm+;?bH1g*&I0OF0SvCS}srtEq0NYJUX1NK>u7a!4+l6m<t z2_e*Sai99(?i4pYGB5_`gH4Ssz4eoyrH&lf?)={d$U~gTsN5f89;_3@62FfDffIr> zt$$uDS||OOa*}t-GM?irk%acaDcBCO9L7omPld|ox8xG90I7T+8MSF5k8$q}9Jff3 z-7DL9W_$kPallD^L0u;(A#M=JS{@e9z+^K{$bc?|s^IkWcenDr*l0TDlnSi=?hCfU zJb5Xr{Ljehx{+zAqi3xePtx)^<8vi2XL{0N+v-rqnzQ$6ba{2#e34Y{pV98>xO=oX zngS&{s7xY9Ae|9}h!->C-2qbY@)4I~VLY+dLaXbC>f<_IRrlQ5?@IY{#?~i!Fq`|a z<mMTsf_hU<T@?7vc|jaI5n)p5mU=TH+qH`w<#CmzLRfkMEUJ9gXeW<)ZQlZ&QiU#U zxu5oAH*MUi0WW!L>n#Dw`+V&TL5fG$$LA7v$h_xToxl&)cn+nxj-vimpca|+A=(V> zhukTHpxmvElD#QACABs1)=33-;U?jTVA^JWZ0D&5uhOHTEFn?D9zQ#i?YI6JT60Ms zzMO8uOk1$1mREPsRP%{hC1GW|QC&~zq9Cy`Bt1WU$t!GEgIZOkj^y9EWE+j&51p@( z!@mqB+9$*&ofeY=7GhF#9SQZgw0cE-SN5uzl&U*bS~pPn0&NF!f+W23(yo&k-b2Xs z->GBg*3HEQetzkZz^bt83dXsE?%YS_v9EunaR25sv57}2DXpd92&R^nR35`_o-**t zGO9Aj!Yr;Mp$%<Eqw$zCLdV`ed6Jj2oCKEHA3BM7*@cnwY_zpP$81l#^^(WbxpDZY zW%Vi0vI&dJS+B;3hg8Y>frU)5yQO8a+Fp>Norgg4QohRPZ3hGk$5!F$zYr5O1&x*> zOLdzXXjy7s>2Ohw)!DW)oqb;SF6#`97|?FML+AxX@_qo_3rTSJBmd`iS!7Z$#{*dX zx2}V@-gsRZ0u@kCU(L2HEnAqcpCHvQ(%K>6Pd^jPl@goKj;?$;LM(CLOyab?q*Ylt z)YunHy1}%tSQTplYN2Q1n)H4Je5DFeQof#10}X4Vb0Le2)q)pk+Sd97Ed__cIq?b$ z6VBCHdvU6qU)BlkL1`m@dwT=C=DF$8jIybefQUG!=ebT5Y4l<7@QTt{3?00Gd_oH* zmPJBPc_R5sjO9J#<F1N6h2^9Nso=b}v%@CCALX(eDhfN}lQf?6mSc{NPhOy8-SvOx zPBBnfUF?>FQdY8j?o%$US3q;Rz$ac)&()O7#@>f>xrYzE+F|>uns&H*myf@%o#=wf z&b9tNe1T`@i<V=t`z#}hP&WDQg`m?0JksQ<10FmcT*e8r#<&j3@=(0Dss}ch0|hUO zXWYHo;LGr1x^iyiPmw?seGndf1j|xV*m(huEC02{1mmTt`Q3is33eSgWu4uJcpaEO zeIpoluz-K`tSGpq_Tl)jrl?!p%&A9R<1e^s$Y?^De;C)wjzF1R<41A~!Y~GB2K(nc z;Ww*Y+UCI`d+VxMZ#f1L*>KifjHAC*P>9qhc^Mzv_;oK}*g@4JH54F=qexlIX_+1N zR)%}cjZE&U(^?9|mC~68o2%coBT6Gte3aENoyyG|Rrp|fUb<j$n+(;14*0P$G`)#A zuNYjVXNyCq9%gNaIdN=}_BNMLhMOdGlQ|b2D7C3`IXE#%_Ra6ifxTiM567c%Y+XJ6 zM02?Wrf+-_|0z}fZ`FswwMv_`O$tl#yvql*37Xsc+#>5SNg;b%ZEfMrKc!r4dbn4h za6hL;dykTZZDAlx^UfAj2XSOd`I$8NXxOZVD*G7qk2;geAq(3h=#$<msKF;p1C$j# zgpT!W<*3c;`|T(yIN|-0qe>!~x1m9M{zsFWn<}hCWR$D8>@oAiR_3)_(aN_L{@|#+ zNf#40)9a;3RS85oPQZ`$A$yhwXpxVIUd*0ks#evS2QvO@71d2CN#Z_Mxm+a36Sw)I zD6rT`0Y|@iH;!4A>-7B*FB5=vG8dDn=J)aZoI2IL;(eNnbn9o{=+$`1a^pGTc-xi; zB!avM66Q-)q|og!(MvHo`EROyJ*~GEEmrPv(<XLO_V-+LE`Lb>vOI~DltgBTaH69a z;{DmSXI;+E1uZa((+b_SFxBdc4}e^pCYDPX^2^<snL#Wy0aLURoOt6Zv;h}4SG#oq z-_1VfZ1L;#IF`O^x<*)rL)#XgLZh*oG9i1{mp=rgau_TJwlZgl2F8Yep{={^mfKb{ zn|^%-&@{|{g@j=VZ3=(|tH2u;X1^~~aU>-Qu3$RU!ZhyR&=UB3MDH#_Z{R;Pq_dWN zUtR<f-m6OX8)?~79($KZ8+s5^=w0t)Crx;!=uwO(bA3v#W%4YC5qz>3R!YlRTql>S ziWuazTf1B=hxVL*n_DD}tSHS!Bd*T`zgc~Li&>GT|IR9gQAXvu|7nEAcQ=>n6|g_s z%+ib)P{fI$cI93l-}CwUqw)$QUyaQyZAwjQkB9|xFCl2_9#2vP(%_yaa{=$>(jX6| zJgpt+b*suo(4wA}Rud&Gu(GH`z*3VLy`5g9b)jarohL@Y-E!K2e^pW85f;;F?UFWR zoYzYc$|-En?&e*4<78S*h2Dfgv>=-$6pcv=^hOH5Z%{!i8(I(nTs73bopZzbUJ;33 zwY9t)ZZJ!R3lVppl|NRNl!YLW*;(gCv2ve&`?oN~T_2|wLJB{vceh5BO;2#MF`oxM zJo4T)amIUv{g9_tgXx%L`$PiMMY5GX%Xdk{7VWvD5x-%zKrnC^<;>ydB@b_(vkHpG zR0Vx^MnQ^=#Sz8v5r<K1!&*shLTZZX1ui3W@2yAek$jQ@g8Pp=1nqWR_FNu}G5Pzk zBi~%2bi`gR9G&9a<#?Gvk04dh4~{0EMOo(<X*>qtj?G+ibGN>5Ah2BJoK_ldVaF>F za#{wi{k6wtTnrZbnCeKPpi6-KGsbHDU!?yqGc3VB`G%?|O3h#e0?9=sHarO6LMO(v zAfxmTYyN(-oAJ~#&)F<uP9HZWo&(Fuc9l`W;?PSfu7z6CF2c;Bxp*mQ%arGecs+De zJK8et;JetG)Yr@d#aYYwrKmn76rVmwBHuFpvR3t02d%yh9Q~~zye4_@wCIoBZj#3K zMEy`DFE07LtxPC3AsHxw8M!{Nsl*W|(qE&N9V+TxhQ_0U(%~|eo#+$h#%G6N&Zko> zPx^-vbo|jE%)hMR>o3Cnt^DCTNzNNQcc)dzitoI>^ZbwvtTSk(0i<MA8`ERlu?q^z zE30sDWXyN`ATktd<2<8!uP8%IoV3kLIhP)}jChJS%1o9z$tYs1PNH|lIivG0(`O0b zayS*N@LF#dtL65Zy}mH2(>rh0;SN4CRB!MbTP=M6rmwqVWef{l<fjY@>M6@%VRcb} z{@(XL$yBnV1E{%MC54wh1ls-Ha*wK6dLEs7WAX^vs9-y7)$b&)*vxmcOE#aY%$Y}c zc4X!|i|<H~9wg-FdEfSi7+U#xlu59yD^EUUD%=I2k&?`=jf~X$>lDUS^a>D0Nt~1N z3&^vCc*Q%v0@wh%YskB$&~tP~-`SP^J%VevMLgpU?HI}l_x0^<sbBR1W9xf6!*o|G zPi^lrQydukr%I{GP5#LSPW7ehu<v2XSrydF27?)i5kbXaS!WvE3#N|v?^Lp$g^~^v zWEg36X1<|q6}@5X0&ZVF(_dS;`^b<($X9wFo*fj}!ZlZ_Nn=ay(tcK#@(JqE#aO9% z&x|spaQC1~|I+pyc({&9v19g6=&L`0;iL)Uwp6lHKQ3o5oK4Pq(~m4l1)aC)ne1HK z*Mz|d7#udFfF+kr?No>4WFvHkYvs06KvmS@rMc0dl3c+rKMI9_=Y6v-VEwd``4dP} zm>7kah?Aa|0dayO7?4}(NJ%NlK)kQS2TGK}`?;4opQa1L=T)aePN&aob+o7WkQ?Lc z+792T;2DQA<4}y-=A+dW*l>*Vx9&Mrke!Ol_Om7irLyov0s9*+Y3)Ln61t-4k9nx8 zmLm0p)wyl>npS^%l6cN$R=sx<d!aWGZ>pKpi|uPf<Am92+=KCymxhUWRgE~zWb9he zPUg+?tjRfQQ`>&D|6JK!Clj9`zN%@JQVYv@mO^0SHCs<k!sAqftfbZjipn<~yjQ`p zBc&}_%19NSla=G-DD^i6ku%u3=(;C|CZH-wmCT4dcuxMnM04~1p224&J8?g?QYADu zNdd<Idu|96i!vvutjfTM-kA*rHi?n}g2GSodo$8BfeyH7IFmPtWs_|Bwowhp^KC6w zJewMrC{>GpbOag{Pa+%^o{>i?FvNXnm8S9Nu13r-RH6Mr@=H2(UEkk`f;q(U;yvFm z&NllF>pz-mJPAfsAyCxWEK`1#nk=>W6O_BVRkXqJCK$i}5k?Hvz=k5;3K__rk2Ben zwJDZ5r+<+ot9k`|Rupi-48u@x8hS^AYd|xYNKFtu<vIWG`PuSgwnw;p^4ZNg#Zu;1 zl(kntwEvR#(Wf^!l)Ca7pK?d<*lkL$AzQ3f+H+AWPgZhK2vXWj08M$^P6%<`xqo$F zHBwE)w_HOuye^&7$z!2}lH0>=gQlrZ%>&hJ=M+uXb7=Y2!0VzhOhibgw;m>9l=Wf7 z3KT2~D#PV|Qq~kG?@rnPv)9dhkWbaxDE%GurOk*-x0PhCo0G{Ers0|?uaaLK!Wj2X z+q)`n5{pK3x9;e;@$dokx8FAx^rKP}Dcw)Jp<0yAMTSSg^jUnZ_KhA2C&;i?R}3fi z!@D0!ym8MXeOsj($(vBmkbm3{((K~=tpwfK#!CKL6d#sAl7^&$1}Ar*k{w#2Mqf-k zRB0XCZI@c8rNdQ4Qllg$SDqzb?&b#8DeKhUW>(Waf(^<hQj&)^a=rq7{oRO;%T1HG zxVxDBE(augcm?oo4Y9pgYl6Zk?OnS3HC(fI)r$q<3^4R`_SJ3Z+Ynt5)-Dq>3o^)A zStaSla7oU$YS_sZBKDD6{~gm1;O(|i_to=r+r`4XbTT1y_0EwgQCw2Mu7KP_a;f|N zlPt`~F8^Cg)!deO{|jFc=$w#;I<dM_TXFel<`<rU(2#F`-bvt5izNwMCnnI9621a7 zYx^Q>R;?l`Jk*J7W1Kz0l;`d}uN#%{qa}@rO}J8LYr!LgNmtF-Ja{2uhvR}CxLD12 zT3+$3q<xz2N#g#FERC3BW**PCdnXnrpk8kpT6<2LNDBom!#TXD%EW$i8b-Z+1>gnd zE|Gq#eRyp1qtx%XL7Fi76tjO{YWGXKXap|-FF}loUJuz|;%K(i+OD`#=c0a5^sZV8 zF$mv(W_|kit&cpleW7@GnZu_vVMciq5=Az_*(okwc`bg48aOe1X+I{ZLx|nF3_sr2 zY^<UQ{@LanQHtYPg&NEBp;CY<?l$2PbeYKDL0RDCpBBWPwC}IgD<FL}{a<~skgZ0| z`?+t1z06N7R?PN`cBDg&tGiu?Y@9HoTmLXtvv2GKA_r$Z_o=Tic1y30F#<T_K#WMK z#f{bgHlbd9E>NUiLo|Vzw2m^fKX5U!R8Ax)3XobBeCc7ZM8~zGlU(gB-V4z%8UK_0 z6XZ~}T^TQDAx%bu36Py>Tz8jxX0cfSP?_A}|5EE0+q%AMF*H;3?=yAPtuFon{& z<s7p`xl;-)Q>ok?rD2Q}ls#`nRh77QrV1NF`+N8F!^pn3G`sS|;IdNi0yaB^1zacX ztmqMN=ipUX4gRvlLu@TkkaHkHGnCNv4&aET07X-=Bi=(e7X7|vTehHMrwXp9pdl;G zXV0@n$>mixcLAgyt|6U##~fv_mM(7()8T*gqS1r)v<Mp8MZKxZgi3%3zNq~4j_(dl zw4AxSSn9Ve^3$EzP|g(;seO;5N(^^@nE%@9q~i1q2s8z@QAAb0v$SC#tY$?MH}G9M z+#2SeYtjb-f-&`zR*<ATem|~#r(c>|%>+RO&G1#N>~z~~0f922BD6vASensU4^D8D zZxtHx-D{hVJyv$78(|b*QCr#!sz#;J>+x|l0amJv68EA~O3E73xqFCbcv@umGv|^L z##84fI9}c+CaX4aL)R1VZ0*LeW^l8u%Crg+5C0w9%3p7J1o@^?*2r<J?e+tuV`YCu zts3UB?_#!=>TYrqte2$udgWGXL(J|@R!^&smU8f)SP=)WvoG`8Rh%>FacQ&{_c%mL z*9zUJu@wfg(%o_;qn*4?9DZ+-Uh?U#5qg58qMc!>(^xk|pmgPH!fj^y-531xn9L>3 z@IBvtj@tkgZs03G2j;=lYBHGO6+Mc<glehw(c9%+u%TD;(h_RQe3*z25*FTjNQdPZ z_fwzFQ>R)iund!`zP+v?OBMN0gVUot{>h7YmAA4iib72Jt9#yMkZDQ-%36}l9qZ%G z!oQUGo5pAO&4aQn+fPhn-AUgqAKy<Q4~hJ9+_JtTMEm4G6Ys!|r(Qn?F8(O%8CcsM zW94LOXRt7@ZX?rHO%Q=9u@37FRF091(pUGz71@^#x3l$-3rF%~7q{4vXS@>cux<Cg z^b_%13(8__8R;@8FFI0ctDNb26*R)3x%m(%%scQUQ*rqbp(mNvcuNCCj?Z|v{KimJ zo+6}XJq_&$<E5ZnLt|2wsYi67$^<@*Z-?etIn};6qTx5#TE=VexmmZk|A<)yq<LH= z8O<8!k@%OI4FR(B;Bh#PI$Fg0Jxq4%$7U+pVi*OJeM$B&j#^J0)G(EFQ7dJcm5UHp zmF@7~GVr~(okNQx+@k6o!31*!r-rH&0)jeva{Y<w-k{25??TJan^i5E4R$rAh@8NV z{2s9pVnejva{uf=+7T5kMI%9pn@=gaLCmR2!s7_Y`R*N8ac-xfurkDC*QxfV^&&N{ za`tlR_7kikrPz;V8WPtyZZXfM{9)hrLTAvV<xiJZGDRy6C(KJ(z~5z&BN9n31h0a$ z6LyUZe0o9a$Hobw`?-@72!`n_Ti+rSzZatQ_X7ZE3E4*Wd~k!iW?4m>622;#d7Y@# zC9^mOfkd6pZ9`>jvyBzw?--GJv6Zi%(ah;8+1vvr{I6OVJ{@{VN=)a=nAbI&%VEVw za4C}2J#WW_iH_b42b&+ls8#7Or8ZnJjA3c`>Zq0F975$QzW(v-r4HF9Q1UN%abIZT z3$amGQ6*^auA{Wo<An~pxkV!1c6aq4iF>+OJ1`Vg&dopg-6;Dde2hOZwvFAK)%yaA zD%2tpNI{IV*Tv80C1aEC?64yGNUy0**U-B;ob>y?u=G8hV5f^&pHHZeTDi}l+(04j z=>$M%cdfv3^x^vVP${aw^A6#`q#L_fj=XA-kH9aE3RjY>7_@Xz&WAwXLlJ6WffC+B zmu?|^5HZ0{k8y6tu+o}WclLgp35Ck8zTRhYdTd;`p*Uy*pC$rabqG43b-Zfma^U@L zuN2aNp+SbTvZh>@XunAF5q_Q4Iq<2f&ES}G;j-+l$)8GFNWPdCU$<>(x*cWWpU~AR zw<OhplV1oHxLqAiXqx^#B^68+P!PfmWoSwI@d7^8hxGJxh;e4!pQcGrC?LaqyFr@s zsr%XOEWq>3s`zZCzU#{|HF6`<O?kB}4}?%}VbP=vnq~XEp{<>!OC!f0mvlcfj+W># zVrsl#=}`}>b#XxlZ5QLDiuJCwb!IxY$Ka4~a|ysE#KJ?Qf_0PoY24KV`Dc*o!>BsH z+`;kQ=eOkVBNP$C=9!;5<yOI&Z29f;;EIiGUN##{;*zszsSc+6Z~Ek4Dwl8YBQ&$a zwpZKA-v;}R-!Mt@$%|Ewe<2}g*vTZ+eFdxx2l(Ioyq8}+SHk%ftiobU!c`d(Y%||# zTwgobufA>p^-fSdDXr4SyuXhMupl}OP3%Q8r-+;RVDi`hpFS396C1XzD<_88I<|^w zUGuz;-B`lePA^Xfc-%z_?SI*Pe#_?V?YooN24D#vXt7wmv=EH;W9v~f|IzhZ1qxcv zU%Rt-<`Ywc$@gf9{GGxekIhnSMJoU5yuOKa6R1FFU|h)@*K*bs%UChGl_>HMetojX zZU8J8I)UFXh+slu+j30&94E29C<J@U>oGHE2+GDNil(@y<%I<PP@xgMI~<tN1Q2;> zd6W7Y4ZY6{(Y4;u*qU=8`)%;M?4NqRFR0FIvAV?Q*eO*&JXQcgku;|UK=9r!`=vFH zFrwo-)^uqjJwjhe5sv*!yQPvDB<!pZ+tI<QB!qB6&G=XGFX$EUXQ;Tc;V!(_4A$Zh zC|=Fk@{-#IQ&weK5|7tbJHFDsB4JZtL0>2`NUEAhe2=_=nagpITFGfW3sD^X+4*41 zl=dSf(sU$Md{FN9!{27OuDZTayW??vM6K=m7yq{b9o`Q&RwSUiCE+Y^Fs<_2ib}5Z zAC9Z`L%M|La8z4u0Oru(<d79}xd;>G8@o#}$sIVvR?`^q0a_7Y*m&_LA^!<)y|TrL zy8K(u0*v}1Ll6AGwigTcn&Y36x3Ph|*L`RtBH^{9;Hw(K-5Hx+A9uYeWk1-^qlRrz zXFYp=`Q)yut4>TcAZfJG&2VudX`<tG=3>pW(I@18)AdI6t&i8_&k=jODAFi)ZG3xh zVFbNpJ1jXzTrH)n;Q2cXssPVtQiMd4MKlsf;X~A80b3J&!Ymo0?;k&bO;pt6${4Dh z#^+AO-GfCzn&9cf0?EKkIroOdJzfKcE-n35AfIO3&-jSF1T6?If9Po(EsbAVddocO zbAYt^397nj3nQDm;pR5jJiYy+>}0dTnqg<ZF#aGC_!W>1N|{zhyFPlSdd_5!^wv9S zn(Uogjp*0KU4O@JMrfsHb;>MRvO1T29LlhiKGk38iy1W_p|+`k#!M*PXSw(5<AviK zQt2+W>~^IAk<5u6_&@UQJjUX7N6BaC5cb0GX!z%hVssOgNjGmLCFx3OFZA%leY#`X z@>_X(1rxFjt12B23X>Pw8pn*tXsyo=M;aKVlr<TEzZWioa{tNV#wt)t%p?X_hwI&M zqE$;{i<jpokC?fS1^n+A=`Jpxm1#t`Z!STEdnacNu*H^q_*`w}V)*7MZA6jLxFRA4 z3Awh>;6<239Ib@cd>V1j{o~r8%I~oVwum;1c^6~7dth2*lltGNENgjcQ}FUvNAU*! z5Uub8_khrZUWF>q+{6|)y(1l;+1=90^~d3H167-Av)dSSls4K_qCNZj>x2_#b^7k^ zquDf7pjs-Cv3}fO+G(?1WYO`*9y7DD1!W^MUH61d+w#%`RrMem46=zv{_*lEm=GFy z(z9Pc&3ozt%k%P5)_vB4NV<%LGrX9E)wuaZ)WR^1)tODgM&oFi$gu4OWNaE&Y17Y0 zrF>to1@7Ru6WQ63rXsL~$G^vxKY7}7A|+9>W1BwaHdM<hqLp{eZZY;Q_aCw-+cCZ@ z%_bD^ff|P1k!})1rbCJeA+jI4jMq?m)Q@?r3nioe3U;gNl%MxZs&G1Fa$TA*2hc7m zUTpK|Y5E?n6A-hIgjI=~EWq)ma-!uU<`XF{z1^L+37SU?ey1OcQ?V;5%gf3-5fs<B z;m7K@%0=pgpphXxdaCI+D>$Az#9>-*S`|AhuC6|OXryyInF<ibw8Y`Yk)*4-p1wDV zA*1>uajCpU8<Zv~npa``FC!baY<yD)b?WLCF+4tTa+QA%hM6mz+Iw1=LsUm4K9kLC z)h9u=Wan-}?ZO-o)Rglt#2nWarqzsSFk@xll)@JzA;{R<2zS)TpBKsRRW)#YSPRTc z)!Wpnf2aIlHZ$Wa7MuD~oOGs*M{c(}diZQ6{FawoUAkv?pIbWX?KgPI{36B?2M>`- z^%K4@_FiuNE1-1=Wb&Po-F3Tp%Qv~^y6KHP58Aj2!c1<k{NZEZgQdcOuPO8^g#g){ zt{$LLOS|!qzL`w)rMwhHFWwS1q;t}@ic!ISY$VCAy+X4C!VbKe3q`~yRKfj-LY#`M zJ4QJ#&?i0aUBSV-o47w0BB&sS&Tt)<9;UL~?QtryE-MpkiYo@URAFO&i&54xc{H@n zA*oegj{i^g{|ZR#Q^_*?)Xn#m`_tEqj%nOq2npyT<RUZUft{c18s0@rpsmEKJJy!5 z5o|2TkaO?_b-V(CPv!>bHpMW!zvO(|!VQF+qnLVaES<7C`$xJa2u5*KL({X3$9;#@ zO1(a0&ok5=a3JT}>=qtc%<5fH_Iz^<7(iRZO!=Bgl=Mbpd0tvxIYB?b>lUk>y7>2! z4`++S^N+b%U2m9be8Wdds2*~n;6ZjOBMdaJ7DZjz2h)Btud?@aR9=eMP?*M+<iqqe zWWmq3L-5@Tjg_7xy$TAbLWP3j8>7k50!boI-deD_9yoJ{DXLQ<Vw==uM`?)mu<QP% zM>^69L5M&KGu;Ef9~dw59C!k_POc$to^MKJU{@tW9+f0iCLa-@dFJ@cP$=0R$?T~7 znR(y<>lGlx>)TcIXKqt!=$MkxM_O&Y=7FzUmnfk8>%|2(SCMchie(Q>*y9LNqjoyF z(N#$$FxwK5+SDU4(yN0esA{A${ovU1R@2-t1Pl~2uB!5*D6eUajaFfP5-tPN<I1U6 zF{N@L5@v}^^SQxZ)Kr-ndh5^q27w2;18OJL&p|g49;VD;TV_<8*xX4+ywkIv0sd6m z&lj*>V^v^`+qv0pOo5L4WPG1>jgZ;f)^=kP#oqcq2ia@Lsn^3~Ko$=B&ujCZ2vG5Z z6zu&>o`vZJZ7R_BGl~)|Hdlh2-s0=!Ii+Q1jz4J}RU90IGRpyx=(tiY<g0k(5;sG4 z@)2_*Yp3YI7@d{+ncJ(d)eTu)ucC&v(vlXKU}u7{O=Vb?b$*2(Ol?Y+?!XZtX_&g# zA~t4627bja&RktD+}iqRDv!hlsOZ$-Sfxbp_|R~&cHiUQom%^3?C7u`@wBFjqRyjs z&q`%~<oEtiGN1@UW?a;9CV#UVju21H;Wr55^2zJ(5S5mZuupW6o~UESLfh(1{yrp1 zB2YNzzesGt0j|9hcY0Y9-l+q%ChHFf1al7R5MfwbtUL`Qy_0qcruMcOCYLEI==$mv zeUOHDjQdHLGHPj>@@;!(^~Tw5Q0F?>xxMo-QsdJNu#UO9B43^fLJ%U%-^JU%mvReV z;1oO(x(h~i0z{~*s$w7+Q1y0Rb+h%&t$z|sBmD@a_Ui3T$~<Q0pyyTB5ZT%FvLIKX z!^Rgq31!&)7d~Vk)b12J9gQ9FC?K#~Jh`uT-NI~Dbg_jq=z)n>$(LuCn+8`TXlNtv zOY4Oc)|W3IUXoWLY`vnS@EUPS=^f*_xT$`MF;zXK<ZI$8OUd5e9hQUkW~I|}fGE>R zWfd+O-8*IZHaLd$AYI<N$tY3NeA(L(n!p6H?eWqj^S&(@EnR66E$sX5F!h-pg;&dM z_VZ$U<_&$iUMD%ZhUi!JBXm%}WAsJ%*>2{=3r}ZFNfyJ*xt8I!!U(<OJd!IKtmMZ| z<Z&>5B7t%|<Cy+l3dEKFOh$UGY(AP!@Z&A>2n!sS=|Bhd*R5TxK*{CdxUep=1KTe? z@{$Zs|5B0QiR-JliyM){(i0c2lhNZt731SVd8b0DG@31y)sw%U*)|y|tBc>qg#&E% zECOZGMBqatfbq4A2%*CJFi(l=p3jcGt?^ePE<^7}2eM)iC=uMX$RDnDO94n9J-WR_ zzr6Kd-S9^KsJ}|Y-)VU0;NF`v%!i6LMVS!P%{H@qH5UOL5RkI#J;x3xBsAkWt*Do7 zTQBMb&quXYq3=2(kz2WS(dAak^%d!jiB5|FzhF$tx}A*A9;?uG_`UoFZ)$;0F`fgQ z=u?=QFA`Jn%CAKwxj7Mx=jzX9j{-`Wp3m>@rV023&INXqi>roCZ)xzYrQPnX=G4;{ znuQlzPtGoWyyfUx5CWsofLIOP+qE>fAN1{v1y*<#SMt7w?t2uVjcC*e>bi8S#PLN$ zFx8mmXWVY-80vjW@K%Aw`x>Uj@Fs_WLZot&pZ<u+iUP;y6Xj1-+<gJIDkYLRGb&zX z=`N~1liJEpvSW0)VZ7%lVZr4pGNPAJj7u<dEUa@PHHZGwY8=xOTrZ$9w;)p&P)a%` zF0Z;L>MX$D`X8q{2ZSLu^AJR(mTVeU`OV^Z`d?Nr6053Nmxk3rihYqL<wJf)*v9@P z+m4)hc<}PLB4yn&Zs9SMgOC}FUfv!WRLeV!%c|-$(#yoAP9yPPOsWMfPX{*7sA2+q z9P2eC3Q{GxF?GXboR{pm{oQYmP8W8p7?cR<kOB8-$cX_qhbgasWH>Y;i7*hY;Dk1y z%oekErZ3qK7T+Repw?{Suxhxir!xMVY~tx`XwN@>F#Pt>b9J7w^R&D!x}e8;j|3AM z=Ev{1G*TEzJqSC2+BDB92L612R8{?S>1ba0sG+9)!a%G0rxmBgsZx=RInH@`gfdYQ zKY}=e$qQRCD8g{ikq2FshbHdFaE&viffW8%#qsKZ#wvL()&+<t3(pU`_+Pg7!>(PQ zsXot0i3Qru1Io7sWpBFa-9wc;JQsMlyxL3Fj^|2S>*`N8%jS#^WjE{m#0^ZU37eMO z+efV{+vi8(REf4v8AUfvVTAD?@~yWFA~o1Dan?5rTqhl&*P-E@r4F#(m0Xns8)x3+ zgn)wE<hc&r7na;k{~A&H_vLzQ3+=2WiDEh5XLLwa9iLU1zo+aa?aq+jP1vuMrDKSO z)^-Q{OZk7N(~%(R_=Qb=7J&wSmMPdRig!-EnfLU%(G}3bhN!H;+j><IRDeG;w=(e& zm;kN=Lyahd!aV{f|1=uJOM%?7ONDc8zV!b{pj<Lk?8d0!GgtS3U~}hGyNIN-!HKz> z&@d3GI>k$SkBqa%4zSAj2ohFI{y`zB)Q0HA*Sde;5O4^6&yF(OyV~_#hkHtK<>+S4 zoZ9kNlF31ACP)V;{*ZG!K_(GU>i&6H$?d^&K%0sAWJxo8GwE(RGtx(k_rX2T(c$e^ zO;g8Dshz(JYcVWqzky8P;|sc3rRl^mdsFYMIfhAn8yVT+Gm~m2Ylua^BG5JW#0PW; za3SPQ=)6?}4hafci6)|-wg0YZSU&MY^Eflkj%R7&en_2EVdY@|f^j4IBo}(?(g5c) z#6T;E?(Fv%Pc3Y5nh*DY*B@Otyij)isCyyyQvRV~OLEdm-FSLxySib{tyI^hsFwp( zG#=1QO%i<1J95HpECK0z8DCe@PIC?!jEO|Sv91=5?0mVP{{fAxB$8!Hoo5!bf$O-< zUKK`cVNfU>b7NJC7gUL7x%juWFJw4y>sP7RCe#B}1<h=JwklkZLX0pK>3IdF=ac>q z0JlI$zswH6&Ln8LxKNp364&uAUz=}GMK^LzCl&<YKqMp&0>XxjJavyp`g(f~n6%mZ zYo{%h)fX4j@WWRmbj@wIhr&S<el^!-<z#h4h)pgd3`3E>sdXtW@{@MvJzGr~&nK)` zWjFyw@2o=By82YreY;iE5qAV?nIex<Ra&U#jyk)9GE>a;a571<o|+Yo8fil_Z+e`P z(uuMxq>!*LnVrAn7`^B${Lb)7)JjPM^6#;ubrF1b<dVRaJf1Ow`gJC^dO)oj*|_?D zKRq<@^Y0pqcp%`8Nb8aEjU)LZM3jw9Q4`m+Rbk>0$LSI#<dS_{;R=$x5OceO$44L+ z%r0&BgJ-5#PzB9gk;q=W=yuiPDrAXTbf|QBwyLgM#&Lte8S{*LgX5;k)T;_~5aR?Z zEB9WTA1m8f`9hFKQ%=&!B19%Y&x68vB(IVNPv4|AU{XT^`un6Na=u$-E?LKwn{It6 z3TJrfs%q)rmMLaNF~=g4huYpjJ^=?ld>nM5@0{7`VNEIfM`Xx3Gl0ds2L5e9sPYP= zudiElR=SFcuieWd)W=R{+>0w9Lcx41Fhm(2=B|7WoJa*sF8sr4{Qj`a)hWu`O)7uw z=<Lv*rn(7P=Qu|yR3>w@oDfgAAZPiGm-2+LQq(QVJ0WXQz<O22#k$n<eFS6pt2!FG zdyRc%DA?&sBv5Y>)NTb4iD9)^7R!v0fHs`*)JaN<{{X~Of3$L2lqm~3)U}1b<@$NT zi)?g~R6zArHKsS6cX}B0LT<s^o;Phd0F9*MK2J#nE()XRQodT8Xl^g1{5_aaNph*9 zxlmTqB`n{7sg77<o&eyFQ+QbsvaGxiP~;rr!ReGOE(i`jt@beg0C=tDlY82qYF)!U zVI_4!)Yeu>PaIK6muE=RWNJ&20x$SfZopCCW2dDn{FE!<Pkkw@c3iKPp5Qq^XCnGv zm%SrJs+Un}-B)JQI#!Bno};TOsUHDfZ-!#r00Qz!zyOocLTXR~Nca2cNM#cL0R3yx zE?kxapIw;VfJaulAK0BQ+A1g}sh*QjYpSW@mcHca@<2}|DOCPd8OGzwA2=8tH9sO0 zW-h1ivDO`%IL!iFk(OJ~RqayRSF1keE{%e<;))oaLgWC-Go9Yt{+RRc_v?}+l1iK3 zzZi#{f@@+PmbH(Z8$(#JNTyk7-H-xC@$^1$dO32~^`*VJ<qfKKLy<PQ^809Dn!|Rd zaqB}ob7owEt_~Oga2wo^KTf$eP2S#~T)g9nQb_=~FIW9^>jvo<lDe>@qYEK??;CvN z{{U?MoeM5vMeJ|ycwET=BoZq7YRwWY9-)fgZL0Jl6%&LwL{iN$JYWIj{l4d*l`tq9 zw!-}ykKqVJg5Uxle{_k@PTvgDm_1$01_@KN54g|u=}Kp>zexBggQQ==p6k#-bq(s# zPHwZyaHLph+ck{DveS^8{$uCzuxxz~NK!+P4WYH5kO_%4n(1tzik{_Lcc7%Da1~^g z$wp>i6a(#_828Uj0+2I*kCZ7=$)Wl|?G4U>6f;*-E`CsVk3aFAx)3}g;<h73_h>1% z-L2v{%ysmkGmcLq{k=GVkaC#fl%g`XrJ%Z=@oJ4XOK_w>agFW<@3=j1h+L%>e@M^w zt)@mG+^CO_4l)nv=jqZ4z`Q)-h7}8yq=JUNm}Pk<fPDM-Kg<2Se1#1RDQ19}LbWuK zKk(*)Rmm(;K-d}o0MDg{Xiqrawe*g5t4QgLaTz1}j+9eMLkgR#(h<~E(t-j-T1m+p zY2S>0EOgSA!tEU(*a+lL?LfQ74`n;a62ii1yL)V<DI#rX%U^0F<YVhjyn})>kFay5 z0>SkkU)#nG;b~FCqK?c)wXI{vzP_%KuT^S_h~*PN_gR@({W3uF?SQ=<Fa9R}?d_t< zl{ynzU)#fbO1BiLjiYB0E2xim7%D&mj~Q%qJon05WAr@Wxhi5D`|5A#2C@ARbuVmm zp0m@>rFB(`+jzG<DpFNb)WVFCg^Vc-t-<g>KHpK(l9n=tAEl!`OvMF~JoSzT{A2#f zdzBL0b?3t*HMI)Znq4<5xZ3##eI(7`d+cM3_-}5Dk%-95HtXho@O+uBpcYu2bmh~p zoNz;2XpLQ>uQiq(V`IB%-9Ze+D(UMX^kspGKbRgz8%QIA&JVvuB&RQTO&{!i)*Mjo zv8de9{I>nzEos_RKxBt2xP{y@PwV3z{{YXgrJ9$q_B^z=D?@;#420Lk_4Z(vs>3~+ z-)FBiwkpwaq!s9twQ<bU@w-TZDb;y~U}9ooZ<#|O@WZ6%Ac8^teftp6m~4^;Tij^+ zhH&iS{Nb@kVy0KgL}hJ_xtQnQ>-9g+rqwVK>1N%U6bQ?l7C&luhS#5rI_c}HSZ=iS zQchYZ<Ry_$k}^DieCNUO{Q7oUrG$sq-G0#OgACupa{VEEHhHf$`un^$TN)T8r;O9R zKuU>PZ%`{m5gCyIi0><%nL>Xs>0tzSl9qTM=b=dmQQd<#+_v?MuBE^Gw{@qggYm}r zRoJ5j{{SJJWR3=N_6MGv2P*^oZ4b$ibAN8e`+N665inVjWOk~Ax{|_0FY*lT81aLg z9Pxwvx=|zYn|(d|XjnALfLYF;xqoIPtxu};2XL?V{b{c?ZQD_6TKa=oU2v{LEk(w5 z3JXl!bG3ZqlA}Iz(+VMEoWA+()I&1x=#wonkd{b3SMvqy&!jkCUOJ0zjokQ;_iRe< zv9iO~agp~NAMe*Px`k;^q&%QUbW*mZe0}wS`&`z_>%|2H?(b1+vCC4iRMwhSsA@WP za3q;wTp3EJ->?u|i~=*BoJ*937Wda)e4(VUJ@r>gkH719XnCWWF&vV~3Y>&zOqE;@ z@UDIT09<p=N-P=l_v0P>(#05>UG1)#Kj_tk(ORvu&|7E!0F-w2tf{S%nW&NpOti6} zhiuH)*iu6ys;aSIVST|vDQ2^}hFv}CJ?tS95SFPU%DNU8K2+umG^wR4D@kxGx;>o1 zo<PVr&z$j}sr&ShqI;}7{r>PlKnFRgJASaTY4p+4*1Ae+tNY*9s-@~ZR6$AjQjvN- zkOax<?-d@V{{WXBme3CVlu!T)6dwJN_p;2ANm9iq9Mtn1ye|*bmYZCfX0G;{zKGSs zQ$<S+t*PZ&nz-g!+2)P5`b!i|PKd$TShVT2SO+79TFpS}d^I1#LMMQw6o7Mi4F$%n zZdZmzXh;sS&m!P17%CO^IOooAG5Y;FXE-H*zbCgNM!jJUKxvf%q1<%R-^^;QmNkMY z5@{xNjYh+O2ydG>=NLI${+$!^03ba*w~y0U`3hUm01a$S{k=IuYAqFQT%U(Eggn8? z+CE*`z&SjD<LZ3%#T=}XTfytQ`|yHMcV{Esy!yN=64KJ$?Nw7z#c;OAQ%@}P&g~l1 zpgCxnknTls8!2Vv2FSs{=~C7l<`o><*Xy;Rg(hn=_lPX*Q`1_0n#MN@dwWei){z$5 zLscbQ@KVLS<E23<2@=Tv05b&#W?Ysa0#0++7N8gdP<MKL`t=def%#}_b4TYw?dQzH zO#r8-xc(+z#Cl>NxChDO&UrZ)>2t_5Rb2UW*z~kA7$BCdbaD09Ut$VkuUdL}Vp@6S zM)bsOkC<VP%AjygP6tB(6Ws>BU(3QvC>+34>C>K`4QRrq?^RVtK}R)JQKh4e;irs} z$rf8IRCrRv0!Z>yeL8u0oCeIs$Ic0tDFEg=dKzg_U6{t`Yhp!7CP}2FlPMr7H+dPs z=Nyl2hFr9zLn*EH_jv2qI#S(s*cxln*M*kKnQm3@LoAWTvZJ6Sv&xOw@y19T^~Fn| ze<wa(_m0e%%FHXdy~{q(*lW#9jV(e;FfpjwhkLHo00kJ&zA%2BHnp)YO89smN-&4; z`I4ZdUZsop^#-1LM!H!{wQeIKT!Iu98^2SJfB5mzLI?~fJ^drH$#(|6e{t`mIK5m^ zM<ko83?Wz|zCL0{+wZse4u}CJtO$jfWg*C@>U^p6`WVn^H#ljSLlNFo#@IQ=1AM@P zjoHV)KU2~HA;NX*<qRxTQOB!~5!WptZ>u^@6qJut^+AYkRI&C0A79PS9du<&6$a;C zV*dbbA#+&(JDqht6tIO!JSj|fM(WBLlnFTBjGe?|gMszVI%#uKT{qe9)JG*HW~y<k z*G6|{1OV*M2rlv4O=QtfekQVjGVPjJ{Rtl`yT;AUj4@>#oPZA-hzt3FBq0UIuaB&8 z3o1G4>_u;CRCsFLW9UYBDdUUyOB#Hv$MSr`A3$@*`nvDWNEHr#qxpnPKnw1c8xdoz zeSxN}9LYyXS6=ZjnvS9_Wl{(QhH||4JOlLT;9WJZ@AmO~!84_)1&HTS(v`9ItUFt& zKM#0@IRaFG>b^2qeTnnszu(t0hRUR{)M$G0W`^gCgct7`kFyGNsV->}CEC+V2By8* z(yofSiYm~h>4{WiEPiy{s=*F@z{i|)-d^(o-44g}->;N3iHgW30_4?zx!JcoiLa;1 z$>5%<BTOWxq(K~L7<|mTRY@fKoFCJn@{$~f-`zxYEo8U`^ap3VSkrR}gb{CwawH`& zC?K#W8~{K(kFf`)v(b$l=zU-73Y3)~XJ>J-FI}hw0oAvUM8C3T%`I<eJEKigagy6* z{A{-Xh~wp!D4|oh9G@aHj(;w3(x)lip3m>bvDvaxf`Zq-Kzf_F9(u>miTe(t?M9}q z_>jFyx6K(xQBQb+-n7umJw@g=iRl>yO34UZ$meSzAM;=icWp5Egb)Y>BofsQJdg26 zn&E*9Q5PUJ$aL#ek;`*mKEiwzd@^3UYuLRv@d>9jhMSU^^rar4(n~FMe1GzCimnk^ zQ3MpNB$3m!(#oE*kY$~>2W_Z;wSdCSl7lsO7BBuMN`Te5*OOK|AB~fVWPqTe?`oY1 zYP$}DOPA>b>7M3k-7kNtzH416t1kM6aEowSN}3|3Sc0Idn95w!Ll~9z#MH<U3XmE` zVooegVhdJ+{-ylbrjHQv(vZb3PjsO5h167<O2uvMwvDx2?e{CVm^C}nLeW!2Nly`l zO4Gzla8kz{lCnjTv7F?*gu8Vi43G$?mfuY@)V*N}O$k2ri#R5VV3NS<@VMTr8zF(@ z5PilEMaLr+W1MmZG1F=<3FR8e_xXW6CO#)v{z}(qC~eVGeg?iewU{jIsDH^w4<WJV z+<!kDbt^RuRn(ip<d9ULQrBSmYHeD8{xPB*@=#UPR4GV9uE`y8pmL-i9x{g{dya=0 z2?dQd`)&4zriGzkW^BaK#c68Zn$%tPj3AztYG|RJ8DXn3VWb5}*^Un1u1F=h$o_pG zkdi?^rM|GsOsc9t4mH%1tFRkUgJ|&G8^)_9;*8shjl<>6F_Dfwrys9eLI7Ls=HB(z z7UU}{yT49EXyVPMjAv4k7mUxDOo|A~kXLWF-^Y>CfC)$(hri}KEh`EQKz#<<8@&fu zM9m5_pF%*ar9f}f1mll!!2bYuPpH+DU+v5F-ae6sX@P&0wGHdbV@M*kJ+@h5q=LSV zu_lCi-@|1z(h_ilumf<~8!AeWR|h!(0Du4~soE43F>0+^zj_+L^sOZ{bH|Ahl1;6^ z`Hwl{`2&u=b}ghoHB73|ooV$qr;&xx*HlALXWY#$6cM`s;DQJk`wo7%$4U%nb*<~~ zln$^#8#`#-Sp9V{ntF-gdWu(yb6UVeVstykPlC9}KG^m=b;)@_%*+Q*yc>qW=5Py_ zVWar+I!9AJAG<D{uG7zW)btV0+#7-+sge0KkmHC`JOkr*)bXA<B{KpOregGGZNF3L zNEa5q_)M}w2Kw(|$N(5McZ(%&N@Ft36i}Vs35a~0eMmUu^wLuGDQ5cX??`1Jr9r<j zt!wA+=@Oj_M%4*Z4#2Gl`5126#IeW&@`IdX?bGj+kC$#uVbop&%zwdGv()QWzkw|T zbaQ&fFo>JmfEW$l!x_d%@zLQSART@6W284eX+a>Wv~An(&_VsJV@g_$yCYl>7ia`F z4nX69{;c#Q^ox7<^^cJ?O<FTo_NX6tE26C83XPtUv_N`kfC$@<=luE<C77tcSWuZ> z>7nL2-p{YpK_xWv#K6di(d3MQ!TTTRdSopHJmG1VH7Yfy(0imy8cyzIyIA9esgVoH z83-i0`Y(XI@N=~Do&n>Wgc(^_n@2OLKnqgZ`DqrXOA}XCilLMPm297rpCkGI0I#o# zGSmt{$tB`*j*)4oRnW9k5wNnHG5%wolWSHww7JZaUwq(7mPLBn$WdbxfgbQN&R37< z2TA}t2xN?;lLwTC(+KiDJ@fS)G-iQlI7+2qO!vxYLZ(nQInNmBw9W;{)7@dSET)Fu z^o2G{{635-yA)-z6aWFqB=P$H0CUnpQ(Ux%>zSa!JMB9t48@o4{@?A=OvKt93ECT~ zt7*h_B+4LNj(%?nFnK(V2TqBgibD!cS0*v0XrrW}K`MZd#(=2W8|46CeL8SV1T--< z1dI4WxN0{s1w@O^2>1K++JtggtBB+0?P{$weWTaS9HB&-Qq+YCk{cIWi3{f-5>$Bi z9S4Y#kV1~*UViJ!BzR&{CuUG<SI?g~@zZmLDvFAjwq~vBWGZ+ezFhD}xWWB*bs~_I zr#f`^?Y~&GqKRdOuUGRA))v@ffLGdOloE>QCXI;<#7v`)uH^s^zeD1s=SfOi_B&T| z4g#V{RkpuM8@}{t_ubh40EoRm@gbqLR<zSLJJywg8>LmB(78M^x8ej5%e_oz1u;;A z2L~#6>DA>5U>&17$gI8NQ?VyrZ5B%3Jaf|1#Zsvxf;G+n<Bork_UUO(qR_P4oK-K1 zUxF^p{7~t+DRoYsr>4}gM$18UrBzC*Qhk;2^DjT-+bU1c^Vb>0Vfj<aOD3W`kbGDC zA@@7sLt2|ArM9(pl$t^<*N7t~8sLPTt`2=EKtD2nHRGO~moy|)b@u-Nq(~Bz-Wut4 zVNPeRyBMeIOdy5higiVWGq4Qp$B+-y=Re5wQb;vo$J_ZrW*{+YGk*3TZ=`N%lztj# zR6-!wyRrenJpTZ5=g&z<2SyI>&rc{oDoOxRWVeTq@M~`jjZqaesHLMQ85pStZr%wD zyc}cSj+SMXWdh{x{`y0biOd_vZjAo`#f&3KQ7$o6L+X<iKpPa2xej&`JTL)?2itMz zfIxV9den~NF!p*o`1FZOQADeyuJO^pDj|FOx}zKfQwHF`{kb3CuBArCp83KU*th|{ z_ouz0D5Ix&8fB`1S$4Ya%MG$)3di(5$Ag}FXskIt{q%<tl_fwLoptjaAclF8Y8e(4 zG4r`HMqKSokFEj1&)*$1gn;DS*TMb$<B2Yx?cCGR*g+<to;fXgLTHoI28K`&CIKF+ z4WN5`+5TO11ZJhW`_?`REEZ^|{s&9xxP}U=e##cElA;BF#UwGpGOWmA^*3ke0Slk2 zbmkzj%z8b?m(~$2A;JP~AIslJZADK;qFKz&h^!$IKHN!>f=80PjDd{%5zkC0xd}`? zDFZ5q{l?xrtoEcNs0|F$GRRyZ1TZHnk^cbw9A~b4$L@>!);h%mf*RVnx4)z!xJ;p{ z(-DRTBxgJE&VSj*)2F4VJBl0a91{ENojuY1(2P2OOk>nME-}wI=N$Xv`*h$1=smSz z)aDg5{r%PmY?I8<-6+JtAJA#jjC{!3z##jKH}yR*g(jlMnQwkD$O=i!lEmEgqkk3J zF`nfVl+q-NA*c>nWi8GRkWaU<&)9X;EgL_KEGYi~nai)Hx_EfP8p@z$pJ?Ppv1e`p z##bx!ANll|X#pXJ^M8I?K!mMC9ew>FyYU1yNx=guIU9Uq2m22lI+Z6daerTaeh})E zvKSv<FTG(!U<Oz~cEaspxbVcLPdW3JCp{-QYKA)LPcH~jqDz|l)R@$@E|FBzx2qtG z5fD6p2^k+?a&z_R*^?ZKi+%ceM@oC{b=%**v9=Q4r9{|}N3f_E{HG_zf3N=lZ%nKa zeSW&y`$J?EB8J*hzo`3*L!C8TH1b;AT&smi9OIQDMi@UqfT!!yR)mlWg*yBDM<yhW zRola)Wo44hc+^&lHPchsG<B+?vYwi%?_RRKJ*F8YRBEY`6?s}H)R$$A8OT$$S+F`K zDXp3Oql&~RIVvO$T&qj@m}i4fTUu+ZG_|QcBr>`~Gb={3fahxkJdMgXV2ps+BOvsW zoWKUJw`h<Fa0a7ZT3gt;v^-GWDdVf9xKu2LD49%v6NgegzSslr$3U8wL1Fa$;k3(9 zNh=iU2iiU+`dZ4^N>5VBE5uzF<zN@tjxp?TJdf+wNn#Q-`o~q3Yjst_R)i01STTnS zjP@wzvz<2A)eAvO__1(Em727sXY@p5gH9hI3;zHRsLSB+K1xE3OZ&CYgLy*;bunu_ z9NM+@sH<-p{Y7nszEuRNUuORRXdHjWI!YBn;r{?gB4ToZR^GOFQzH873+!_;#%6gN z*%SZ}41Qp9lb-|(ao52kM{&;l?e_A55=~i5r@q!>(3`#96vtUHM^KT~At7EaO1=kz z0O!ZINKBy43wSq`X?pJpoIYg%qrcl<*a+C(nWvH#XydE(<lJx$!NB$&=O?77tRCx@ zr(aqT$`~?=PDGa4#D=S$rlvJ4v402EFC(1i<>7{Xy}zHnanfAoC7C^Kv+GSQ1LOn{ zDxf_10(~H-Nou>UjK5m-6^gQglJ8CHLkrYW!&a!WM$o=unO-t>O(qEhsUd+3s=;yt znotMw>94b-eKJ`HKjQ3OqmZbr{2Ay0foj?sNNetOH2Wi%yRwi0W&jV&PdMAR-#+~~ zg(R>c+kbvAM3m|*A5*B%ef;1>mP$K=RUk4%u4ai|OtC(|dmIn1*Q3Pn6du6)@zYpf z%9)gu&=0ofQ1$EK1($hbhf>%2vm(aQsU?$WJAgmXfPc4KRMrFmQR7Da<GtiE0RDYk zbv{;t%T@BWz%J5I!C<X>jk;O}HM9{_H7rs%EhRCLPop^Wu(PHeRh%gs&q1L<vWs>5 z?+98H6`dYhztn?vw~REqQbd3>=_7iW0AOv&<KL0}zMUm2H6vdhutdZJl9EX$$an+v zj`x1f%Ik#>XK5?xW0UX|mG@dQ5lIebm1&zk_*Q{={{XX}e!DhdaFn?WJ!n1Y4^BxR zElarZW_mDvIaQtGq+bzzt+MwMvQ`e>-<pzoDXsUKnz&BId8#R?N+gI#EFB(Ibt*HE z0pRooIFilG`F|}8;HUh#nYoMl(wkGClv+zCZT>3z9on5ep{%qvt#?gJq-_@ZZ7&{* zq^!5o)k6y6tk+Y^PGG5-)g#Ho#fWl}V|il~wT|gX0+h;6s5|(JkYIUo(4~KEL{!|1 z8v+iXdGEBX+1*{=kBQ4pr=!02UqJTS;3)+)KAwim*Ltf_ourY{vWcCy%?mK6kW_As zj4_{0SxWTU<}P|l<tr^H^>JMt#>MrpgiVy0Q~p}qjjj6AQOJwM-O$z6EjIRh_0v~H zt93on1cI*PaiWfz;V3J)A*GD~+fHSQMksQsa9PM7DM&~{f{6sHTOGgyXc)avH1}AY z?eMid98o-rBmB;Gxn%{IwiKW6jo2SxIwU2jA1JwaQp^ZE;GBE(+%}=SYc5aI$2sXF zZy##W{D|b5c;}im5hJXOH!0v}CnxSl`+6c|@GGGj`Fm)7NSr`WtBZ%LT<uLpyv#l& zlH(;MJ>S(+Ni>a27_cLHfW=M;Aam#XXB~Wko2O5Dx8HLgPXsL}cL$T!)%DbMt46zB zJ<6u0IvQ$es;Zf`!2+pyB_2lH47Zbva&y;7N`_<pSKsvaNcl4h_mu5nMqzd|>-8|_ zPb?AC2;0?2NMOLH<;WoM_4EG#pIpiSKdg3S<qD?l%AQ)if0%5n+)_VEY2;6seB|ST zIbJc3*RCN<89+COg<t3%K6K>H4N2acSD1~pj`gy`Q|bsR*J3)d{Fw>>IP=cj^NxB# zRF*la)H)wCNJs-Q63cR_+_4w)@r2@)m1CGT6qCvvlh5|YAAXz*t3k5PLJR%a_z>Kl zut$2Ls)$ikRMWjb<7ptPQs_2F8*+&O!TA`Y;P~_OJvu5KFYm??he!%F4*TlPUA5Hu zRtFVKf=HX>n`1MojH@UIB<Ihc?bpPDa->ojO!1h77GACO=k$s1;hMy{m$(-_b*QDD z?{Bc}s4~vw+Is0mBL$l!m|^7S>F>uxEiSr&O8Aeq?GMPCl2{0LE+6XqQI3=%vC~^+ zRZYbWWVI@+<2lC(#~B&>0nmyiWz=h?o&o_X338fqcW$p<l<5xh)wMN6EiiasGM8qP zji+whA3hs#`|;F-sf02>@bmTS2%Z#6B!DXYXg`0}CtluLjq&A*TEfOe*Z_}!a#)@Z z-yhSY;^*WvVBY<Icw%I<ut`_w`igzIyeF%vsMGga`+ZNTPjH3(G4f;v3<&q}jQ;>` zij=IwGF0j3r<fmj1gX`LK)dz&7v}VW>rLtE(JCgWN?Gb7-U5u2$siGuey7Oe`E<5& zN`9A*gwK?s*WS<L>1S&}T`j_`>59c%v$SpGAUOx@58M0ub=6@3450V-=Ly2FB&A<_ ziiY}<(ezy}LP`kfZxqop&FSV^eZy%CN#TY+AmjDx<0RxHfL~}S&=efAKl9<HkuK<u z-b;D6*3{pqogyqk3Q)N$F_L#@82<eOhyVZptUi^5laWQW8*+=EqplU-i8Gs(J3@YC zQBQ({jx)RUKdI_5OHdSO;g*t+Hm|;sDc9=DwDgxcI(wB2W(22|Ski}Yn|NS;VaLk2 zKd(T{#~>0Y{<Y-}2|-t^1zYtU(h7&3TdfTHfHLo)!7K&<1!Nc>$e+KrOsk0`48(^% zA9zrap|FF<wa=$c73893U^i_?U;e+RQPa4X0{4adm=vnjmDOA1GNd4!gb{|u54j}! zbNunp!ycjeVfA6PFous*U1=GT8>S-1%D#fJ9QY*p<o>umdRgMDD$`h0xjG$T>}$*H zO46E$r3kyi`XCp;IT^|G<2d?sKjTR|LX46q7?>kXZ&g)IByT*M9ZYUEDQ-qR{{UW| zmBdY~{`Q9ENF-21gK4@gOejpaaq|`7us=_4Ki{dWE-Ay9i<>N_i7-aBQnCXB;z5jY zkWO$xKHPOBcuGwi7~~)AX1GL}GUZJ;QYtj0Y_W#HkjGnGK+gnoBJ=kBI%6E3>OtSm zB{)b5oTm@neYMl|tz*ZfO<%2YX-uA``P>!f<{MAnf^t5*o}@CAiZ#CdJhqDf4g#C( zeeVwzmx1nfQB2Y(YPw}!GDCXtlgEy6oP9?_s)U;lv=&p8Fh6Jd?;g_s0A{_iYOjYq zO;Bp)qgst+bPiA>0g7rhBomUqOnYOdipiLE>BvPMb%1R;>m3`WrmTgLBd&d$KvETP zkGH>1B!+=hmUw@9$tZ30Rh1W4f}SX3mCMA1V*yKUJa`x<r!p8)N<|IgDENZ-bkd&? zdJg@ewWQ5e1ym&#Ps2mG4n9^^Dt5Y_Rs5;?f=66KJ5a#_l@yN}-xogz`m^D0UR`vx z*=y?U?9Dy0;PGK=X@EHm;0YA)%g>NU1QKEv>M2#cz4^o70FUkZ^XIO*MK_f_I%ukz zY6_21DJN*riDbj<D<J;>2|Ggp^dS4-CWvBv=?*74i;(5KKD@Ptd*qb$7YGb8R3I=# z8!Lb|GN4ii+mAUtJ1IZ{oBq8TK1xAkyFD*={b9=aRcV%sOGRz7$7ZgEjsE~2TD1~N zZgRat$sFF3e6X0~WP@sDTVQkZ6Vh|I%r8J7i9tePLf8jjEJu$&q!;QrT5Sz!k~fK7 zrr;$2V<AO2Q~l05`DH0Xzw__dhLBa6NH%IQ=kJ#|5v-br@HR?%dtjoGDV{p1mU$#< z`F&|5Sy@=(G7;m8VgRSp+<s<b(i!NjI&-$emezz!L0XQ%+K;nqPkTd+w^H0GH2$Tq zLmdb1`?}G`TCYnR$bStXmJ<}LH^uYt#Tj>i6}JEfUuHn50E$$a^@XZhi5Zn(+Ou@n zb$w%0)sjs`Zlj;ldU|Cjn_TB<JN*55Khxi(t!O~Zq082giBgDJdz$mGt2eARR94d} zm`YC&QpHJ>fN{o1@%|CUdPyhq0zR$NXM$xY)Ptow_N{rmA^qhY46roRk<E#Mx=PH^ zvZ)7a07l?^5ynC3NRv(dKGD@Dx+?zwR^M+5$Lr<NlJ|YSUShXeZ1C4sR8&PtK@o<Q zkJOQ$(TXgckgn~hxjR%S$;NtZL2LjF+C5=wLWRqkR?hzbet<)2JANFMjSdRNSd4&h zNdEv(@;c<Ve)oiNDNuBQQ?9;eeK(Clx)?x6?bO6PD~8GWvV0zXTw{*DN|XRET}A!< zp{a-}aQ<urKHD{bqPEvthM&_l5Y<%E%{63{o2t-6voEC-#O64p{6R7x2YHSh<I@>Z zrZa-hHP^!Mu`DQNQFm^Sqx<Uu%Tps5m6;KU$-;hN^#1^Z(`8_iZrsFi2}6{-Yp%Y3 zSl<lwVI3unQ44eP5LAPc{-8gnO{fPIf21vGDVPJ3N^W1@=^4;e%Qa;(I>PbEE*v`N z1IHuW4m|Yw)*CYqex6lm5mJgkcc5(t*!#REmg7+}fU(d9A+h`sk&(yq{(N-emXJHc ztUoSMa)jvi&_C8UHOiiPxR##ZM9@fMiNvtsgJDCJ+CU0hc0KmvJm;^R6O;;j>j_YB zd);fTx351)bD^3AxlaXk4ZKo`)+PYB-b}oX4;%tAI^jTMkUd@=Sy4xD=sfl74Ht`? zHr8g6Iw)f-(W(rn7$@b=(}U-wB@U7~LJ|sA=q;<zQ}*<|ViB&mAeu_CO%SM&)<kfp z3amFN0F%#<dOlEOgQt(y8(IP%Ovj@ica6(mZ)$grqB$MRrMP{(l>?8jKhQT_RHgvw z=kKg4RwaR!wN=lahxckj?XOhZt!L1hDuYVz{69`cV;BlC9uK~K{+`_;WUS*lL~u=d z5LkLYTZ8VAAW-UBYTB9Nr<P_Ce;JjA;+a!}_TaDE`}7iUs{GWaJ^VG_NG43q>T3S$ z`_c;NX+)4ua+J>h0ESliScO;0I{-)j0F(a!1oYB@NMb|O$7V@NTP^E<Pudg1G$xvx zE@hTb;X&MT$opFdJm9b&&#tWs&zy_%{ja{8LJY?NK_hOg=H{HM4rx^#Jk_wMNf%_0 zAL2F`leC;<0n-GvKnKT^E!0L;D&d^+s4r*pXxAs<>Sjuszm-b~Hmr`R^v>Mlk8Yb% zlKsIBtQxIAIp<DQXMZqZa#X<0Jw^d{l#;n88RzTzbjpxiuntcA+8bQ6lHMO%Q1do| z8tZkM-)yr~Qr%&?T_s86xIk!HCAG@4vLXpd#KF^Kg_Hoh!W^(YEFdja7xvZDSn5=- z@HC*XXJOXd>8))H)Zr<r;}J(mI9-y4k#_@?@JYcVk^MScxw%JpICyGtO2A4~Xe<ur z#=ZxYBTe4#Q$-06EJ5T{3_@~PDIUXv$G?y|c1x*&uRp)>97M33px2+XPZxmWAeKnt zjb)B(AW#O}@#h>5fB5s$lEu)gKejRQ-Bfzff%<G<vZA`0<9S(OQ#20np&27A0){@^ zc^^&=Gsj;7oW!J9b%zj?WKQAfZlG9J9qE9nb!vucg=b|U%HlGhkih*3;~%d`OwxO+ zY7Va)`Ds>#`P#byUkGrPqMkUSABKZ*Dp8#mB#h_C`T^I+Gd+f#ItGRJlC}zCQGcVg z-Jeaa-HP}D?nS-(HE^(^Nl#yA)b!EM4J+=Nq&-x%jUgC2cWp7?U}1BBSe}K%xJW82 zDQh)Lk?gqT59H1>gLF9twIzn$zn1PP9gEhQ??Az&Y+9>PT&SsGr)rvsQh7>G9XzNK zpzT#IvF(gGZWlNi$pfZHNg#v2@8PURNzOA$(_7V}rCpBiR;kzj01utH?k(p{_b;>> zHr+h5Vn{8kB}LYjk=iD!IECJx85I|VK~zS5XjgMLXHB46u(_v(-uXZ#Q2|M~<<H;h zU<b4sUr0@JsGy#voZ7WT;^Q^pYqbSkGT18TmFpgg5ge)(K_RMP2+O>Zh4li4UVy?& z3r0X{T(B(9Q%45+HDUZ*^&au%aY_argUm(UrqWj4+0yCTXKlNCbgHt&RqE75SQ8ZV zQN$r>W0qs<6plGU#}NftRG<szqp}34DPl*R0Bhgt2+ZJ+-%mz~Z%$WHTq3Wnv`i+l zJou;K)B*q!1y=)VZ_WT1Jdwc!zK{r|qlTsk3vgI-(`ux3qgie|l5_lzTrtT0O|;#k z?wm7K#xzEzf^}An)oF9NN9J9?oa5><_Ua{luHk1ZVi4LozRgaJO=5aiWO$MS)VT_B z+HJLK*Xkgale7KKj*VS3CZmQO#ZLGPYKNXqNXL)qj-%#w*N4kB#3X^JFV@aJkZi8V zaRr~4BoI#_eKmi-NXnnI-PAHs)l+EQOK+N##v`6N0!)mg9moR)3=bfWe0$@sk8XHT zAt|1cdN;4@)I-v{4aArI&yr5$kaevdui`h2P1)Y-2&Jf#Lh8vNBuT^@oqlgSLEZPx zKVF_awBc%?#3wb4_O71vtT+DvqPU<Xq?+xPg1SDfMg-HwKOWY1VuCmtmq+SIrvqYt z%(r1!5W&=f5D;7Q54ig;4ci_TqGk!`r#I!|(bV=Yi77q#Dm0@8x9ii<qilVJ?#)y+ zGg9cP=TVF+Oo&^L3%Q1PKVKk^ZiSiKjuwIvaS8n^UQbOasjNsy>=zLvtx}Ii6=3gu zHQ2S`Twf2p=8~GCsvQe<Xp?HOXNPh&amts@KK*>2-f(x#^ONdn;0@umb|Z@i%`YqJ zN?e^@zC7XXm*La8^|rdnQp-hcsHczB{4G^d!6fQQ%0vj-@(dG#ef;z~z9R6`F*)WM zmT;Rk_C7)Upu_O%h?zx740a^cKZ}q&7}60}{5kih@Wp5>7Wp1%1As16`=jT8z>&4E zJaflRpNO0&5~+(c*(cKa@`k5;I^u|176qtYj&v@}!tHB3Po22z{;koPPfywEtd%s& zB+}fjvPSW^^_cAn_+y=sssW7SEsl&Gyx~()6E1KiO}~J*@?bnyVt9gQeAUSyBxqAp zy$csFT^g}#++3`5^#1^hq;XD8BRLGxeORC+faA%*;C0*=Z1`p+Y7(Z5!uooc1BrH5 ziD9yQ=@e1wd|2PD9JS#cWleL{+uTysNlZ(bSVTcg?IQsA&p89DDmz7kGcrn=lA%yD zIWZACv)I&01!z`M_f34A`ccV=pSJqN?H3qLN_r4yEysl?Imfu;+v(Gjak6NoEiFow z4}Vw#1A&~N$wG?xB>fxTIG6jUQKt0;JvO7X-7S)87!woIiI;suS@r;eWFA2BMnLd7 zjg#7b9s!oTLID2&ykE`?PiAq;%B0Cza9g@q{VM$Rh(?20{tk`_s$-TFfMBshwB^YM zIN<!iWB&j?h>_Z!9;N)0&q9dUgZI)oitN@s1N)K`1t>!?>2Fw+th&aQX<?drfGK1J zW<EY#aCrld_jNYDIfX)5b5c2#2KvNQuFvA;C?zOSItHya=nb#6p<Mc|o+^qNTBW8{ zsRDANJ3dFlapYvE<J^v;=Jub3{Isl3MpAE>Hs4r2S7i8#mQtAtBoFwSnjM3VmMkOb z_@eCQjJ-={)E&ywmb=t#wn}*_8`MTolfMc=oG|l@<D!#`mpM7*s3|+R_vZ<n!f`2D zkfbXx8z`T@=@Vvx`04m|tCi^C?<Ro!N`b%BxukoCw$k`kJb}UbbqS9|)JrERZwB=A z{h-_@4f*qgl313zru{&x#L;i~t?Y!C30AMUnsSY&SY!31szDnC4S-)hK68WPEOFL- zKa3#EDJ6)FHgCah(uoFitRmJgK~vCbI%!ytD7*F9isp_w4(xWqt?*w7eWe79Wd8t< z=sbVt(iOxKvPN)xe^Sv}o$%|l62r{E&QJdU!&7|>5mWaQLe#?bw3^FDO-CAuJ!QO7 znD+V3M3^J48KG55pT;7m_GgJmIVVV%lm7rx6h5ik@9P70p7VBwWH$N^>305SfSBA1 z<vWl|6Up@tlHct5bV5{zXBFK0`a`Jfrxckl<|7R;KvTOdN&~9{UoLPXS@Bu$*=E01 z>8rPYdpB*p)5c_)mVHG`rXX;BX5F)QAnXTr2sr9?PYs;3g`qC-SzaLsQl-qMQq5ur z+&4Q2Y@g$A;XZ+cGyFz&eOL@}c#Xm1>bV@A2R!tdoFf%g1k=Oygp7I=5>n;)AJY7` ztQ_c1i!X;Abp^=jp8Ix&>ePy|Rgrp%CV_BHO3SdH4f#R(9!SWuFshP4oXP4(qr|@n z;TYZ^3UD$PG>`b9M)c5cW2{Ivzlv{%sHaGee>+zQP*?+aO}^e+82e*Ac2^O?g+I?l zEBS<45%8<D-!0D$o<q0$W&B0NSRFCmUf17kM!wP9eHC`0EYbRq+~Hyw9B&(UZpx9* z91eVQ)z1pKW?p0{5x7u36gTA*?jhOUBZrt|;xN+?u*8KSK(Vp4wmQctKWeV4vg(tf zX>|4O&!}i8(f21aTdSg~jh!oiRo@Y1ClfKodHQ?wKNVU@LlIJW`@icQ;aDt0xr@$) zC4iwBUZ?&*b+xoH<f=wlD~yB@IDhdRatEAyAHPxy%)lEq(KRAcp6+V)Zzq0VXnV0K z7hOFda<ZDLeak1XGbtZm>Fv`DqD3|%Pk()5>8Su0CEDlKJ;eUZ-@%WCn4?;nSu0yq z-shGzky=>P$r39%1GxiwM+JZf2N~&1qP0_9JU+S$Z4~LulqF`wT!T~A#P9Tugg}rh ztV>r*>VOGfSu^_MJul^~TzWvR9rlD5`6$hj=TBKwk<m?0Bn=|IMhf8JlRS){9FCrs zAx5AWZe-vYD!dfi^#!iu7^$VXTPmrX8C9d57!ZDJ@O)r{kL}X_QpqeK&zgW8N6ssE z#h=3tjrgbSUE@pLA&d8!i`KN=X;et`(*Sn5Fa#eZcK9HWMm!{8)kQHr9{nL{#UTZj zq2u}=I*MFJiN5}J6JK^CUS0G~u(8!aRE+S)BvCkqwGZWZNM2No93DY9KHYY3WagY1 z?Ee6~Oz^pC#3)OUlQ8lQO*R16cz~*{R|sUNj%#&sT>45HCkhUD{d{rIgA*-PN{07| zxx60|iDf2Q$Nnb%UL9k6v~{vuDMH$+=4M|*d4<k4w&nKs4Ugn{Bk>5ME7W;Z+pe&m z^c+}E%o0_Vg>os5l`ZBkv^~_)SFNhmO%=N0WC<eeXdiM7;D#9-H#j7D1dh7AWVNI^ z!5x1?^@k_+a~mpaO~b7PLBLW}D0Qf}O7wx6ic6$Wg_ee~3o{Vr2OD#N&PUh%eQ^`h zFt8xEI{Wj1Ch)n^NnFWf3f&|P^=khB5ZEQHrgZ#dMkZ2OP%!|E;AA(99R8g+Ru|nC zzK7o^4HA=-ie@8_rQ8mmv}!jxkrdEH>l~mgz;J_+$;TP-!RdJlg>-_1nzDwvvHlDO zTrah?lGaj0l_H6wD8&@~<o!OMpy||8ksODtd~oct7?v6h_B;A?funP&mX3Ogn}y9T zTb3$#BaD=lK_Ws)U_lG8i~?BX9k|JTDM-Hd>&_ZdCLhQ$^u5@4bhfS_;>%er#@drl z9KVf>uHosPK2ylR<Hmol)1(GSAvP~>rm)=UO3DeTYrekEj*vpWq}W*DD$Ei+sHY4^ z8}ZIda!=HfdJrIjD97*a@V}7md~LVfbsmwY6MD4Oa#Ku{=%O+595w;u{Z2EVev-CP zV^aeByu_Ds{{H}N`H1^ekgQ;mBOa7^-Z8j=f_e5h$MWN@Eh=}*PFw8=DIt=g+P90` zwwqGbjdCP!8az)cxnf7TIXq_{QPW_Y#X3W&nh+mH)Q<p1@6q9X1-ibo@d-&uJyXQB zGtP+LmN?@s?n%cA0Xh3%1J~I`U{2iLI<SK+%A3R+^L_j}!%B!&Bxzb|f1HMWq!Kg8 z{#^0*=}A+qnwvvu0HuL)C|iGD;<36LP3o;`V7J(>RnjFh)XVs4IV9Nva`L{=cE+KB zE_hMoj=m}dS(ItE+CehZl^>Sv=~2&pzVMAGZqZ81Ewa8QFS%Fn?~peE^x*UT`uS=} zOA5EY;uH9Fk&<Z+^b~J;wIRxm3W02|tav7hsw$Y|JJGVqo`1Rj0F3mrG`M2m-lN;c z$&`@cenWj<VAXITn^NAbw#`uW*PSO0uc2A$oZ956o|Y+ENTpcMsR3CBrt(KH0D#6N zR?0I1OqzfdPIf<jL!Dq?WuSKrX?lul`<_t<$w4@$np$`&C5;M1T(8XR+2_Z;(t09U zAs~|9zbI^BA2Q{Obf+?Y@Rlg1Y_yd1F+wr`+E;et&UoX;pSQL;bhx9xzHJE-K_rkz zyYG9`?*z4WIH{<a;f`cg!-*d%4<5tnaBy?dW)(Ao>FWDIXu>dbq_&%mpRUFjt>3+? zRo;?UT6GGKA#M%^-MJr_fG|h<x>CRuH}`%}lm{-|(N{k~tA;J9+%!#AyS;5ha#~)O zXq6IHk+aLHl6L!e-Twdsp=L_}X1_0b?F6CKLq}@1q=GAP^m;TZid#i3I!4l@6%k1) z5U;!BB{Dz<{?DF=Qsya`^RK7xrtqZ8N|aM;Qk!{lJ!5n<D?L>;Y*z_qrB}m7GTh@l z{<#PFj+!$-yFP}32$wB?EGk+_9w+$vbAq_-*GiXGmfu|Tn~4HAOAi^rAYhyk#yHPG z&f-#Ksiv$WsRI81ZhFM73$mPB8!XSl;U-K1<tQXya2exivRvLZXl)lIk$}eNTx|ey z4<BQX<<P^6VM)p-B>w>9A=UOHjZ2nSI|no;oVB)`y!yiTl2)e<VUjwTV`Y(8oPY}C zmBw*^zpq3Ivei_m5pN#0`-p^@!Y4|YC9MS~VhBE7$<h;4!#EWZJuK0*Ya6-*<Z^Sk z<Hj+M9uHl^Vh}@xhqcAOXznY9VkP%gEnvU=RGy{xZx-4<+@PM4j-@4OVp6fO-I40< zKtTSi+<k{iQi_1oA9ZTKu?b9t0`mz3a(!uCo7sSQ?h1fnx|lII8(mKCY-8_&2mW0e ziAp5VpaC=GC4~j;ZRmcUQI!Q>p;b#7+#`4ln89vXAGg<@l0%0gr<bYa4wZMpWtYly z4ApPonpPxuW@rSn%M(WJ2{9`S7Y8`wjFbJyJq}W=*-3Wl))wjOA1-E85R|ds$JfKG zDBm?jMFcdfN9xMPBokG|9`^cfc2EZcla1T~lhV~Flie?GJNP@>))c9o*DcBGOFb{( zIT-5q?AozX(CGbTe!hxDk{w?yy0IqC@1Bl4AR~f4SxD#E{{1yLAS457^3!9r?F*eW zg)UO+IT{Q2U7n?_aUEE`Xx`l4yG5aBwJnx{T@PJbNgd6ZrJ7f#hP4@@g;W+U(4#ie zO1K#yl~(FGX*q^|PvCdx{uZ=-^AwV_g2X+B^t0HI!)LU058(d8q&t&Is%aNYFLCX$ z$4Oa8l(VR%NTuCV3~yI7#O;!J>Sjp9Oi;wm;1Gm-C6)eQRY%vY$!gu}YMKay%8!^7 zklxPW{5>Mt_X||$J4U&+TYKYCr7Z~sCA#Hh(_23oQ%hEpGzMxjAMu^z7z-$hAg!K8 z)hzB%rGIcS40=AttU4wX0ClaRL+zCvLsoYkb$mB1O<hBGLXT5uT6%kZDW;Bi;zIjU zNf9OAG~9yk!3v;s<dnHTFu4a}J>B*?LxfnQQ0edQ2K}_UQeHImHNxLbT}?yiQ`4eU z%wA8DAU<Kav4G0Ew>JHufg>GuTAd+I^bnbq(V{|TIq`$eKIgBM33$}U$>;g>nCgQ^ z^U3x78lRR&{KIY=ARK;DewgDuc=c;i(%J*7JNnncg0CLHnJGD)x{J}DP;GvZ_}9#2 zD*KcxazW(t;GaByx2PG@2v$_7-`hW!gD`T^&$ruR-eNXF;zi{^V(rG>Jo^vVk<vnx zl{>K?zQPGqfkMCvKAPWa=q>9Xq^qdj`KVxaWGHNI=OpL;JoNGrF@a0hkoRqLFp()4 ziLrft`-ju_g|S>DiG*^IyrT?*k}^&?$Bu{!SpcNDcMjZ%df%_<9QhKJM88dXe!6Q4 zT67?{uA2+6$GE}%`RDundhhqLjLG-m^`}U3WSL1IF&_Q38r7sIqqssG(Xuf~SVG5+ zGoOFg9y&Huti&X-2g}G>yV~&;i6tqy08yI4_c~Ji;RSU?LI|cGA}c!<Cj>AU0|S$h zpYPMsP@tw!1iqr4XJ29yi$O>Wm&>1ncz8pX;g)T&q2I<>9!MMvgZ9om{rdWB(&_|I zQLW|6??`@BoT|#E4O6MT$UZcsV|4OE`@X6_L=_Oe;H~okjF3M5f44}(sG?vAp>eST z-Qny`MdniLt8)5VU#usZ6lIqizE%V%ZVr6&_2c_=AI>uBngh#CM@V-UAqz<hb$!X# zVa_y1UigkjcXp2imt%v1Pd`!Pq7<^{B@M0b^@w~FwFNTBHu4wi;b!L!b@EjT*_xRT zq}xv5PrBrQe^ZawrLPc?hD^1a6Q@6I4;fP?≪n`1`v?F~f57qXv|+41!4*$2jBd z?bLMab{wZZ-W2-!!_wtW1&HqTv2Pxb$*r&PX?+)|wFT~~N()UL9Fo*?jmk1Yk@YRK z0rfx6s~2ZcHhv~tnPJpR1M9z;eGEW$jzqjZ6;o&aPyyB4Y}&uB;yLSd_~h4>*K4Zj zy)j|6S{Wn|M^qlBe69%fHo7P*KA&C>8S8Pvd?3MOd_?6bMCd>j&`_F2ONsc4i<(Ms z@{*7|O$*h?_ec?&;^({-&_K%iU8Duj1)?ZPE5HD``tW|<{YcCBQNo;__L}~JBqaP; z;*!)-f`CbL+}WOm*f}@#jdb4<UDck>mt!WHs-~xzDcrp&ETpJD&A>S0^gT!qhgd0T z$ehjB_Jd`7YvVbBh(_O*`|9337(iVuKXkkIt7z;RcTHTi_NT2+`Mm{-)YDBOuRFas zfhTERSe>h$Nb1Rgz~QhG%2K8WbDF(5^ooaRJ8_EQg%h!ANC^$y0U@7K9GdQFrQ+b} z?}qP-4$)~zB<^>^e|xEP<Sx=ja;LL3YTMyBE+&ow?uZJLowykzst0O#1e{dUI!Q`N zxG5=fyR%oEZvOxT{{X1|AHlFggYi|VXawa_NKs031UMjOb4LIcVFMMq&fjXWRq8u) z#i(fM>k$*sBQeNMHa<pB<d8wZ89a4S;r*Ue+(^RC?%eeQP+q<UvFg6md|trdxCGui zfImGimWrSyn=>&>8-f828ti&u+wa*8-MxIV*ZfL%Qp=#NyV6Cn={j_;tBD8rWT}uW z3fLrkqHhD9wvGwdxp;KvN&*h#1t{~-4_N%p_>28Z@h&*CDs1H{SR{lb6d-L&tEnvd z8%HzfyLWW#Z+AQ0+08{yO(ojHd^9o=fdxV<Fp6*h;7J=o;Qs*qPrp=;iMUyUWTmrQ zTUQ^=fR9~&Mm${OFc{ROWRsCsF4<HMmb-XF0bSVp*tpfkmpe>$@jIAzGXj8~K3%xo z;A9_eyD#E)83@R<C@4^X*A&0nBXK{{Hx-*bQ)FVLWF)8(6jTGfKo{l@Fhj&qsC3<4 zn$)YN>uN%&5ff7sGCW`r%c^57jz`yn)z5@+>3G~A#7Rv<_elWS$7ksn2W|ctVz?(6 z948$xgg7Q@Q!tFBh#*~F1@wutK~JJIQN3K^)E9fbWR%F8MY?5Jz+<@p+E3;y&PQ4Q z0BZ1@Q;7s2Qi2c*{$!Un1>bE4jCyn6zALi5moqVv@oU6R0G2>$znTJRCSpKM3jo(7 z2PB$xgB9;ChmDd4yVXR@0`7WgQ)eDqjE{e8@zo_?4!F4kxCqsh3!ggk+BdWPO7=KR z$vS$Fr`A%^-CR?8(&i`q8|g`Oqp4Wv<XVaeWJz`}&aD|BX9VYu(~;Ff3x;W>3Jhzb z2kE0963rUt6U8EAg1Jh}mq`p<5k?`X)rT!QMJ9>v4bq-SXc`EDdWgXlLo2pO_ak8O z)}Qe4Eh=C&8Hb)*znvq*pVmPC0N-Ggr>wR3cd4x(PQ4?T6}1icwT0vpIGL2lt^QR( z9R9rj0ITcP#Ds;^h8~|-@!~R<N_zGE{j?Nl<`e3@7f%}IN4ioXl20GU9)F&A_wm=v zb7jy&(@H*4l$v{Po<22>pZ@o)wd%Ub>1mBbuMDxyf@g_pB6wt7&QPvGGc1dqHz+th z4jtRM%10VmL9j?_)zZ~trN^v(hyMUh{3=A=!QqoUP_0Bb1ciLi%;4WE7Pxyuo|({^ zpHS6AloZv~^X*nrh2633$ouh;<2_U4VCE=>8H;@{&s}45;~Z-Rg-&(l0<QT-kkTx+ zl=xBauA_L3mrd&GdT}E#Rd<O4GZW;HGw=Nmzf)2>EyNk6if>;Z7L31*d}8eG7!=Q& zES`Y+G!$HZ2RrR)YKZNcYNE31P64>n($uMzjqoKwGOKc34056W01!DDDteMnWAXCK zrOx1K%J1iRu3^4Ec3T=|B6$D;&ZOKCp=U_blSFr`J{$YvpsmnJbkln7#bZV+QZYPH zLAdfp!?YaZBzgDfDf}B9BxNB<9ZfvXq+R$o#D2|jDWpl6DRU`sf=hTkeby@$sIcDS z3rBUHxr|$#4DRemz+4T)<NQAU-=|evULI<INjkkrKUeW+*f?$@1X^T-BonYm`Unud z$5^z~vKeJULqgyj<&)(Y>MC@i5%WkOP!`|a3D88Y0br7lA6;$sgKpe*yH|HQ`4y8) z+OGE+fQYU5ak!>FfFGdu`}HluxMbW?ie<Svk9YHkF41<=1;Q|YI}tD;HM_K5?IZg( zySaPP-@m8Q*NZ&Lj!RT!x31qI6p}H|pBeY+{fF#4`LfBF>3u8gMgzt_sm>WA6Y@mh zu}f*<bc$Ww&c|yW)Jl}vhLV3xOp(VrlIr;ckI{#?IO?O1;B#>2`+>k0cj;!R_0~3i z$#(Yv#)tMuHtvyTz4>Y$In%6kXLfZxY%O%6qKy&dSUnn;)6<QQgYWI-xye0iJ)iAI z1i)ePN?EHC?o0mwJ5a}p{v-S*?eA%E=M{^QEkO>Hl&J|B9G1|V+_~h@mWJ&Prk3{z z)e_S!ib{E4s7USvI)aQ)rUxW4uk}A(m&Lv*VOJqZ;i^9{{#QjG{-S=|qn(5PlsLHJ zGUjplX#6col9`7wU|0f?cWppx#HptA7M;@4&s}Xwp*-p7v7Vs57j|~-JOj7`k@Xz) zjPcIeaSkIT60r&l3yi7C`a8d&k7afb;J;_PF*<G|g-ch=n2?fItkk;=f!9+G`hw4A zzgj1>-Ds20`6b^O5xis^ZVow8cpuE?&rEiIkK-`YlQu%T>~7`5T!T+{iT>DpJmFk# z0IUu^QdKELSTPJ-Wg&<n`&q<R8|B+Y>D#TtPuTRm>i+;;6+*^dTeP7jlQ`KBLz2Ah z_RjCOUWMTw>sX8`F<87xWrYfXEKx11&{M2_cJ|4Ah2gwOd?$qPrQ+rxSu-kfk`%XQ zNJ~2^np%{C7SS4&Woe-8<%W&|RpRxp)d8c&I3Z-lLF4a^p%vR~Fg@IQ%V;H~y4%;v zEwX+FafzU}0FakzD-|=;1-B;9(lI@!*B44k(G?Yj#3G(43Pp34LGuPuNjr}Kjy?Qz zZWH214VNv|F>@LF#Q=Q>72L&dihoRfp~YfRGF}@Gg+ZoKHA)4pSmrOtQM8GVOKG^Y zrH-1(TQ~0ts7aPtq*WoLesH<xXaEj*IQsRE_QSO^@tD8F0%f4kCCh=iV$S_~$FhDC zd?Vrf1B9mlq^5MxNm9xMR9W(p9e|^1DqyM+4LZh=MH&@#`4tyt0S(B<BggmZsV|A* zYD&*nKK<4&v%4{XS?^)xk*9Gl)}v1sv?X}so?l4P#O%yS0x*BXaqZ9M{{UA;O2kfG z<`*2#Ez9zNk-#uA!%M@Viv!$CA4)f*0&2|*Zq;*I<f*Hyp`M+HQxgSQ17kav9R9h; z$sca6U7GElFNYN){lqJ9XwPHywId|>mGDD_c9Bx%DL*P?x2ghC;OL;h6R0;8cX$z{ z?JcT~O5aCRYk17iOHVnDs72~V?4^~q{$)ErD0n{oSY`ZG$B~pvMR_ar5u8u{kN5~6 zOvFwh=;Z;+Tz6^7pOj|@YxMOzDNp<RQ?Ue+zr6@JI3#d9{JuT9Vt2(3C<Ek8Kj}Ui z>F&^UujxOs($WwnESgj1ARWKr_5EN|T<AL`gGyg7R?Cg$YM~LbtK5sLgCJ6+KpFB+ z+W_@s?6+<>jx!%FO!SP3fAM2L?(v`eVE6&q-Uq;^aSk4}3DUqURtP=dDj08}xM2~N zlb*V5XQm~V567&J2{B9jQ7VwA?Scs41|<7n^`dr;YDW*Apsc;p#1><tHnHXphWHiC z<M>G;P?_II%0^&8{LTm<h9r|vn~I3Fsch52j=DK)ws}@T9|DmE=0?fqA4dMa&#yZk z&~Xf`nd=HWtJsgzss8{CI4bgHz)GXj<)`S@q%}>awEEM=2As8H{3KJU1KU4LV1I6% z!}gzvg^&Hg0_Dq-$)YWP3;Q+@%i*$e*ZcsZ8#g^I0$swO*`)3g*zGiN*FY1~k}7ps zL|cJ<qzvZ)zW4<7ZS0R3o~&|4E^#SQHzl7=O<@^N#(u$IQQ6!|xK0LIX9lWJkf4_t zidZ=Ay>0`vK$<qHil*gnr_`0gp2;mosXk@fyD_#TGA=L+YzLl4_3L5nEZj%o=6(o& zahy^0+ovWw$BI53N#V2j#EE=tgyEf+n6MxM!Nr5HHa>K+G&OyrplGTgsL;@;$X(N{ zg@y<}n3L|@eR{*zp42T)znb>3NZ!KxKcsuVhvWVjVUi4ygaVaxohwxnxP3t8@Zh)Z z-K>dBmklTBmLLUW0I(R~jB~-~{{TLt*TVSu?}0F1ck!*X>sE`76SaR2+)G5<C<>qr zEDO@%yy8i=)!Xip$1GKJ^$<Kv1kg$uUG|;7GWa>c$G&>1uM3Jo@=nYMuq1-~-|J}7 zu$)H*xnDm9P`PEQoTN8}500GSLb;NZzUPURlRMa}6!FG5@PEJGpl1Blx`}csb>-^^ zClM}M$Vy8WptJMkA69mPsZXp-Xx}kMZ=2lYXPg0!dS~R6fad)xYIySc!?UqMVht<L zO$Sa~p#=32!zSSgo~gK4qamHw2b1>kpKs~Zwik!ano3Ztse6#g(wC|5c!=WsUk`;q z&Vw;z{#1Zes*3Ky<dzVXYVDPhR8cy?6<8aZH%+4l?07iy{{Y+3YvK5uDn!%y4VkK4 zho9CWa9yF{*!jl_DKI!FSY=Y|39ljvdl;>Kz*+0B_jaJAKf36V1ZI&`AUksIa6V(W z9Q^<o>vimI8B(xv*EpY>FuBchpuZ;dj|2RCT-;|Hn<oSktUQ1ipayng<u_0_8ZfLx zFwW9V98}Q5B!;KdL$@W#$lH&<u=?Yx!ltDTRiN_R);#Zr!p@mFG7vKYY|qeIwLWIG ziO%2IiDrV9OSSnaZe}oKNMPQe<Zw<$BgT9j9DRDrF`pAql@wv+It7U%>CQcG+3)E( zWZXI@W3ibgnv|s(fVCW`3LCd~Q4cfrewEUel6^^2=mE1PpPwbM%X#^}zixyd5~dTI z<mtP8N7^hCKcxIBl1Jl|PUQo|yU$Hp6w!8W-yBfME%?VgWm!wZjf9+!4W14UobpfV z>b@q;Qi%C1E2#Y#7)rmToHxzx;|Y4zEEsbOcha1$BdQ+@6VYiug_}im5!_+BOWf&e zXOF{GQqfu`a#J}hvMxtI;@!^~>hC&l%$9}B4)-1$i#2~}@t!{lWa8!`ST1NO{{T@2 zhg}G3`A1`I`r6G`ru$K+C9<u`n89eTrG`_Knn-DC7$ykdZc1`TpO~C<%*h493SOY{ zb}<w4mz_$MTH~c(uG__N_@M4Rqq4N)xVlz{RvYEowwTh*L03r(dVW{~R4A57mwIkW zrd3Gf4~1aBqW~hftd=S28itzbQW(VN3E-!RujBxdq>7SSmU|FekOQ0z_7~#YxZ3C9 z7rENf(|P<o?$f00`gV>TA4g9NwUqPM3fh`zg+hn<c1t9)ml9+s%PTKWNRnlz<`6+s z4L75MR@KRSKob)tT(rt3y6R$FR0g94<Zc?x9qR4x#CK&R)0Gy8^p*PYXQHY#Hu^a$ zihd#DBYIanrKPEzXAij~l4X-2j^rRLl*A+@i;y{={{R}VeF-4li$ppm1saBZ+g^j0 ztSIeA?F!I$e&p(nIjU|D+o~=X0QAUiX(bH`g%J84u|Ohi>WVX!lW4(B$_#9dGboig z{{SWgklQns4kP7GDOe4rkD+Sv<|4t<d}wycOGdNQEpT}$ZSX-~OKud-wRIFuhiu~; zzGq@F8-&0rGFh+`t}K9sEm|>e@?&B~kEI)O<~nEhvitY6Nj;MIiO`+-w_Pjrbe946 zMkaKqr)ah*k<kuY8Dr^^RDN86@6mWXS|=o_8htePy(5X94e0UU>F(8daq1F59Gnfj zdEj%`k0&{zyU)u_qt|kXmRTR0nCC-Qr>`j98D;e+W4=|#&H%{vKdI_Eaut<N_6|Ym zqr{Rd0HGfGJLwuy>26A|!ppvC&yndfB9o1woARze;2w#Sf|{91oPlt40JCT=rVj04 zST=0LnWQSDn|L#tmLynH&7sPnD4JNBI2JX?VlvrQInT?VCy%k|GI0S*8FsL(x2FAJ zoK%d&xz)jaJWo9tj4Ps|qpqF3Q$FN{rumrVm&iY3{{WvxpeQ{wzWx_z;tB#CMAevk zxo(c|wJI6Isg7nm2=_QR2kJe%V<+l588X8XDA#YNKYGUm{F4%quCyL|wS#!HWMf=1 znQEFdwKqJ$f#4o7$l&qso~0Er9n~cu7G`f|1X@-U;lK}j>91RjIzmdeiW%f;Q!p~f z#O^+(Sdwsa;2ik&`T^2rPRs}d4=9erAS3|c=xes7^<!OuFIZbiRa;jClT);Gl`*hw z!X#oweuQ9-kQXT~rJ86Cn_3B#h@49<f%O#G+}zxbq()S+M(pV=MN<|Bb|m2a&$pj` zfnFeom;fMeVbk{%iD`14URtP~UdK&*8a`ecfvGMo_18ppjD+-M+D9A^qkwbnJd6zE zj-)t23&l$+2~f<{6fV&p#*m?fnovkNUFt0DW4-sQ=?peoWUFbTxYgXNDx{eMk?IU9 z5uEMDFmibQT}^g}8zx3BW*$tWfRaPysSmwt{KO{?Ox4InAyWwo0JEKR+yZp87gEYX zMjA(DT!32#aQ4U}1IJYyb}%f}A@>7#pTWzTlmw{vYpdz$_ks#Zgz5og;{=aP4aXd0 z<AI+)uR+L|ij^cYdbKEb<q{Z#tpEg3h^@na5m-JnR`*5N*Q%qpkHF1$qDr%{;UgZQ z0furxaS-?C9Ce`l8^p+)#`3Q&V3jjn>C;G}{7X#V^vtsA3WM~kTC7WRjyJu~Y<-kg z-N~G|jfntq2cKh+_3PK>UQkp?ZJ*tDk2Ne60GAxg8Z+-vcHWhaplR=ge$^cnr!N<w zb&BUzMNL;Ut#R~LNuD6*(+7Z{9stP$&t7rH{8HkB7cyEh<phQ)r~tDDKU&ACxJSZ1 z6*^3`sF0-qNz8TFXw(6zS1w_OKFRj(Z9l2)cF3+Rkxx(xTt5&Z!6TNy&*m8<4F3RL zrEuR6IEuUKNIJGtYma_AMe_;p7l5fKK`Kb7{{V0vT7op^;T<adi1+OmrgiVccWSQ` z7Tb2et){nJ?US}c+%1%^JasWf<&H#eQA;x*$I9iu!f~We#Ee9=Vo+VjJL)-fj}ZJ= z?B*wh;tmFIAqXT7AIfN2*43`j=&~9}X{C-)3{cFwhE%e!{Rre9gDNF0Zjq6d6GB*@ zpx^ZS#S8e!{hwX0_@vVJ>&Bg>wf6760*-INY8pC7;unbm?uwS|ssX5{GAe%;9=uuH zjBrty!>A=rpbvwwG;qd;oLIOoY%!cTsS;+KlLIL(SxI-O)QgT{E+o)hjh-nYGSl6^ z1f`)h1<_~NHvmuO3i%nw%s9tAY`A0+fOTlkrv!Y^R2|PvcJ|Tc$M%j{O@rc!$9%1l z3J&01C?~a7!}By(dN_hTw(KGbv6!Tb)TWv+a}7-sE=n>JoP0~dg>dq<2Pr8v*?^|C z1&dTPdr$f}<MXBVi}2D3NSuWxfxu98CG}w2i`A<X2UvF!gGHKpJCyWO*U1XaDn)O1 zsfY&r+k}dXk_f{C!Rs2~y^U5h8M2bVnmVb`wxhVGlzY>+e;4pwot~T~E@4!_6WvNP zP>NKL0WD(hM`wZQb$!b93=zX(*K`v4A=X+;P*!FnWVYvEea;k&XZdv3{++_%=4L9A zYyMLYT`b(h60Y0vEOexaaxt>??#Mtqs-mId2doiW?HZcvVuqo0MzpldmD0k7rWk*V z7R4ZN$8!9~0DynDOULni1{jCKN|@xnQldP1-GwO(@J_<^haWOw97h?4OE?o#CH1d# zp90{(#qY9XR93al=c;d2lZ5ocy*iAqu-rcR_V?(nAMrIfIe#k+g-TK@MGzmRu?^af z=~8DF#HSg=c&yoza<M8zvRE2xOYGXkjqP=XXy&j|L->i>i2fE6o=jy3%bs}z4nC*b zs#5X86FARUVx<y8Z+}}x@~#p%M+}$4!rodCl!9t|jKtdDUeIxC(s9@5PT^T9ja{@> zJg~sT9wb^>N;iR($=Z3y&)=)}!<lQAiLL(tG4yL4hQd5m{a#}q8G#BQpOmvhW0)QP z0BGbL9FkVl!!jv#Ve2R$@D4ox08X}9vhsrEz0y35gPWRV6d1Vz<UJ^Q@)09;>YLT> zFKhzH-jTdhNW(rb<&VBU`#(;b!<Jbl6^%(UoL29qk=f46_G3_YZ(32?=s$Qt6w&%` zJaKPQhdCZOJb{mVj<N1P!Ok;{O#$md&#<`JpQ(>`d_L_=o(<Rx6zQZIDNJtVYHz!3 z9+mq)@WFBHuWKo3^d64Tmu}_B8Z9MF<z6Z(H)0FVTvRlY#-NkwL^r+_RG=*5PT^c4 zUKUVFML@RwXh<Hh;-82g5iz`L7O9Jxpn`mdA!q9qui|62pR{GQu7I)jXX4*aS+qsf z>XTAUr8{*=YpT-J@xmNcMuu9M>pe*UjblevQb9i`W(CgUDZ>DjBsr&a63ud5%MU)0 z2f@1mjp8XQ%Akb*0Fu@vrAI`IRPN@0*GR5itL(SOyxq1h-j9o4ime^rO6!&(skhWy zXyBfNq;XS4RxXIGJaJ4bS$3o#3CP@c0Apl!u58)mrIZCw0+hYj1DToYQBl1>i?3?_ z4gUbA@TKFhvnApLkV=9kEyaqM_k{&WCeA>ii(%WnE%CjjAhtnw_^H#{-ku1+xJTU^ zO_J?RaLG9_J)LQ5VDYyGNkH;TXNj~c5TV{;4w*o26sY=x9)AA-U6y7Q%kF>XRms?j zS(p*Zuyfw-$^0t!ioqDxmYW@iTl!mMxoKKyBZXY?m5y99rb`?)PaaR6q4=L>aM+}@ z&=Rc(ztim+k7K{6&M&|gl}VbVqg0xY2O#+u`bPk}_1HUCZFNm<p110>y;~|XJ-%wX zIjCNsDaO?GR7ERe0};C)91k64JX7IrH3(eXB24K*`58)WT{j*8hL3)HF8-|hImZ%| zPn?+(g~Hh&zM$Ejf$5}l{{Z$*_Ri1g>&xxl-4#vFidtxt$O426q?QZy$Ri(r)2Yvg zcqAfP`>dcv7>)Eb9W>{7%s;CRGl@#D(h#rhtP&ca`@K3xcIBw9w!%bqY8QlXs~q82 zk7N08I?=^TlO88hjm%JwjZcNz9{zUb<9yN9D=d1IRzW&VJ3vMh{P;-NGlB=xaQF7= zx7rQ}F%rpc^LYOKBVzbN@mhBR$DqtnGMqTOd-}(d%~hptHVdU)9dtg0!YP^=6WMS` zBc5}d`5k%d7bGP=Eq~%jG_UXa+n4Jv0OBge$`jC+%mKY=>8aKu8oJ0Vl}q@_+g%iq z;qfg*X}LoVPUDa{9FG}2N8z|c`O0}oBqZpP)Mm90Llb;cwAd^nf^l<Dg%M>fDK+F< z=^rcWEYDkVLw>Qt98t89qFiDnmy8}r#{?g+<n&bj%VQ=jPbolG{1536IA4i;9OO^p zWR;u$0Cg1g_oQi9XZ0PB01D%Bx;EfOasWS1f4KAGs1)IZwNX_MLrpathqsHm*E(Vn zM7@+M4?czT-XJ|m*vq?LB-C1F<@>Uy@kU^>uMtWP;ON7^+(rQ&e4G$E+ISDd^d|}U zJan?Q8EL4c!EH4Tz*t9)dvpCL;-~R*#yAEe@a0PRnN$hNqF=g3kNzZiiop~^xK`(A zZkqQ+$6Qt6D?J=wDcDX}ZUhhxae<yahf$n9R|lS~_<Rh~g;<3a@S&#BBgMFnYj7Xg z;yil^DQON~T0uJ0W~1KQkRV!%TIv`cy4e?Xw0(U<@JX^bExVf`L#ZKi?xT<ok~*<r zJ2!%zRbXZ$8WN>}^|<R^QA6VY01~*gk@FMU94yrWz#>6WuOzt?K3wSzJ+amrt5;Q4 z!Fr02)1oZPQ4|jvs|+sO<7vns{Xhiu4cq?A@VpKgD#I(jOB9tkV)Y{QYqVW|r_YL= zsO<+7ohypS2M!QV%t|r@s1^xxMFlZn9MZIj!j(~BNkv6Cc_TPdrH+5n@%88a4o4<Z zkaL0*J%K@g4=D9!Ck32^WgrXaJQ?4@KGV>=f;geBx5rf&GOD!rJ5+!OPjwBBagKTN zIynV-a|Dr>ethiwjX;2B$dndksZLE>Q@-a|{4I6HJ?RV?Y>aL@bIBtso_z2;a5@;w zQb&0AP08s--0ABMs#*#N%2c8Hj;-b86@$6Gu+}hY?J-|^dxodA+|*VSw^Si331-4m zqY=)<IL3b~dyaiA!1&J(!|>3Y8j~$y1M>s<n(8%w4z`cYUfgysw3z<ZEqrQR+(sMb zkVLhQ_S}#qg%ssbNV7YI8kaEjZtc#Ds-~u_qtM#I<pKFiZ{9Mbh9s=tmmqV<C!e{a z@gCIi$um#R#7aN_J%_1F^nXjn_rSgnb|Vvp`O-LMC1Kgg)EbIRVnOrg7`}keVx|}l zfz%R9Aj-!YxA8CzKr1QsAMxX$Q`)XFQf4O<jJmsc7sx)bHHi2(*kZFOTrOZIO1&!V zCq1j6IzjH7(^kqD(rZ?Zsi4}0Bv+;@BPo7WnVE<LoQ`?N+o*0O$MM*eWW`VUfr0$P zE54K-bRZbe`z!GGfN-UJ&Jlz-RXGk(-cV~*ODr|6(7NGULqi0wJycNaU=OSa=NTg; zV?S<r=qwKf#Lr$qEWJE_tbWle#`{-;z$513Cxi`jW2OH9n2&B6@l~d!w^LMBh_Fcj z`brK$lGw)#%b%ujJawjUuZQFFVh&Nau>Sz8d2_a()G|cHO5xq_`j~-jX#n*alU7tp zSj^P$nK)peWRE)we?j*LtG*kum>EP2nTg-;7>5)1&&TH|%o9Qww|CN{{f4mD`^KfJ z=4q*^C#i`xGRtp9Y1eVX?%U1>8T*g7QwiZxLPAJ6f9@hG3hk~wY}HI&RTAaO5KEoh zRoliN?YHXk@>9#F;;#y&omSQupwH$wQgO)VsH|6KBrPn3DInXtK>F9lEj%mYrZQa2 z)1=BEZ!H}f!iU|AG3(PoTT5uDwb)~~)vHQs)gf5QC&*QsA8^?7p1ax14l@fjWVzD{ zrO}<vr%gXtAL9pUI5!i);b-vtG9}I`SIh|sOVCxt(hMT)OqXaib*2k@FX1L{N_dsB zk|{YYv=f#bocX}}p1$qI0!;oJHB*2k0FZ7$AFYTF5#Ithwl@pfJT#mtvdfyhs{w#e zF7z){+$xRZ7X})*;HH+2rbvuZF!b5w84vy+o7{e$dh=E)V#s6zTZU~mzbo{tex>2C zn0(1lFXAh?z2jVy&{B~~)liCwW6WWf2OB_apQba<w@>i=6v@Bb33M7BY;DV!y&w)P z#PHa42`NxY&dp9_Z?H5x;ktXZEfjO5HAEE^BbjDpEJ-;VMg~b7aqI_IEC&ajI3&*u zkzhG$+6}cal5x(`;P3`wFs`*Iy4Fk9j5N0*0%}gs$|+>8nK`Ec%y9`YT)ghsR8fza z%N%X<!TM*ZrAZP9mH{NxBsGA!*mI`UiUFF8WWU4^5)eRA)M=S?05I<oO%k8`iwJ#+ z-(=GjGFaq|BCeSm)LvN0kCI5pJnvEnVA%U)6(+UX89Y*O{7l7nQb7%&AC<f+J!=|& z!~9QWa1+N55<pbIb3JoC3%l6RhoihzeNo#@MNNOLmrqb5+Tp3&O#c9gj1^P!sK)I3 z9x=u~-D_M2wzw&nBn(`^ok{Od)$eBqpV~Z&@hjj@9>Ve5Lbxszs!X+lf-=!RnIwXs zb`Hm^8ON`+{RCxhdO<4N$EdRu4m0_Ok)Az{GoGtwvRop1-w%jxP4x%wpp8cd@k_RN zIY4nP74sCI@^Xi(Ibu?4%wEk3D*Ll`f}9(sh@Ol^w>#=c00hQKD}X)!0G@NxI8MW1 zmVdE}1@-{_;XGgCw-~{sO~-I~hzTF_ATTa#kW$Tlx<Y7s*=(z1Mz+)?On{+fs#39& zo?PRFIb5G^o}KW@WVE_w1JoCBpsoDkzl8p$@GHz0gi;;roh&w^TDo~&I^nFX!$NjL z;cBLa3c7c%(;ABNQqx56G_y@37+A(mRa!HE53pQdH#!bRSxa|v*Q@$dSn;kQMsk%g zU=madY&<qP2Ox*2z3lClrl*oV)z4FLoXe-~CX%h(C|PZ=+FC{+A#7k2UB~8UfwRX* zDgNLjJ^5+vwB4Xkj^eUVcwPRT2G(O0OX9aw=_~E_k3mu!WqmESllP=HcB-k}H{mK~ zSwwHRn9-@K#L_B&&LGY}QhEwxtkS~=31eFCTehLY!FWte%sC=ZWh@Zkg$q;aADtqL zc0a#*pSL=iZAEC*rq*jNiFB%(1cpOVKJ-xt5-TeFq`7UGOe(ema8&F%r2u6I^51Lt zxY@T(D-2eVl!B&f^V5;DGg5{+0d@GCy6X!Snt}-Rb(V-ykKTz_6h&mqADSS$Ld3wK z1ny#`y}ZFs`HL+wscM0~y4#chpb(Z*1!-4vrGX;a*uwg5<LOy6#ci+g_bOZ4MypFK zLO)KVJ_Ly@rIey7s*nP=%yY&DBF+Iz84{2-r7CuR%d@?18UXxaLzNN-kSE6c-GPTn zuJ>IlSJ8bza%%qo`M`Gd%Ot`;qYPIApGGC*9PJz~2SZ}yPGLer@*X31YdHV_+QNQL zRfGAQH%*@PJC+?HV)$Y19<96gUq)8TscR*Ymg7#A`Xe+lirJ%)AJAB)nGrL&m`fup zXvlMuv~>O-2AO3GOWu@ydAC}Ywy-p$f|8Z&HMahA@FAk2QE59%H9FR58u~TKGD??_ zWXB(sc5MUOk?s#2etnOP967TgOHnuOiWl6g_B%(WQ`-b26ofI_mGK4t09Zb^_G?F4 zXmr)@xs|sqDMxpyf|k*0{80VtbdpH|EV04^55p<IGO$g+61X@WdTZhDVR+YQB(EDP zNt1+U%%A}v*2akOa$EttQ@cU&LRu$F!b&cs8V4lOuE74>;2OU`=^bsXwf2<Imfc~e zE%aAvx>c`=zM7>!=Cb-s!_+&8cZUEIz<zvnZozy$#m?hW@baZ6<%GD+nX=Zd`<nrF z(adNZJG5#XIASqBn2>Y@$?*;8t>MQ|Xli{LBC5Mt?=#WMxJ1xbz*U`8HaJ|4G07gs z&plB4Pw@W$ig1Sfa#$>o6d6v{Ala!_EnPJ>E*r;X@c^lEKnhL5f`Mnhjju{W6{6u_ zieR&8n%ZfK$rq~C@wk;vaO|NlzDLRqIQGfKM51@hPxoG%YSy(r`aDR^!B0|LErr>u zuA`UM93JOoq-w;c(AJ9Q8I6qhIZ=0mg2QU$<bIz|13fqRDf#jyaRhC9TR;V2*;41Y zb+hvO{?MML{{W>Xf~jsAIc-xoLLLjRsp*Z}5T+Fm<O~uC`i=lj4~F8Brj<ELqku&S z`ncWsZw$}E&rf`)0QG-*!@QPSHKmfESnYLTVZ@R=)e2CMK6Z_FWDMtL80RF8vra49 zoJ5`?cxs74)Y%G(09Qp268baOts<j{<FkHZr809H5-6d_lF#9!Kh|5)o`K%q+v*E} zWJCzH-jND<GH(3JRAU2cWR5`36)w#DM8e^*3&Z2Fic+&4@~Sq-=>eG6F{4<4c<eW5 z*=v(FVI-O=&2H@jhUZ5+JPgfn*dZ`!+a<<~FeEiZwDUV}$==c9AZ^Cb7tfy{?NX#E zXwM-`;hy?ct{s}Z^^1})xS*QMpkuh@UPqfoaMw|Y%xh_iq&WT|nxTp)0OfNUf<fKD zI6p7GauGiRF$|Qglq}8|g6=O}=^Yq(%3!ifyMwi^Sb1I)ANeNKwAT1+Fhg9YVv}&3 z#_Ti6P@sX2Y~v%0bd}?A%A8j*S)S)8HS2#Cg3-d~%9f<2iJ-lPgngUV7t^+kQ514m zY2ZbCN`HCONZH3OxKV-s0B7{)jzXU^9hj+irLvAz_jqt%@kwN)==HO5`tmOe7NbVn zs-vlrjtMQQLwXT3=4k-`05rKPx8);|l1_8R3vy-R!m^_0R|DCFPnU^ONqrtS>grD0 zcPByL^u5DM-7dCz`bVUwf_s5S$-zltz#&;#1F6piK_lCzxJ=x44aG~v$x|%~dNXqc zs>C{aIWM3X&J?_4qazRi1-Z9{1HE?<$}K_gaCH5~x|Ur*r|U$Exf^WpOp03zlCH{m zQ<1a`c<a^t7vmB!GDO8;$P@;lK$15h{Gz>p_zg229#$nGR;mbV+=YF*k|`4IkkuX0 z?aq+3*jG+=?^{~!h_rP3X=`dKo}2<qF0D9O5CW$d7zzhC9aXrt1A}oKTCodM$z^pt z<fJ~Psi8f2v}t&shn=C}_*CH~trL<MFiNu!X=0%5{{VHhq$#4*m6sTACVtpkEvo4x zfhnw2$rR7S<&;W5J4cVFKHV9@I5q?kFi#{XRQ$vjZm6%MaVX<BcSN}$DE|Qd1=6iw z?mcxQTEuIjE}g^DI>$_Rz8xPfoz!}I<rP#^kkri^U#NjAQ%ceA$|SKn#M#HoBYS{) zy>Q+o!{p8wNi&L&ySW4;>0L)JIH7z)>`xuy+#+r_ieW^lvcn~V6(XRiKsj(&hp7(Y zJx_OcySI%=@O`QNBfc3mU0u$)eK5MKPNdeDq-d$e(@`9WBS;=t5h{U41p1LMAmbfb z@UrFn!kM=6KDujXj|$Gh1WXutfPxQsstA2Lh?aXD+<Kn!c5Ai$+@-ujD{759OLKLG zA5JP+B!n!95`&YnRGSP*Zv&8{r?^HQRLQ{$v(nmb&=(h$WK5YUAku}yc^x75R*vi1 z<k2<S7N*^)<XgQ371|oeX`v#{%OX_Nej&MPh{vSs#$A9;0VHWkbcgrPUwcJ*Ou(`; zzE-825pKJ5cZ!?w{qVc3d-L1fC8)bw4TkmI%_&h+J+gwWBc@G3O;V_n(i)d+Lnh>o zc-)YimDRX#L@#LgEUqDiGFDq{{r#RaUxXhMX7*E$N#dwLn2<_T4ZFdfm2sh~JBN=i z`hwX@bnXoFlhj>wp16Z1rjEXYYtvQ0+h&2IB}z>ZQNA)h{Damnh{fahP8m{WNlN8{ zQl=y+jfoX-U-Js-Lm#DS?B`|sZ^mVDOcpY1oJ7fKnvs<%L!gY=3agzAGoa~KV+!t- zdj6V7*)OkLjLgyzNk<4%&N8jb4t(H&$G=pLAI3Oj;7r*FMpHn3VLpd_HI284_9L|T za+z3uC5WCCat0n*6tD^uaj~V^8tSjz%}6Mtf=wq~Rw|{ajw`HG>dy|uQor+K9#kBh z@_EKjQ|g_-m4Q-cOUlLF0c~Dn-R=5CAy^NHSUxu{ZU%BxR*(puxkQi;{AGbaE}#K; znzYWKmOAJ?3w4YKMu?%ehXI)h10?0jjs||d{bb0hgtZDe4)r~Et%YkItjFS&EE)d* zFy=Ka`Fe`b9e26fJC)e4hAz!~GWNSn{6TzY_XRY)UB(BR+os{BjykF1ZH|c?aSu{i zfML7=wL>Up1$y)+VSW@Z5>QZrS8@da+c7U*QTfyHpZcN3V{x;1UkL$HowGB$I5~Dh z>h;<wr^X+|k77IL1&;aIJqN0^E}he=X{uV}u`JgN%^a&TdQQY)Hw-x>eMl#*3$-1N zR|ol02}vph_jF>+X?}hY=9Te$ekx{uBB!%gH5k828&$(coEnJ{>uyyv#SJ?<oB{bm zIU@(@(24|rJ(w0$xoB5>XedDp$3v^>ay5w;w)EAtdecNfX*7`5O;G8+Sp7j6R4iX_ z7&sXOefmNY3xig+o=}`@z?pf45&?dFZ|5G{zh>9N)uT!FOShfly}d=#RbK7X^)N*x zzA&-KOJZ17Oq>u@0(S$A!`StQcEjRl4B$(dG=FyxOJxR$ulXK(GqsvDOjb7^i{bcg z6qS_9l#9F3UF@Tyu{7GYiF3W*85c@>+?oqO-jb@Gl5-t(V&ElJO7KxO;1pbyEVv9u z&~zUN?Ozh&Qbs2na-~%WNbdORc|A2EJvZ>D`f|cc!l#MxX=JHE(vxs53E4*M)E23T zKGE?Faj+YKmhKwkAwIO|*m<AdoSr|`fq~VZ4aAi!5vcI@_KiD^b{0+{NS7@PkzjNl zLLhB(-;1UCw9Pfe=BcN4k_IQ8g@Y;M>3~1{k?+)O{w7wJB<}9aM=HOJX&e*b&K(jA znUcrKnlh+p#1Nt44_GCWxbsN_Q%7FHIbsDe=O7=LWM|GX{d(Fk*nu@En1T6&?|yz! z_!-+y0XKqid`}3((Q+2KXa4{d5Dj+>U!Kq@UEB#~nd21?B&2QwK2MH0`+uvYB~567 zRx2{fGgY*{)bO))fg8S|q`XqqTW>U0{Xt@#K&YXrj7b!O7(0=&8|%pS<EN$Krb(C= zEkz_)ih78MS(t_9mV3g**p7`~v52EXYku>5DP12`{4#3FulXdhnv0f-zXq?iRH;#) zOfw0hI3J!j45SYrW35AlagHCw&&lG6NSvw(fs#q)SPa~h;Eh3xe+uo+E9Hzd)t>L= z1oun-0Me@yx>z1&k=35e{?i`L_fE%mz3MGhKV-EfmfurzziHd#aZalYC|*j6D&2u< zDbW%~WGE&8jmkg_YrUrIJ}-wT%un|vJ7(LGfwAz78;9}f6Oa)#EVh0}q1CKGX1$`{ zc9Xu{qwa+`yy!mVSv6U!wOej=*7SIc_WFAGKNCvTG|E}2q;xs-m~LIHrx@y&H@K{( z{$69~@P%;zm9P!(eOr-^H-6IZhK(oMTV#F8({~D*>YJU)x@(0zih>$QN~fk%3OOuT zZQ$edImtV3**PeZta1g7LvLP|5&8}NHhgi#@eFc%H;F@|Bc(v79_iNRw|yEs*=oH7 zS9`czuR~D6(Mv1K9Xyu!-PMZi*}o$zfCA^neeu*^0{Ehg9v)s5cyLHyRNxLpn`0V} z#82scJZFjGxVkaQA26w53P4#0FXYZt2@V-9EZW3pr>sKiDCN~A>0+yy-AokvdE|1g zqz8F6;C^fc8TQV6b)#`^)BLsN!URmG<}*^CAn#3V@#ap+{2P@vLgw++Pr|7svLg#J zV*qM%{zq1qpgY9pWA84OceZIeHPs=jP${B@dIF4DQvnHd8()qAK70)E)(^%z7sTbJ zZxWv@De6d4O%9+6mZfSMwvW)x#C&q>YLSQR-wvEouo8qNNSL3>7!L3ZR5Uu$3cq_- ztICG&Sre>J81&~W@&n5GKQ4a0anFvaa<QrfMPLHn54#%Edm9VJtHc5(r6nnLwZ;A0 zO(F%<m+stIwSl-??|0FvqMS`2qn4sVvJu8WByKCg89s1E4m#O8ImA0rHwKkq5|JU8 zXUaRhP4=jG@n^(N74X9c#-=NcW;pdID2Z9rtmFa;lEgWq0bWtMzqwFZ4OAL(r@1jR zND6wC)heYMo*g!^Y;ZZodL~Z+#ZH=k5%NN7`BaB{cY$y#e@G>N5%@kA3Y2h^V^q3+ zX>;<B<=CZ_duh8;1Mgkc?mgNgNov#jttQMQ);yKZCUK02*|r5B;E!?v&r{ew&2Xtp z`M8<#$ZC)6jT+*o*l410{{V{}uf!zbcy<DMT=!T10Jw3#bxI?X6VFJK?1|jT=q=TE zonE$zw^1bYqSBJQZR{P3;QXwo!2LY+pK)F*z~oL;tUQmHkXR8gfN1UmP#t#BqkZgO zXFF+(z{<q&Jb%bq!<lA63X@V8sY@WxJp<OTM^SQ)$dqlWb&Wg58L7dRk}7Xlu340A z<nS}cu+JIl8c~_V0hfTnGhLLWkPhImdw@-Tv2DfTW$*xuS0CblAyn_CRG?3TlEUB} zxx+QjQTF^#Rc{vxYT9~<*h(p8UVdOOUnzs{=RHVZxCa?T#7-$;E`u^ooS2e)BCW*! zAoebH9&Q*-Oy8aP#JPkyJE$!n9}&5vXMM8h+Iu%^>MO0)+L}>lj-sAQ+r($l#EcbT zR+tn(8zY``_dQ>@_B)CuVn!WlKxQp0srrzUU@aIwVLLzBxwv_X#YkM`W^h%CN^-mY zU+*S{tVkxNtpQq_NLF4Vrh5*XlF@MnHfoxS$Z*jBRG<Od+YR)<<Dh#z+X-^krEwfj z&Px|h%B#KHq&o@?c|^Bvz6|0q7*r>-JT&2Eg#spiDZoyt0V`4O8iF=8Xppq6va;1{ zq>A@l9c}fpwGxz85-4M`HY9%&Zbv5>{#i60&~eFA@g;a9l*<N!!PsUkqwS=6E8*W{ zF}yp3OOwUanYf1UuBBV0C2H?0D5hiKNY|*fEw05+6-9MPWsX9@BPGh8@P}OeMn->L zp6uTP#qk9bhF~n}rnPQ+_(WgCZ;2civ+_c?fi9~g2QJPWKX#TXq(GXBSJ&z)x#}jC zrYch_JTSD)v;uRq^N+9fJ$e^`a0z%RVKUv#%Ln^?qre@!_?3(86yuzfNft}eo9=&j z9g0MbDNs)&Y8yKPHq(KVo_IMPG0z=O%t0>pKF6a*WiS#!O$n)~(~#cZctLi1<S#r_ zW*D9|3o&9cMt?331Ia(HOT#5Of|e-}xaqisAg>NUBDz~&=f)D-jm+Eb2~%;dGPH5Z zj3-s~97q9PLFWaIKkW2U@+6G39`xJDz><Dx4qTH;C{~-XxA5166sa{l6Yj0NBF_UW zsi@(Tc)%bJA1;3V46+=MLukzg%fltzZjw3B>MYqt)iIT2x|r2eQ^7rPfE~^Qg(0@6 zZ@C#6$B#JaCSsl2mtJ3Z;`m5XT#{{Ke8FvhXqNk3L8jGbRJVq_0N$+iDkf=TInK~Q z3Nyg~cq5LoFNrvgAd;DaVG_=Qf%JC0u1tF$`e(v$6Z2$#7=AIoF(pKxx8-uleR8&s z6D4)dqJoZDrJk`N2tH6UihKa0z#ncg&sjGPz^@TBN=;6?bMb9{vFlDh#<2W12E0<z z3K9SVscLkH#_M`&`CR&N(jm_Gc)$ZW836IP{Qw+wu5i8)@=y+3)nGP1x9uKV<K4A5 ziR)2hhIJ_D=9N0=D8O@tS~pKzHTJ2X(wbu3HU3FmA%d19A*XH`i<6zPt~WFLex!9h z_*KbCn>6Gqa`ywK=Rh>lkyB3`!eTKhl_e!;lqiw@c5<RYVXC#aAst=pe`Pe@mb{2H zl`QqW*@+sUQ%@LJD4}jWY9OSm#@O0sKR$OU+2g9Yn=L6)WuEKQ{{WN!0Mzzv0Pfa- z+{Dz(W+Gt|u_wHNKpP@Kr*Ly0nM!r5Rs;yEUlkfpQ5|7X-kW`*-)+(qw>Y%qNF`;e zydt9mT_8Mck=5crIU+P;!34<5lZTsxUJ=WvJB=I8l)FS6xwxz^5|PGaq#TL?tW*sR zcd*LFg0+g}+zo4F(lnOI>n&?>S>|G~Bx#MIROOktTxaf2Kd)K8WIH#B$C<JM^8B*y z=Uq9t*GTo3#!rcSJF{{y+*cDVQ&&AD3;;`k-g2cD3`-4fM-dZJ>e@X(?JcgF=SHwd z>63V)rd0rfr31&Gso>+EK04Czd<@|biF1lnclmP`qr0-3mxOt1h5SpC+9~s~cyvsZ z5<#vmM6`z=;1?~+v$Gz$Mr&e<?2eVSmA`_Su<{*A41g2K%Aom&7{K%P1E#;$X5oQU zlmeuOAK6f~tEW@?!Qc8X3&m6<<5w_CQDgrAzm%}X{m_sPFe%OrbmwR^uBf|G>3c;$ zs<_-BWP^2DFj)#8n2)dwIV2CK(e4l0&LNA%CPc}3Qb;9|b|&VR@rv)nZ-X70;Cv2$ z5yDP0CW<5(i8mlP0f`_RY1S+q*SlTc*O1yYzjdy(*E?;F+b*23$4^P=tuTl9EP_P= zr2xdFJ2QS<agDubid?yg&IhqV4S$=*jAlv50YEG)+`-CUO`oiE@3eY}l8?7n>PDaU zUDfYU%j!fGsp4u(MI))_{{U!Wz#e8({u9u%r6)F1V&{E^yW3jyh9oEy<~mcG2YPZh zHtQ5Kz3p|m_hVe&Z`GH(i2NM!O9d@b(@k)dO3<|;FjCD<?D0EBqXkst;GDQKB`Qb^ z{x#Q|Ql;rgkd#BDWp}YAjd$jBt5t^4Npyb5=8_~8m37Hh?Hw&WQwZb8;bH{mdlqcD z!iC%qNtrP}FT6D6>(kB)ICKzMh@&|#eRZ{6P2RLmS|?py>Zq;uyIk3o%!wRk1bAKu z<U3=`m@*<UkmO)thy#wJX5o{U=2+_2>rGpFeIN{6iDA@|PcW|?$$D36ZxcqC)K>Sk zL0x^j$udb(EO5&Lm1v)F+q;hk*x(XKAAX^xN}Nau06z8gfiW0xR7w+FkNE~-!&bd? zg_R<>R8~|`O)TZM6`ykYVKMV>IXJ-b!#la?=}MI~EU*JR>+Q7I+9J?0t1^XcLf^HA zI4jaRh^<Yg+|kq1Md^fngKNmS8ELRLG2nB-A4BJ%G1C5M&O-r5r;A*5-+1K7oywOd z-3KvB*g2pkEUjL@()uAJrPKDiMyu8_lB;u^`m{7Hx1T~2#Lm2b5&lu+DZo8ff2RKc z=r;$#kuQt(qx-m<glLoWBs~eKHfzQK@kjcaOqqv=@E{AgGYgc8e~6*+&{nZf^ksGS zzrVWEU+bMgM^$XH(wqI(`F@&Np`eWdymB3^36W$dvBo}9`xgX!y?evrnsE3Y%?u_D z0Ut4v5-F=cRu|GCJ_L+c65=C>V#!jFNX+1xhNIH9A-7`&eYNf;pV8M_UG|1y6%F#P z<27_7iJnGjnqe}gK~*a(YI!8Gxf$Doh^R|S&>fb{A7IWHQ$Zi0@^E?Z;z9oa8iubS z$m+mem5T@7Tbf;T){?wXRnW^wCnZYCGU3;5RAc7G4o|ju^NzA#j~p>mI7%l2{@Sa5 z@(oXP5h>V36k=9|rllg6W-on9cnGVv3)ED1YaOyyZ^X{BIxrk6s%44#FA6_WKVH0~ zJLysX0E;m5pz@*EM!7C}kueBLK`IT_l%rE_W&D2Y9baV9w8>k0x<N%vDsMsgiyRS! zUvr*5=g+rOJTJ5Oz9Wdqfk9a;NvjQ?Z5{-j#U$ZS_=4AKxorc`ji1IV*0qOL>m38A zHO=CJ;itP}M!O=7)OxonmQ(4hr`ZYr01gN^{SRK8_;uKh8^SS(lRjWxHAu`hN>V^A zO0goY^|6fiwA^nKHc6U6Oq7}mxvrw1vC!LCwto+rnzvZ?s?&FxtM|=%#~egLqA9&- zf}^XsI62x|?j^@Pes2E&RMK$x4%aIHqmd3(-vrAEnO^EZdYZU2DbU0MIFc5mqG7mV z;E*WH*U{Wi`NY)KNTzz0bhpf58%0mZsNf8Y=W#wp;61(k_2z6p2gFayhLDpv2_^nm zbC+{Wq3!`MxDYIyA(&Gre{cu<qfI#gb|ja(JON*;ZNI#&t!th+Xi-UjJTWBWC!BJ5 z{{VkpqWDIhD}=+$JXs0~Q5izN;uE!jpr-X;)ziB-hsB~w(~3(HZcdFy8f?R?4O3M* zmX2ABpos*b6bzAqKcO5CetOgsJpg7T3Q(S>ez8A^WT`6u04*!*4f@82E(|IXa<0T3 z=OZ3`1JCmQeGe^ZQ3?bW=Kj8MDw*=Y%AMPGVeO-2QkH2YQsEuWMh4|#Nf{s4`t(QP zL0I`<o__qGnOKE>RfeYQEz|88%T(_eh!(=aK>!bwVU98W&Y6>$!jfts3YJuY^%UrT zj&MP0zsYZcqMmwr7FmRE9_|h~2WZE?A8)5kW3YumMu52X`#meHGBD3J&I5z&YqU}h z<?1`#<GhrTTkrPzgo2o}AeV_YlEr1(eqsv(2b_OCy<^y38GzyalU(e4g#e|0#<gJS zcFGySCfaEc<MFtT(n`W`>B$8t1)SUxNKhY`lFW|?+OnlQezdNVR1B+3ss`rgoO^YV zV(~KmYSkq<Q~o-zdz-&q^p9Tf{2GjuOPrv!0a_5|l><S_5A^-c8V9DQxPp)*tgKEB z!tsH~_Z%tv_4XQWL?kV!Pm^35cspwPL>@m0F=VX*tUTP2_wt75B$i_om2k=_g0hE< zs08if&Q3W4IQo5h8APNNC7kHbU!x!1JB^r?yt8)N!|r-hcaE2S%P#QOSvALVJ{GG? z*KHow`E}->fn(g(z0=1H#*Rb)fefZ-XH4L3Gmdv-t;2zFMdL8yVuyH8nwM>St}fxD z^6&bK><pe0#XfX`0%kcO{{SgbpgguCOWG$7hD}Rx_;>hg?+0${t0=7YtqI;MhKbVE zH8ZG+y46*Bp_T~gcQ^4pIIguZfVshqvH2So3?fONo>+1SCzFv!(|Ex6q5MS2M(SE^ zsbE1qL!Bz|C4SRSiGJ(+DfSYFTlRi!HAARs^)z-VC$FVimVzgOq4(28A==U_#S)Ur z>Y@1nmCm7!U&~2Q4qwUMzfZhDc6Wy(8;njJOUej->px1_-}4PvM-ToFe`@E%2X4Ew zUrXMrrnmTl(7J}TWV_ImYP+?fOav7+$j|^(C|&e{L~;bmY<x3CP2-7@NyMlnJDPfX z>!H?B@TO-GN@Q?}C3A*@GRmJrOaB1XPGO!plkw&7Rc`pb(t3O1BjKtGQ+I}*C9R$` ziS6~;#+73U94=<qA-F^Yh>K8)Tr)u<C<)UXO9zMIGEd4WQBdmqh}ryg5PxGh=N#-; z6GWaZDs~+xP*4g<NkZx0Nl<GzvXVp4gH9V$q<2YcQdn*jw>oN>XsfAe=}k3FzNtwK zPf-J5p^+G2qhJU>Vh>(d<2(*-7Z6d2NlwQ?G;QC^UgAG%dlT^k0oo29FCW4uCSWZ~ z5L&J@J7`6T5jOT);dag6_`8i+bM7^}w%yRBj+WKi0dkyE&|a@G55(2d)yTtWj#QqO zoH1Zk<lHxGW$RPyp95U(7O<&V<*D<FosxV22G_k~^PBpw{-F`s?}%7QQYVB>l7Iu2 z1e&NZWcP_?B)A2)gCBy8BSopcDf?BU`-`S8HpRDUN`=(6YPApm&OkB0)Z6E_!97)F zJnG&hK}jl)W+hl;5PHveA7`dY<8rWbP1FE8xTo>q9;oe~#r!`M_<29H93gBKEYnb; zLP|){iJ=-ae(tYxdzJBd-Ang&b(K|g(pOHxhKMmV_F6A7QB$T!SkX3&3~p`Aa!Duj z$AoaXJUa?7^2Gx=-Ss^G0BHQ|!{L*J!-S=?nyBt*eKZyUfGrIA-$2QwdwqDaRc(Ud zSxZ$Sqo&fy=!Nm`@BIf@H^t?Z<K<<qb^ieTV(t6ENXPuqQVHfuzpo>s$0)VVw5Zp5 z!r`dprbVi#qNk>iB!*92>ET%YOpv%Nv4U3|0zl86sDfRd!oL0Wi0NR;Q<kf-b4}}E z_p?E#VVx^Gou@Y91w^TyFj%(iLgV@H-hSN?EEKGbJ@{YF5sO>l-3FY$dbXC2X<CoH z)g71kB=~%^*{Yh4S6wtsn$c`!Ici9M0THOF8S%6}z<<th^y~B4cu)0i($7nOW>m$g zT*)Pa-|4L*+P~A+!!+?+aute8$&!?*T4pWXN1aa~W0q;C>SK)?@eU+lKrg#KJZC(U z*6<WkNgx0Pfj*S-kI?z}AfiAN<?LHXRW_vjWcL~At0Jji#QHM?eEM>21(f4BBR)O1 ze=elt&N&>p*TD6+GH(=rkQY2Xk|=zA^oD7!HElG7W{?!fBp<Jw{lDMTP9kL=+jR8S z){DfHOEPk%uE)#heIih>UJX{Cw-U_F7V2}J3k5$f^4i1CTMuF}=57F?U*JgOH6K{~ zLjJt_BCZ?T`IyuOPA*)zwx)0P)(dk$C#-Ha6FWf=sExqek<LKR+x7SA(wRwi9?W>P z6J~&%!~@NNYhQmx1)~dvY&2*jxmDBK-~bZS$gqs$kMWQ($Jd}|@d*+RX{rO6P#!gF zMYDr^I^*1NK|C`DHhPV|T&auoNIp;23T)atS?a4r1>;X?s=M8ytpwDy)Y8P!RG?xv zM<)C*%8`-agVsmd59%whoGTKn9}(j6CCovpO5CQ`2?eR?77zabN8Z@yGN*9qs8UNP zEg>Wkp$Z`LW}(nLM4I2>OSoFDYKZN9yt>PBs**Z)r}W`4%<jQHZ<vV3&N)9I$vGV7 zTKIeYYIX|{k$|1W<jpD_)>ElZWHAFVRfeSZZwFw$6ZWGFvG}YilFZ6+-D{G>(Y4c9 zzJCI=ny2Aww)GvA*B!93X+0t?HJ5mG9fs=-d<`tebb{v`ZR$n<R8^f+#;B^S6b<c_ z;Qs)suZCTz#sqeE9;tJbK>2f}Op@i>xTOM{{{V`V4MS}f?$CTJ?H&<7K5A1n2_N8~ zNE)1{DLa+~o0p0S`%S(nD|?CFja%7UK$Pj-9s9c9S5O!*tF3BrQz9w%RQyCh=iG6| zGb`~ojgf(sht!g#9+c6D*QKM^KhWo7zI4oI9mE?1%18eIWg9<1aL0J$<?;xf{TOR* zw6UR)7(+j%xXA+~agsUn{;shU%^^;iX(j8J1$rL6BlLd}fmH7DrY!&g`4yCX3;XIB z7SLgOc@5)MG>HEIChMHuJeBi+4s(wj@zJ<`J3HnjN|h)iGcpnxx2DwkMR{F}!>vkJ z;%Ao?dF%85=?rvQ3rNyl{{Vh1vZ7n7NYefydWV&Sz6tq>1-bHhA9K+hOSc?H5rq64 z$$(2{DG3QnXea^OO)E%_?8m`=&T#G^IJ}HI;(^Wc7NxCOzvSvs?{i}YlJ7|>42)fl z<Y3$mG0%WM<N5VXRHX$H1F4zLmVXDW{3B*UQ{4bC_v3xwGOs}CE4|LD`&DL&TKOL@ zBS`7L0|AECz-)oeJoRkheYoOSd?1k~Yauoa7z0LfA6}K~8D|;zG1;CY#1)Um;ew~A zx|Nwh)ESEf3a}PpS>41C#cH?FH4D-`OjC+=g~QcO_yaru%t6ip>K7ZwF&L>|E^2~+ z4Me1GZs;MXXkzEWJ0pR?FXrLUpk9Q`S!1&)XTW;A6iQa(adw5@=ZQ+~J8^|oJbMg= z{)4EsjO!T#k^@@dhTTV4mIh>*K_JohCw^NrHb|@<NLl1it+j|kIp7?9Nze56>$8%i z5*9kw>2Nv2f>8{^pDtcdDXTOsw^>C=S6yFpinfJ_h`>-}F~L>@5$w44@%nXg?0;*q z94RXjl9gv6^F6@Oinh*U8~Cm8Yle2VWS_?^a?GHhl7cLzBryk3acyrH>CF_Ha?w#~ zwrb1u6);mIY3Wmz^$dzwl4B(0cMeIw9D8(M743p?lg#X99R)$Pm@RMe1Ft*4J(KuP zH-TYB2LZ$<i3udLNGw$3_r6<~JDyzNgSsoODH}u3+9>WfOI#LcQ78O;Ek5)0Wb{b` zgU>nh@70iFI7I1UZY+eUg)+=twpMWzb>RD7hQv(5@T4kiu&>T%VdauqprpuA2ga}s zVuQJctV?*7dvy>6-etJcPFaEA1rURb<YzrcaeP+;P8|LnmbVSuT3N>V$oA1@?3BLQ zPR1r*6((G!srj-d6Q~&kgwVS_WR?$86E2C<@@n0Gq4bq);$1&$yNcMJvMYG2mN}uO zdWh5`I;ajInRCem3<6J8X<|w+Gx2j%o?-$>8Q3zFl$Qa^5;hg(2mDg)PZZ&k@TkEl zDVH%zLdif;%#`L$$ZWtJ>>46o{db04Yi;evXtdStzUAAC5qq6AMOCG3)7&Ji*vck> zNy@|wrZ;1_iiQUpK<m(754bXA$T-=cCSmR{R&X+%*jIL;XOGU$iabXXi{ne1n6$u& zg}_VZO(+TOki-JSScrhCBm>q1<&j;t7D31j?0&ze_v-cet*<z)%~A}I6l&E6pw=0# zHQQp2NQ=(Q(lmPqCnx><<MrwEA!-5X<-XAsH;7A?l1O$U$M$hvu!6FxqV;l)g4;t; zSzB2+{45bh(#b0kff^#^oruN)gPwXMt0c2FVwfhRntJ}WismAEmamqS<~p@+p##o> z`$3iWv;FGP_IPy`sO=YZtU7U1)isscezKkgKyc2|JU}2E<hBR$>L(A{P9HjF%9l9` zR>44;*b5H`B=G!F5M~rAmv`9KgGOewF~DG|sVo3AX;2jz{{Sl{<H7sm&qhrOmgxuI z*hIb+MtT8j)yAGyA~2RYzY|x97?B|I1_(QWC-1=iNA1@P(Q;q)gFaE3T3{qwr|w6T z5b3(<try!}LR6}(hvz`yy!?bR=Z;2jago(~v{(dU@KYs_^gd#~+AjY94Y>07XBv}; zmOx1?s+y%mh&0=tkuU4At81l<RLIfn6)pie1PlfC@^=&Z4zfPQND0KuTEOozL2|{e zY<wT>9>e(M#AYiBnH32~c!8~1H@%59G=VKOp{|;)&v?`yz9?uYV5n+uN?A8cHAts< z<4}P7s8%TyIXK(^$2sd<RP;$AT2p_gzER~oCI=CUlB`N5P$`+oB~0$-!_nT6(Ek92 zF3&wTV=mgpE3Mv+>v^)>C;SbrmVd@vD+cK8v9$1JQAP?&ERZWkBxgouWkQ`$`)!WK zDq<wz5(_CHl@Ua{sDPlvwE<$?V&~Xy27!UjvH+M1(VncAv0%=^^{$a-J{Gi3a<uk? zxJ}&)Mar8009jpCJs<BX(^Fe&p?Kj{r&$X#2;NjO?F0{zNdU3xKaEe#juT|5rG&P? zsoW^^6eg!=JF`+3D+op;w9L-V>=}()kfUnNNUgsXy{FeVjc=;EiEFV{$Du9yMd4(g z;~cLIL{R+F2;H}WT?CvA{G*<6*WH-LO~YZnMlD&EW&#d@B-k~8+SNNleWk$7p0whn zl>D^IP&EP7QV&t3t$3)mS?Tp2oTsa(hLTAZCoCNm!Fc}wF9i8G!1wB#$1sb)ah#IL z0Y>gY0e7|I*nNfLvbgVJeq_mc{#slD0j##Og3iI-&qjo|i#?K6f_1gorj5`x@59B2 z@;qZ4e<A$+`U8dW%wkCbVuPRg9s2utvT=^aa77f%7)g0l0)R5oba#Be$2dYP8frM@ zRMU1Qk)0%n{t7Us3VCiZ<NE!&I$vqAz#s3ZRDY73ZrbbmMJ<01I0C9LQk{-(?W3#y zQR{1mXq)~Q^!1L1yjp6mn$2XVdLdUYsFcX8s43(u0{}6VX7P_c<EzhOxW+RRk4k4u zQ3WQ2M281$YSDrJ08rhQ;J7Z$OvK>uvgOVBh)7aUkftWJ17UtlZwB4SwY6VubXRZ- z*2hzAXzFeVfp)z;d(AR#P1)QEpc_ZGJmWgP2#=WQ9n-68eg2jY9s$P{Ap|T98i%2% z9xXx%*pU)nZTpn<bW}QWNouL?6H(pn7PXIiNiVH6Cgngu`Ot}EAb3;bjCAbzWGEl_ zkJ7E|bcn7QG^$=>xfgoXgSS`E9ZXfe_|J5<_X^)fa<3GVJ*Y`haHo(ojAVWD;m$%3 z@xr^0fY`@ekfoFifuEgeQ>f<?<p9gfq~4n7!!g{p(NrnyNmr;V#BkQoX{rebV!&jB z^8_VZ$;ki{j~x5-q|Zte%4a@)yr9D^Un%7!<-JQe1H^9Z4uU01eLYpCww|88j^Agc zq=6Q7B)TkT9;BPRHh9L-<H0#SH8n~hNd()MfGza5lm|6(z(_y}H2{qRY)K-7-^$T0 z(%$8vN^5e}rF|t#%Tlwe09_TohF`!ARaedk1PlT?iU|u*%o)6B)iw5Q089y-RJHz4 z2&?PKto1JvMLwdAw%V{s9LXEXT~?h!GGJ}XkTaf7^XOGc$gop!<i|jDux*4vVkOMT z5~_8krj|FjJ8u#-mcGszC8?l;)s09dF}0W<Vm_dddB$>jg2YS27EWrGrND0swT_3Z zIHs6X8HI_pxzv_+16OCf7s+NcvN}z*%q8P?A1Evfalrdyf%VT{qpB-%LHx~n{r><) zkIB@cQou?<WW8)0y&WKpG#3OA(OCX1^;Hg$(3T$CTOBGj9;%iL{-Bf+j4kxV;-aM` zaq_(0yzR##2z>F@725tTtad$0QU272Z9|tQ#0?)_O&_D*)4yYrfZ-C{e{i~rjR_>& z&^rbM7AI|B$6I_i_oGbqUc=pb$~!E2C8CC*De3EEHMB5Jim5F^0051*?K@bM^#LFg zk>E}v@%IIf?G)KqIjTyfIucDY5Aq2$?;Xev%}Dl+Cjp2=v7FyP{oQIowv=!mtV9}5 zQ1?Sm(mTN>q1C!dHc2N+s4HsWrf{Sm`Nmj8;ZWs?<S)!U#J3jh=i{ZE1|@Iv8j4WN z2rdXoA<5{<9K<JtVJBf0F%p3wyU~Fa7h>DmCho{uZW^~yYAsQ#qY^jagjA6uvZXv; zsTfh`1R)v!01;L{PQ01%zqd2^Y`Hj29I%o}cL0*WRm;?p?93yWp48&7>P*mCO3;U; zZO@+3Dr#*lq$~A?owwYmrMyzm!!=wGx6EUiqhyUs9A$U7Kcb&Jb?6W2e~a<XDZ>}U z)>A2n1w?mLkVa)?{{WUyp|HIw)*L?&ILQc^hyyVsfb2T*9E~2aUVbjMMgIUyXv<G) zYT62P7e=d|7}ZJ4YBCx8HV>vT{t^!yp1q^lJ`=-n)4%lw6gsCMAQDLK0!U#-a!r5A znnh2wJXoO!$xUBM8?hVc@fZ9t_it8crq$NUJuh<B6jjWXaYYaC{W`oAF47e$h|9qP z$pGYXP67Q#e@-%cXmD%7%o1kFbQJ(p47;{h4pM3fGz8u&vhWzpQ6&JYF>-C@Nc9nU zZTE`JVPvM(RWxGPaWMW0zTVO?dwooAwW80s@XFrdl=wYip9Z@ff#4md#QZ<w2rHtO zDre;r5~fyW(Le_7lTbuoJ2Q+z5%@I%CZw0x^+|rUyV4ifX{||mqJpr?_-8B&H-=&W zRBxck^Y6z({{T?^jN%*v2RU3T@~X1PB&YyJZ~T%D)~Kt+yC1|Ss{*xQs<e#-fz(}# zRUpud#KlQR92F6m>FLoh%EmQbKh4|-^Ut<?b@{W2;xlJD=B%klQb~R4@r)~tPnt?* zso!#6r&G4unnLEbQ^?Y-RW&<scVq%SpG^Hfw@gXm*og!xM8X?lN8TMz6vU@Az`FIZ z_xr?ct91Pxuke+WL8696*&-9haB+|6{JQkN!Jge>_=4tOF=sO+!nGA*=UQ~Av}#?A z?L_QCRVH;xMGoywf&!c>?=r{uh|xbOY!k+O{{Vmf-n|K%yaF5^Oy0X$w~Y;R07{fi z2h(Qf^@!_OUY@4or}Shsve}aDA&#ziS8J>R8O%}1&e<djv=!tjBx8ftuh~w@E?yFE z9GffVBMv)(PE($N$ki!#P&9UX#cvai`M8OEF)PSs8nq1ru;z882CmQ<bFyjMe7}RF z({{?MOED5stvFrljBZIl-jaALC^-k8m}C-oM{oGSo|*puJyRE`sRU{YY(|XDR*iCB zVz^xH8gTfuCQ@obzbOP%C^>pxn0h=%GE>uwg+;2GqI;yU%E*ipGBjr;2}R>OKqYha z9ca8m;nK0#gNnf^GUcx-fK$;#RDa@a%Xf;04*sUFoH{_jV<oP1`DCR^NlsIgnuRO# z5|*gta!eK0+u)hc;jT3F^E7I9R3pQ*?efHe066k8c^^a282<o;nAwVPH5|aqYim^a zI~`&*AJtb3M6&o<gq?~iE34n2hNaSd+^@A=>U4#>Mlo1&YpO1CBsA@eg6hz!vEvvg z*q<F8BjH*;Xa3@TVW<JE&uFgkuj+s1OEU1-T&gYsCo?}L-#~iBgY4J9_jEocq@(T^ zZ{+QUle+2ajWsoW((!nfUFJQKnp(S6Dm0VQOY?vxc-BQM6p$EnM*`ru?iN=jVzWv$ zXa4}|s*y|4qagUv@mqoMd1x!csA3p0%0r9Nu9M37Pm%1+KVfxjaB1te#LlnyZtpD} zf|Jv>+O3_Xr?pLOScdpk-&-wYjYP3B#Ne!80GLoRF(*E0mVluxEv5dX`hghl!*7Rp zp3!k}BZn0sCX1MBlpp(02|FiKb`C{m?@w+!Z}APQ={2`{^o6TmT<KM#qZL*61%j0r z{{WLn5}{&M<NPWKY?dcH0gvq_GY~*r(h+b2fc_hc*Y%Ioe#L)EJ&xe=@}~@Dtw3fg zIf)<(XqIX<0HEGKS!ugR+UB6pS`SNU4&K$yl2m>c%VHGXqKs$NtBEohxWdTgm>vfm zLwwW)fU({?yZN6DN8UG%0oe>53R01Tk`phL2bIrWRf9`+aPGEn_Ub)daJMeSLL-sm z&J-^PA53}Ak2xJ1FA?1$Qi^(Q)|KY*C5`M?562Anj5=01fT7~<J+uXCJ4a{xg?G8? z4&}$DH6E<4Ri~2eSsgsJRRI(#EY$~Wj!{Ad+{AzY$mkqo!HQq^)E3&lwz1dUL`C3x zA1{TgF9yQOmIX>ufQ?>(A()r-qzvj#$!O|}MW0RD=_u$ev_FnCNl1#P6)t9Xk|_c8 zOE4Uq;3!Z)01HoLdtZp*fJu<bmMBtw#^8C5QRDvr>JR!i?3Zq*&EnXRGX=y}8|I_G zET8(NE4o9}u|d<6b8gGsTmJyV&&MW{*P7`oHRX<2=wz9eMrw=Qx;kmCwUCnk06*dl zB(*Rm?6y)5n5Z2|c9)M~@n0v5VCCicDg=$RRjJVPs~E#R6#NY1J&wa;J{@uFK$vlI z)MZTyRzsCp<(~DN22yUC08I>atKqBRdqmXRZW<=0vS-v<dT~)fcBkTxSLw=~^4f1y z++s+kfb3$enB|H{m@6q!x$$3!GmvHC<tQpt&^57O6gh8L!8<|5@i+yUIH_Y|_ez29 zCzB9Z8di~4zi6G7-JmOAwOi{cCWZ|=JtadpIa#KO%wQAX{{RsWA79t3)3uyu@z}J@ z0?7~JPNV4!_IHaeejRI3YOtqAwVm3L=Ee$yr)o-onT|N}>Q@;c`1*K0e#fDb8u33e za6-vu1E<(+=gubH#KzHgGQd$8AMmxCa=6|-2F8C(=kM2KrA|iG@-R0NQAQk*f33~0 zXz!N3?#)MW>NIF6RF*pqnzd=$Y*L&o5Yy5`j^t+wUQr~6-<0}w<(>uE1>t7S%imh& zDQe!TE$p{g{a);kYCAF5KFZ;y@$788IhvHrB}xFOsUpez#gf8<?%@+(sL(85i`gsi zRv=q(JPaN^@$dbIR~$cQ=ct7i2^AHq^NdHefBte$hnNK^7!^*%sDHv<QFgq-pZ>Lo z_fGdB$4#Yd)p~zURNJlfFeO^SEkFzygS&B2$<I7>H-X?LY7;a}U`M@U1o)Nz0OtzC za6UDO;P`$RCoW2gGpcS-S&b5un}Rnm1E~A&a<yJ<l>Y$oZrUx=PO(iR)JSBA7ur}6 zfJP2L!Roo%nG*PK%Si8qg8CW}puOYQ-_Y;I{w48ahViNR>1o2vz$GSHQQiy8nS^G| zU(ALCFk*Fw8=u5v`d?4#i*(k=?V6@5#8AUgJb|bdkVQJk&ZMiE%Plcn5`_$#utMfP zgm$ADDHEk&Wjxg>nyav(rOBatH7+Al_?`VQ_B*z7cvm0oj}QpKOORKSI%KIzm{7c> zW&Z%T`J5$|?LrJK(z-9=qrVzflUiu|yHBF3=AMo!x@+An4LeH{sz?<hZT|oxWE_lv z$l&v9!uB7w99~eGP<8yu2?oFM2HSJiBzrB}Kk3)8OZhxE4ToIB{{WICD-fdF%vew* zs^LN&6ZU*}>hVzTa@BpP^-8%vjK0e=hF}TbBEU)cxX0xjk@f0h{{YeZkIVr}62aR$ z>hyGxT}}DMpZ@^$59|gV0#+JMC4)9uDM%I3C13zFqwlnIQ{cz3z5f90fT->Ew7qCM z$7$*q*3->xyEOF{YNEN3Vuj;V=}JJz2jy@ZA2apl?}>l@Z!~tVkKy=_6__yS4knpo z70gHnD5^jT2fLVp$u=w_#$VKr^)uN{(=2gr114&5$GV`oVJTP?3o1&L-bkUNs5ZdG z$<U1+xA>yfR;x^tu7mB~lB9~{qIP($nvV45NJPW(xuv&Jw2Fjb9^$WnLY}uy2mLj6 z6S9U}t`Wl}g``z$A2MNm)VI5601rs~-R<9Rc%<|4l{kQELRli4HFe)exj_CNsv}Vx zRMwcmmZElaRe}}e&ODM4Sopvm{a@vvBxFMYvj9AU2Ic1%)Wxl3)}s8+(aqz<*00vD z+<Vrm?uBE^HN#hGE4Hkax0WV4s<&!zSdRjoAmfY<JaN`V@e>vEF@Grq!T<)e<~8eE zN9=F(q1naYdoe7`4fA`!_ADRtsW2+-B+9JpMC>s8hDads_QnYD_Ukw(c2G3&_v0R` znITD{MRf5V`a{xb?LmE_x?d=*Ro5yC8dxf;=7AZbqKEvJ_x}JjS(FU>V4ncsoJ^&x zW=a~4O?~r-iFnyqd4mqM0HvsexvGum8l-yC5zlFo3xd)#YMbK@TpWS&7a(UI-3dPc zeqhaGUr$Jzz)uEW&3b<A&NoYEu7c8T@>{DUwmjd1daa~K>*If?zsBIakUfY$euSBX zCPrn7J;*Hk4FMN|Q^b(D-ysVDcWzY{tqIhY(i*ocl*uvv9GTk5z$JJm>JL8re=Kw= zG6aRn*RNf!AlZ4dur3+>h}8^e!a*JZTmZfcDLiBTKHmK%Y_*hTK%lPl)OgqkaZ>;z zIJ@{APnNnxgY9o&dzY^4V_8^ia6O~c5mF@5tPgNf4(F#5U@|EfEtLe46r3DRaIO<Q zX!+?K(rL_ccBt_2jPtdh5O`(+f8enT@3WTg1fFfF6#zLJwIRE?J)G2f2TxcvhNx)m z5nU^4DPp%uS!*F!`7X#u1B2tnQ$Lt=Hx%Ks5~NLCmQ6LKYs=Ew!5x?Fz8{F<CSfpu ztx~`xL9&-S`hBSJ5ky1N%nZyV*~$WQ^0+&WKhi(Xrm(>+l_ib!qi?AlV%Db<S(puU ztJB|<61Il!6q+jUuk`MWsl3`Da|+bV>kTwcW=NUIEs#;A02~zzS2@~8R&L916yotY z@kOKnR!zam4L2@*Ef{ykZ-`U)M-Ei38p_2epfj-nba$|AFQIKAt+ZCYj;BfK`D?B8 zIHQJ{;=DBSni<IqVo9M4&T>e>2b3+5xC40YfZ*7miPFJvmw90z5CzIf0V1~+cB@C3 zJ{bH!?Kc7olZsH8Ela4JRIH?u87ohCyO6|DoE;67wR}2i1-Dd5dB5ld8yZKFnvU%- zfqmFzA{=8mB#-IVIWrd^EK0+nWb<64(`pr0LJMqbSlVX%U*dUwX%f-^s|kv8aZ(Zc zb%#1G##^*y!$kKQx473@+6z4KYC2j=b%@jcD8nMGi1N=fGO3PZl-dbR$J?oH8H<^S z7>*wzLPW)XX<3(<voRD%XCzTiLeF=KFKoMSGaV*ICmO%)ApGQnpn^&qRH;O;N{J$l z9EWJ+7PF#?+1$N%XQ!>Gy3$ZwXQ8K`Dh!dwAw=Z~TmS|LKEtnAa5BI2$px~gq!KNl zes!sj9Px>Td`l9Ntm#Q$OByu;iZBcXXf=2pyjIH;Oeyduz$B5926@lEdYi+_1x~ih z<LRa18;oNL5EO#ScC}lk#r~1dU)k^2{{V-+?rIrRxtg}etTo&!^^_VON~08u3NU7= znJ}$Z3BeG?#St)M4Y+}vd`Rsd62eQ%gHBm=b&h%pjJ3JcxU?^`*i1Cc#&OJR-6QV6 z9*-TNwAg+fdka~pJC$qgZkFv0M8H$kU#M)+Q+_&P_!M-j3q0*nBOWQBiCRLZ<s@KX z6R|u)9N2YDGXVbp5~>SSlRyA#(W2lLCQp2%CEmbSlLjRFc7Q9-ef}hLwyCjey<OQP z(q9XA2@MUw>S{j`sPz0U!4~0qrAKJsrgG%G3MKSn=2lh=I+($59vwPbu=24Fi%qmb zh`2-Cjo3N0;3eYmNngy$K3>1_1Ahy!c;ffRKWa4p0A>Cob<Ud6^blHfg~E>Cf2X2| z0Sy4DM>0bmKlW=Z60&1}j-#9ow=To+x!BGe7dX0-a+|#x?s@+49f+2hSd7U{3z`PK z?cjdVQ0b*o&FibkeMMzocH4u`-~K(ixn^QQY47xeWm1$fJ01Lf{;;2W#a7a+alDM= z{Q$}12aa+V8OZ6sn39a7)AoZAHD-8HHwJ*v^rQW}V%btyAij%)HHcsSYa)O$bG9%} zMnT3`?0G$T`vb&8si{yumDR`^knQ)6(SFc&dS*2fCCv<#5Q_=|8BM14)Za*rtv5^0 zYVQ+D^z`>jMOzvD2_xMRmNjD=0tjSvF2FE3!?5HMI?}j*8kfZ|-`Q4GrGawS<*W0L zH1?~pxW38p7RJjf!psDvyUtP^20Ev4P!6?@+jb&b6y2t=Q)xKtHJ0iNbv+azW|!7L zBQh$=2-+KL+;NTvCp}g1Xqh%@%;DZA!i0zWu!-pjkt$TA;MDT+f(1LEv2EKyKq*aQ zS9!JmyU~5UscjG2`_{0IK-Bjt<hzO{w?bD1!^VAR-v<jI04c#I!09|TRH>`}?98oW zcRs!k(Vz|`GGbGjLITxVK2Qd=YQ1b}a?w#cncjUsp9xn;wGNg40FpNwy-MkO3H|XU zdskc4i%ly$#L7%@#pyJTNpj)19Cbd94=Gs0gdD(kl}$x}wf!Qm#Q2sc4a24tq<5Vu zVoQ!?v=`q)6vMX+Yqa&=l`b_jf5w&=sv;lcOGWvBVaXZeBdRaNRGA!3PA4ePkY21v zYCqYd(tp#Co5cGujlv~mB}Hfo4VXFwoEEPuKz&bcuL7FiQ73Y>i~j%!NC-FqeB+XQ zU<`Cr&e-9^$&$YVErHnA$O`MUH!t9q7M+5_PaJ;~2}qzJlS%<wCfyn%_-?w#OX>=g zkvoB@s3MioQc`^>{kM!Odu<>Nd~?;pW&?*~upm5{OHc&0)6`}b+`mgk6^89MY5PCN zrj@4~jguslvcDw29;BrTp(Vo|t8o@@;iI|tiTj|7E|j_2sXukx=6U9KMTvzhfMtr0 zC4k0qazF>A_J6XnFqpA5IF!sR%o@kUu%pmLf8y`Oej&xUBCu1qQt;?2O0%pehe`Z| zWJsta2O=!}H7xadCr4?iR<5EN+B;<O@02p`rMI+&5N+H)C~RZNU`QQVv2)D1YgOzz zxnWUZT{=6M^6n57sU$cRHMjsdzcAZBtZUku+ms!_(Q{VEO;=HCvQ*a5?oqm;Be3$A z+Pj<8jPiWvjyjf_<ov)~)Hamoa`b1UK+8Z$IDENsni0<6IB)^0itZk;#ojvlTi13g zKNU!p`RXN(riMsH$fJ%(on(h-8;7XOuk&PJ57VLKhaFz!$$IOyhr%Uhm61S@2x3@* z8&~Evy=vOTdS69q3&x<jP+0AicRJbY1T>UlEzKyD$iQ!Lu^12(H$3Bk&rHsmC8ef$ z=-nRL_+G6Nb5GBfB_&#cLtQFccV-)yHccyAEyhT-c@%dFY|S(q%Si;WkIS&|56XEN zA486YRxwMK$u19<pQr110}`~Lgd_mOjjH<w1m67`4sEvzNh+<7QNthL>0)n7VH*x| z2-}aF8Dq{fpXJaq<)4;9&G~Oo)p16)u?anMR7nRjhw`S)TY*B{+BZW`%W!jVMTMl5 zuovYrVB^6gk7LKz+oyA=O95n>{d|LeCI=3)ne$V|;I^+z>*W%2&uOQzQVJ;Ui+ZZ4 zk~)~)IQ<yn`F3IQWZrWiAcX({g4sBXNKeYRKD?U#UuY_3Z0ZZ{tEs57xN_Q7jjUMd zEb&)&BJ*phsFIe`6~d-^nPMxlDrIRM-GR?4sLkh)KS9^MIR5|`P7jBwd7~zmzJPB3 z09!}qAH%-K;y9NWm4&(r{{X~*7?SP?F581qymEiM_X_^u_j^=p+JLn5lGMhOv|;i1 zYIIdfctd*#<3tDf{d)1&Z#!crhv3zM%=1<WS0{3OPMcWA>^A`H-{Ha~u*iY|7Y;v7 z8k$F=jU#AJh#!ZoF>hP7s%;yntd`n3n5lAVXr@+CSdAuj;Cc(U0a+NFgU4TwxY=Wa z@fG4Fg_-D>vO|i62T2Y*NOMZ^jmB)yse~<vzg=zDkgqtF?6n$iOW1W)vJEqN&^l93 zi7oqX6hexQdQ^)tPfl3>02%|AQ!4-*gN?xC)-l{S7OYIEvgQf%X7`3g6=fu_2H8|e zwLzr=Gz_`<#%0mXk1m!G#s2_@%dol0+*+8bEKt%5s5G=xQmcwueY=q3JGSA_c-k9y z$vGWe{y$2XF&Iynl|Rd>m8p7-oxvwsiZo+<bQ3k^Dwbg9*QHOS7hUhx>xQ7Njyr@D z6%j!+%JLJmQ^S%7BP6kX;16<F8TS1X{+T-=Gk|tK44LwZ=3*LYNVxfg1cD7Rgt0wK z#oM#elP?b8PDN`$yIrfX@OYrQufE!1<E|@jl$w0%=1K&nw|?<Wh>{j*C1+LzvQE$l z8Nl<`u0)gsWMIHE3saVz1XpMFi3pmdAsMYz1_1LKc!2b*TmIYXn|;qk4P9yd&m?!s zNhl<Mf0Uphp=WG;a=R3tqkVd%aoj1y<7FXv(fD-*xL}o%6ao(*OP>v5{es3OY_!cw z5U!=m5qtDurzj|@si(I~Mf;BCD}NnHPd$835Y0VE+@*Q*g*eYSRseobdatLm3dM1R zahyb;iP8$?0u2`_SxQ!RAwq(cecB3M5h$D|q_Ae%`oAV-yF%+-;wl=G_=-APg(AA; z9f;e=IbR(6VDZpj6nhSy(r}Z*jME}=!6;flbf`TEV!+t7NV6Dnbh(53a#m7wElsPx z)I{N7y~B27o|=`C1YNt@p;)^Slet$Oo;U$cNybmNUx<Gcz8P^&%j1>9c(O9gxF8&g z4%v{R24HnM8Wtil3~muvdD$mk&!MTPHF3*LAi6mtmMEnP$Z%5`Jo)l}+y3se1-Mmf z1I(VK2TezbtVj2Yb=OqRwG>iOLu`z<qNTEG2qYKBAPz7-gRRT3ovGoSn0a`)Yh3lV zVjC(&6cQbr)Dm}~jUTi8QwPHv4>YLFEbKS11?n60F&0f39;#v^C?!bT4&MXc^gQ+J zY%=~}3rWlr$KCHr8j|8R1Su+%W)VUijR@*C^@^$9?(BA3M&0Ze5v?nd_imYM<bf+p zv3?`d#_D(#S%Dvvx8*p-IVZ1Fd>xnCZ}iF7PS7S=Fp4EIk(H)oF6LsK4)``9z%HX` z^6zUqWjFD2e+^NXkU{WtnzuV?6kk;QXIxsQ<Np99EcLf&nahW|)W4#m02An|Wcz23 zIq}u=2f!psC-CVKrQa$N6Xru~(N|VELQ6ZW>Y_~<ihwQyA2zH;(0osKB`WErmVG@f zS{7i*O(wv3I608#Cm%QT>c#VB2FMs}<EG!RYD8*ehZ$C?0ZpswNACoa{9yO0;ImS# z#?dS>?V1^)q>?Ezt}%vLf2cVJ!Sm4yp3?IWDY1n~z!gl)f?R7tD*FM=_BAkVPx0yA zx^)m8J=>?iSY1seN*RU*cj98Clbrm#`}pW7IHY{DF$2Zj{QZa_JRq!qr7_vIi_P$( zul^x>nRbi1azhVkG@qdKQP$Y*b+j|xky(agRge)9IVW*!76d3Gaq6Yo9@2?w{5~9J zQff;(7IyytsO~heFCMP=BmF0<v{F;WdqSd&F{%;=d3Cs>B^3P3=R+Mk()9LvSWW9# z>RP9_sWOV1%84puc4h}Jy|^Tkg#eN>fz>k;j+={L%)~6Hom5}Szyo7I(fSR;cn%AN zVUxpn4iz%xp@RStHoFp|x{1E9?NQund(1CKq|s@qB=qq!5$Z^CPE|35!6c02o`aaI z)kXJr_ty4!vL;YPaRrnfn|Qr^4K;z;>aFy1gsZ9|qClWK{{V;qpXcM>oSv9c*npu? z@AQ4(^voeHn+Kzszt)}*$6ee@HKn4boYvJ-DGXwR_>OUwz-Hq)$-(_N>3FP8M?hx# zY{0jyc3hz~)h(TkNT%9(JOyex>vi&v@f3>{CAwf`h0(Jcf=D?BkU<zf=8#9XKw=b? zkd&7ux{p7!7BJK0P7{=K8V+>S8&~;(%^R!lx;pP}q_)+mw#gt_sH9@hxX+mV(T3rM zTwsoKo|=_4N$!U6=U6X`%9B3~xr-HR{=1FCR77druG0?kQ`6q`MHZygn(obej^{~W zr=~SEs~nNCRYOY}0`rG;f}v3|a}$M7&9u#7F{@0uvLqKXLsaTL`o<OUv$EJZ_-q#& z#{ebZ30YD@_bf?joURRCoT7U8V)&!9-R%9O?e+V(7LLbvGeuEVX{`^fszj4%s`?pf zO-!~ORYas1BYEU!TZBrpMCUUp#_^=~8?>@fggiGLC%AN?OVyN<qJz|aRcjxf{{R_& z8>bsOcN^i8KNBW*d3i`tr2>#chZ>;QXS79swFgmmCg)o{&$*qxuI*;4rKY#i+$_ym z9d$KM7ZFv_)<^PG(M-R^idD|!ZgN!gR}_&ZRxc!le8f<KTpo<~71f={k305jg$y*m zl!=*vB!(5MP}siUmJz_SAZWs*vI4k9$j(9gj~sMROZUG1{n{nx1dtRJ`AcbQxgwUJ z(kxG7Bvaf=MDHf`7MiIE$ptpVxzFDNq+!+)O)0PO1MwMB{zwm~^1g&`OYGZvcF`ZR z{<7_+EY}T7-B{>NMGY-nRQCH`^@r4nA8W}ojzC}z7tcKOULUc6N9Gl?*Sa^;Z%-KV z2WdDN;uz_eoP6~xl@`oOia;y?Qi-4egHT&Ys$J~(e%5*&yMtiR6wR%*mYFhY(P?D{ zt5<0y2^~Y*He(k{Z0@U7NI~_YnvPiZ`rA5|!S-K=$;2gCfTf^RvY57{Fu2t|TSb$z zosHljVL0?DQrALr7X;bzx8@cs?|tHc((AoB7Mr!!P+X{`)AB^7nzo`<jT$y%v~D;V z8QYJr9^G$P4}{&97&Ofx<+Ftugi_;Cy^UkgyjS7RXLybv<7Y_*eBvveWRk#By>m+k zB*JUmo`&@tw3QXtdwo}_v`I-it44)<0#&eb7oV}}BOm=c_G^gEm57;~p&*ou;DY2; z+M=4cc-B4=@xCYU@MCb@nV768#$*#7svj>iWzHQRn1To}k5v3PX)S$usl1(6tZbE6 zDoR_1)-cZsGeV)hR{(|e1c&o*N%lP_fqWpsFA7A7g$hcPlE4t@Q4gW86fp&kLh)>@ zM8dKivlpY&C?u8-Q9)*SnLZz>`}y$MRe7=K9ets7*KBL%HI>#2;>$<>065sOwDj{J zm(iH2My$gq;S@5G0TR3?uvq*A%!pDZpqFZ(irgOc{JKMOcx1_H`KVJTSJH&(r@F=D zo4qx0wG5I~S?FXjtgj2)<8c#^G6V2fjz{GmUOaWBVfa6Am3n)uSW<hP@Wz{(YH3#H zZ>%b(_?4)%NlwyK+dqvgaN%yNCP8ip2jx&QapNBS0gCLrwSo(twuaXxLyYEv+WCJb zIzjtA>9rkCU~hfP)CW(`dF}?f)LPm)*yU~2_Q`4%lHDN<j3A@8z_G7^5RBt2dhthU zCKYiBq#6Yg%7ur!N1XdhH#RY01;C@-_x1fD@8iF_HGQb~QTTzpQdZljH8n<(t+ra6 zNs=n7UMp-^=kt1(2*?M{3HCh$h2i8&Rw8o*rmxf8;I7Mda&A8vF9v|~6jA`C)IbBo zmX9iuQpIhK8&nd+DXA%iG<yNq5;*n%4tx+lo_h1{2`J)RNUnMAESIPcSH6w()<1O9 zEO4F<iFjo9r6nl?^AJD)Fly9t-J>r~6I&Y3R%mHnRV_1u>k^;wm0m~y=L00;t0bP! zNy70oBM3fpvXWFB5&<my`BzR+0<P9h#qe2^F{%<K0st&PDhx#&n~K?i0Ny%D@R47k zrO*}D8he{pUhWeyyHHm`AZBksEU`u4%Lqc*IXEnTE_$Br9sx3zWuXZO0fQHDVXwY% zhU0jw`O^gDtf@ef?_%!bYK=5#k2RmdW#XS-X-hVpz5%6cX}t()>kjEDgmkBJl}vh# zvju-4b_@p!TddC)!DcF82veksld!Lj)ep9@v2YKH@&*oU>`ESRHVn!n-<LbE=Sa1q zvE90dK+|YCn^)s3>nv3=&adfJR8}BRY74K2VU#;~*mmO{PaG;ru-}JRbb|NS&s}$n z(S5h#3dbuRg^;r0u8@;p#*7a7);UYv{VPMDyPK}`tpqJiW~HkOLWGuY!$^Wi!!r;` z3n>}<eR`)%z|NVCI5`P*3j#%%>*0UBqttzw?GNHOJe+<lRU^D~1QsMx<coc)1-}k` zyo<QK$G2Oq_O!U^ia_<(Q2F$|1w*zLVece{H3~>1ZuSSIa4sJ&5j-Vna4Ks{Gj*%k zpf8C0cyQhvSBG9%ie#qF3Aq659;`s8DC<3=UFmAAOL5S)i-o?X@ob}tTB>SiK$i6^ z5;#ha3<eIsFpIZ#(!d=uXAN2;1*t4F=ge$5*y$cHoh+4{*F4<6GtTv^#3%8mrf)ij zw|1)gWfc`|zTIBcRg*dcG;!?MzLOsdmdlKSLZ{P;pJ}*yr(x0ZlI16<wTIt$(*6%| zd^Qh=C{JEY>0zREHS+G84a9P3tF3hPH1_)I&7#<nNU^ijR4Sx<h#Q+6g#hk9Vbx~~ z*#0FkNhrgtY*>mPT68hF<31;LdPP-^lt^{~VMc-4gx=mU)Slkyj^Ew8H>i6-LEjA% zt?B1AW})pBP{;%f+nyDOv5>|>lH`rwJ8i)Otq%>su~<ndlt~Lop%>`<<IKE&wKyIv z#11sc3QB_%m0pcj)PmazIHX0LiL}~#m!<Voby|z}wc_1cmdLKJM$t=EOsJ1fRAF<u z9!SAP1m}Eic+LiG+w(txahX%{Iuw#}y&aiB>tj&D)aMYKHv#^mfSrJvqLrZK&8uA| z^>IVF1ctm$H+w#WutOEUQ0f+uo)H?uI7uEvVT=VT?d`$+dGdO%@NPZEG0XY*EIzf$ zN=GtJbw2F~;(QZ>VI_VW9XZ9Av8I%%-Hp!hj_Y%=Uv$iZ$99_k09|I6@#ShxC1Hl& z6p6KRts6Rx!;_9qGDgEm$6;~#aT2~*#0-Zg<#z-qDb19|^lLN0Dw%}EVF{5t<{UXH z?_Q4KK!8i7!}PpxyW%RrPoz8L+^Tv?DQ%XTio<fDj3UMiGNQ<=fq2+SVz~bR4l+9S zXJIjG#PFF5nsZFcl|&M1l0m7~!1N$eJgwUlBMrrIKPfp7q7;#53af_D+(mDC*(>WK zk)w%V7|us53BdAsJde|>@Jc~t-Pm=E(;bWXtIm+=4_*0Jo|R^f-Fz?UKH_&nQG6c! zI_W!~!%l?IdZV>bYpVphqRCd3nvV4g+!nlBZ*(fsQCCyTK&D!wBrQrE9fZKF5@-0n z!^oeAor+r2W|RK_DmB1nVw=*VlR>}wsdAAnN)K_!qC+^;og8USM|=H!?KQG(IcC+` z16Jw(0ENqy+MXIk)YTPo-LG0;2oYnFC}&wCf{s*zAvBOiC9G$6Apm0Eb`P)^PB$)o zCl;@ofJjOZLnMm#^-(Tzg%%n@dqdhK;FN?*Mp8qPUC3%NyA5dOj`4A5do$i1)6m#7 z*K>QB+OG9QU2Kj@YyG7X-&-QcxG`L<M~_X>i$_}$#~i919vGNq@;`|Eij%^p36cT% zYa})51qk{Yko>@lcV#<9F^-9f1!(}@uKc|ozi%!5=+y_}yW<w;ZqmwVw3U5y8fL@z zWmy$1G!ZpCQ>b=G(HSC^ps{60<8n?y5Zec5u!Dv0VKQlXpP5}$#<w(ZB0X4-#bXL3 zk(Jmmumh_K-^%d?cY8)!J9VwwPFkxk6_Fc#vb~nPL@H^Ts*Wn71fGxzSypuC3UK%! z5!AN>#;+M9%~E^LLjy`vTJKI#BQa={2}v(YmplDGOTrBoVwUA|qPbK+hJq(96y%wl z4i!hgkL}ckBMK%-QU#4RU~Xyb9#NxwKHx@XF&OE?`%;KkH7Hp(sQ&<wVM<)Zo6})v zYo1!^S_q62J8-~_(VTp~Njczo@y8ut9B+iF97$MQB3uyFKlI)715;@CpAF;khXXuU zi7y}v3$=eUGhNP&Q$Z26Wlc7wvDL$MwcO~CM<n!931C;soEM%s3haoSsKah!ouROL zmf&5SUkStsshkR^6qZv}s0epH3^gNKM{K?z9@KF4;U-{|nIpc1=1yd@fTbl&l%;37 z0aK{BNG2zLhVJC*dp&eIR^M<m*QjZIIw>Fgg~FSb6w{{%at+&jGJ(P4tNuMQ=1Mcb zrCdLQjr!U!t_vd~&kn^FQdppkkNBt>x=VtR#ry&2H8;d{j?rkZyK4<KchYiG$GWbe zVQQLH184-gjp{R=3g@bn{03%Jf>e@Pls+St<cPAK9%TYCE6Nfa)`6Ybw9%UPG0E?W zUilvP_aaNZwp#Vl6cobUXO4IbZn2-5NlaP!T1O!LcB%v80{Or=QNqh5Vbr5q?a!28 z-L35`oLumk1<@=J0B}h^n6tAQQLHHgow|@)^rhOiQmYEQ)de$-1bk%sbBux3Tk#4( zpTy)!L8;0O_x(Qc>(A-S7<hMN@p!3}Pkmtj0Q@%Ax&idKgLaNs=<TfFtEDm{Za8HC z07iU&KR!QRvrJs{{Q1RI1xhray_=VWdP@l)%fMkJ5X~xHy5$78zO{{O{8GujLW(|6 zsz3}m$NIXH;XE2<GG<vpixMgBnmwWuv^-%vHb|W`+_9@LR->ihv$vBPd)?U1q{O!R zlH1R=<?uZ5fyW(be-C(M%r_BPnv#MNcHC9<cdJlQqsxEP_ZY+CJ1Z~bP0yNYHERWE z&e3E$dn#&+&Vr`3QdColTOE7V$nJ(Bk=Ux(%7Jpq0wRXp&g_wqj;?%8QWu9*xBmb+ zci%894z@M)ddGw?{4#8zD-|?%W+i!Ra65$ZVrbewC$GKNL#7hVe6z_>XueR^Lkta1 zAuSk`7kGma%uET&5ynXN2dSJN5EEq}gtLQ7Sg{Y*$72BYgM)vkaZFYamu25kK~@KE zSC87d^B%I)Rt-@{5>r!4X-c50oIobqx)}i(4y=wb&Nk;KJvO<C&JddE;X>nWqA0Ct zLQvU(+i+W`d$+7RcE;x>o9=d=O52=P?L9Py_nDHVpn^(u2O?<!1SiZ10ONu5&ry71 z5>qh+$Q1?IwZ9`@q+h!&!qYnih)PiN5(Zj2fg}RJZ(UBXTdhnHU+y(lxu|Nb(@iUr z78PZT%2*XY;|D4?A3shy?2r_apc<b1qKl4JMJXx-F#xzX^S;&Jlr+b6gi_PhPfJNt zUp#U%vK@vzpOr8H3KSE-89b7F5zJm8AdoFa-)|N^krt_ln2MI81|)0GQri6tNtQ}y zzUmz&u+q~<a5DM{=tMWj&OuGY4cX2Ijt4kBMc|f9#RM0v1J;6|8h{?P2WafF#ijrN z*G4~GLsxnd(Q>s<!M>uR(t3Mc&|dUyRm$P``-Jq=GPP7R_Vgga=&{5Z<6s~7m<kC+ zBXN8YjlxVy5>lFwOTGL+xu_<)2$7X66%m@@nDZ5<l`qOEw|e`<XS36HLrK|WxWTON zu48)82d8eJl5NtHyE&Ff(PK~s0<#?W2hg7#J7}&OE=~qX%`o?yW*yh@uHrn!{U!DX z@k^b@6)PysMH;ywi)zC|U|dBCoKk)&(+a$9H@k0FSKTH6B$LT(`~5*2b>W<LCUn_z zXHL0my>F#!xY6D{`NPzHQcxP5TbQV*<yN;z@3-CX*18VGQ*6?3*sOoVnn>=nt27rB zl&D`-XO2ZAt~gRQpK#tfqjBEF;4xUFPsHXV019YO*KDMH`9<#!+G&#T3c?vEDFoDm zm>alItqn$>dz0|LeAIsyTBlQKABb-0>(1b53w^dQEY($#M-6n7%&>`M{4$|siI_H9 zAz9R%0oGgb21oiDW#V`a9SEMa5>yA}N<)^+;?ymri@+Qx3Gx<I-a8WZ4*CLg2KT%$ zP=3$O_giQq?gos$>AEZZH1$&2Xrz*8tLkY4j<JT6r=ng&M9eRM*}*DEzz5@B0>@4~ zCy2^XkukFp0haW7I8s7MJ@k;RKrj{~flFMh>SY$wtt<6<+zpK|)_RKhr?h^dy4C(7 zYxVIWq-mBHiYsdskxeu~iFb7Q^T*0Y8<2DQbMVU$tX3U-N@4<1Xq~=VLjow-O~;+0 z9}_sp!wDc}Qq5_-8NV`)mTx$#7psL8b$vC;I?39-qYYTi0EsExNkv8R!m6)VZq4AP z;iN7?<$){{kiKG4d&H=<$tKT8itS!LMlyW7Kw6bKY5w3T11geogry;z1c3Lg8(2TH zYuddFXtYIOx!&Tu#a|^w!j^B6j#OY5(<ptI4DT2@Ap3N8;llgssC_hQ^|fFuKNK=b zCLkQicN%VbccY>jCsIS)Eiq-SvBxwv`RaF~R3hdIlwVEPcs>S4*zkPy1IG-U5+-pR zKvBqo>`Rn`pYrbeRp}Z&1BiUtCUFb}cWvoM^0u3pe&n7y(Gh4Pc48xP<7xSK1D~M^ zNAf*Zu>H5;({ROXvcbx7sH`%8Da%8dIg;tUL8ylZ5JHkwn7*UP8eE;M;tBMzt)Z`D zLsdmRKy8yi^#U-<!3A=8$JeY+`h}O-?lr;253~|75|Bg7oG|K{^4DPDJ>-*OlY0{N zh^{nb&IC<Ll9uj}m#)pXNStWdB#opeepE3Vg9Ib!Mo0Vl{9WUmE@lrEGYYpENfk<x zNYLd_3J*fYx?U&+B?$;nuDW=)=>hecE=XsYsOxJjY+1Kcux+YOo(TkgpKqsHhXweZ zh{Guoari{fRImUgEVcrGoTb=uq+R%DZBmx}M4+-PrmZP<b3vps&)d3)iCc>}rjZ6C zG{ky}asm0cDtYISdGpq!zl_+~NlqeG0%8fMQ7Uf(lj%_wh4%C=R#POgrB48jKyts` zwdedneZu$~_;A<suJUM=(l@*?{pVqm@UpoikkZA244!k79(eLPjo_cvFK#dx#ZMDH zWThYW<|L^gY!ty`{{X_SqB4#woIWLMnv`S?>;nPPf%k1HW07AFzXqD;vX`CK3!HlY z07z6y@kLg#ifN!?p!#(PNmXL1Fv`Rogm8H4^+(}PX(#rNfMGbq*&<n<3CcUnK_ZUD zi%^lRVgn!9>9S=f5Q`UL%z8JlU`w@s2tN_s?5m=u?YC*Q7NVz~Q;P5s3w=3Xc`KxK zg+>p6#km7%JzOVGmxTorO-_N2pz<WJ8^EdIa}#jEx_TFXDrs2kUuC~z=A6=&i7r18 z8sehwsViw3IPF?`tME}<CEN)nu1MI_J~-iHP!-S403x-F#!s5dNy?F6!AHgR*hGj- zl!i#nYSa&_+rlIpkHR0qhi|Bo({#|*Jwd1@6%<r-+LHFtNhHf4D<|OnRAwHY8)!q7 zC9#a+v&84(kdu){mLRb9_VDW)Pr?q&U^_wD3_lFSC?yK16eU?hl!tKS3zo5Jv>9o| zrneevH7y+_u7;W=NTG%y9X%>2E5<lfP`CgLW1ntni7enhPpP@D)<0Oz3?T`TC}ozA z1unwL1DK`lK@pay*Rkq~pTg2rO;4xk<z=Q!ZS@bl?mTW{IM0Q^_c=~05*CmU<O8fr zVenx+;w$vzJTLr0du{5bog$7Y-DPf04;d%NAL{nO>5M$lOre7{AMfw$4kA(%qO}h} zr@f&aPPLTJQ#zAS%_EJD+4tb!A2{d7PtKa-DXYJ;qkE)DOOi5#np^jVo0g-wO-o4d z)L^+}F~-0)<vfN1k-<~vrj`mx%U}=I<MfD(AeNb{L9SoDucncfoz+IxV2ZL=sI^yA zGB|vX!%S6n$fr30{{SO?h~MEkXa%SQJ@zNpM~Nb$W|?*X4xaW9NmF?IJ;st&a-}Cn zZR7I*!};<(&rBs%1xACf-Xrm{$U?xgAA1<FzYE_Ez0CN6*XH%4Z*yo(GiR<9b^5Vb zgKK19>m4WrdexjGEP2|TG=wh=J;69GD;+qZM|1;K5pSh=KUNiwH~e(?_rd!i!U{2? z=He!xgvzIQ5X7(kGR;9J^I0xxJ2~F3f^UaUiTeAVr|zzuTW@jeW5VjKKI?3})Hd7+ zCzdrZ)Q^3W#VF1~?HqNuaUK&bXi74<4CiyJ^|^02{8sF*Za9Vq5V`ny##vGBq!$gf zXhwjL<*y!YKWN8hYd#ol@^&7(THm{wXtvSO&2GC@QwR+u9FAp%pV3!zk6@A4Y-C1v z5~!KlD>88ARKqN&mK1u^N|WuQN}I<Q#8xrW5|khEDW+n~OKAGB+j!zjPQImMM6a+q z?89@%k3Xh*G|MbA8}fvifRdYKwzX#T@rmc*tj68Xq>@%?5<6W+^}s62l*l8;Ad&Y6 zqvU{>Q~USj;}INIX;iQf3x*V?=DlOlkHPQ9FL3lHYU=b4#El$!ms45ZIz26Yr=9B> zy5GBOaNRvM5^ttWxSBoBG-@L|WI*k1!s7e6RvMi=_URcnX?rTElao6Kb$(&C<Q+zh z{{SPb9{1N;_Pb1cJ$D1+1GD|_xN4sCXuFjryNdBsB#k8Z#ALUnzI$0@G*mTo^+Mex zJZg*SEjyNZAXz#kNn|Q$E=6v@a%+L5O>Yo5Ia5<TI4S7J3#Eu|Zu+tB1%uE59z*+! z+}%~(U3;i6HCD=7V%~0b73pWRQhGFSJw(Pvj-Hh`#`Yut2`B1$t>WjUGL`dG$Vn<< zd#Tir_UFr#Z9F<m^huW~4yFtNsXIG=9s)W6+Y5wUvd~oX_m5Lct8Z}7s#59N5gIL$ zofrlDeR6{G$Tw~DAq1cxJfMxEI^Z7}`%5bntQTcvBw<!)x!H&+H6~QBMpPP-RYe~1 zQNHA!5EF#_PZ^z_Vnegk0$2in$n`9iXE9+?{<mEpp|w4_0TnGwHE<+wM9Uah>Px5r znT`rnua@>bdxzn4%u;akCFAh|e9}b1u$+NKX{#lJ07FnYxr(gJVpg*@TcHYLRyXk^ zyB6_`p}1;GrCfA2SS#x;69SOZQ=?5Rqub;eBz^pO>qf?7BuJI>XD9>>O8`9M<BC}U zY17;FwGPb|gSLMM-Qey9%KNSA$J_cN%YHgzG>P^xp$fiFmL7Z#vK~3{qld~-Mj0k^ zYxz^=Z{Y!Bc!K3sNODEK?RJUw_xm<zuQqzSO_#oRX1><a%K(}SWJ?qZ=*Z5W#b?MH z@r5He&yJ~B{{V~l`SMjMGSV{w0d-O7$a+Ks&d@DMGSr*D^QZA0M5gYgx3_j17Mh{$ zrP9-<?Uw3GU9y_nUkvo=PYf#*QKJx~VmQ!z$}^9@Sj>zd3(UUp@V8RW;S_uq2|`p= zFy<%U=^ViL@sGP-5;tuL9*C~gHvJ7nL;nCh(JJK6ce%q>^-)(oz*1F-6*k5QE47;} z4g_y)6^ot2mxaIp0)rPVLw3=@YqyMTp9C>n6NUDTJV%a|WJ?rf4v<q+=hdq81&)mr zmfi3(@pY*!(CS?m@bBEd<X9YfpN!J+Lw<#FGyFqCB*hQ?msLM*yswVnTtZyZCrMg= z@KFNpC<yU?yndbVkB$A2pD2X!DNZ*fC%_OZ($L3f_%`_8y4FoSzu=$Z+Lq9htq<eh zL)T$h$-_N6Mke%vhCZU91Hsxz>wWCzBL#xMp$S-VRR&S-0bt4YX&z7QH^z)MJB-Yh zvagk7DS%5j7I&}wi>Te2Izd6$8((8J4Q0N+Nb1WLxTmLBtCs4s!8lfp0dGOds0;#} zh4N21&s7XZ6rDU39SNq}IH3oqsEZ1C!ZB&hDM?ZeqM)}u;xg~2bhRdkt?gyIO=+vO z?)O!2TGoPSLZsAjB&#~dD=<};Y@m#sjtS$VI7S9mAq!OHFafE#vxY7`m{t+<6oBNG zAI-WAU_-n=6*lb=L1UxSR$BXw((g(5@%V&D9)gxwSV1XMya@~JaI6ZIR?p@bFNAh! zQ?m(*WTdqX&bnEzs~GzfuapL4l6D-uJ!77l%Uj=joqo07uHvTUt7<8tqpfzQf*56) zU88_Rk?<GOFyyx(w{whj8`>@(KZat_io&Zbl@JIH?i?{B63kdtg(<9UpAK<241Wuq z!|_sqn=B}#ny@V70>#0*19-E#qvAqt)#(o3=zh&mSzV2$3d;JrluCjokwIl-N89R_ zGM3y)RoI|?Ej%l-$WrDmbBfx|%~QWxSEI&J#xdE}Co1LXb{Y!Qa?op5h@U`wMd*Id z>F)1$W4N&XH@LDx>suo~<u7#-yU6tMg#ls@DTU-Cg^nUdcHyLWzXF>+Y^**X{ktAR zuBUCHsoD<JN#U3!;_(_#0Yar{0sY7Vu|RKs7M<+XmMf*&>94AFC0a3%MRBK&sxUo3 zcB;4}AS^<uISK&k`-I{0aK$K=kdbErz4MPA@h&Hb#4SpmvP!fhl<z^Htpf^PAelS6 zbGb~_l{!+2mDsCDEud1y8|@MKN7n-Ye=e1Xz)86-G^3vlp;#_CiIj%~_#J#8g4Nup zE|n%nd$k2ZlPHzximW;0kt!2`_s`d$=5U-URLZ9R0BhPOQ`<f%FS?RQ=|{ay3{JXJ zyuHlr+*P-I72J#Exo1e$R<+heD%1cSlt{!l+7A8?BR)CLP~2~@oFj=LD8|YY0#2_& zd4m4{ZQ@Ucd|2ZcTHy;g*fRoa%niJvL+w|_k8-Tt!CUumsp>ncL2)rt)Ka}XUy4F- zs?=o=4^RLX(;$Z*L(f%i(|jhu&f)(6fZ?R!@;OTrmG)42jc8n6CA%&0I}nU%$K#Rn ziogqeuGa2>K-&>o);l3{(Yp6o+4SD7?SE&r^w*ONb#M!v3ALngum#|mW;^i88CoE$ zpt86ij&dH!{4e9PS2GXAyRtfhNfuH=hB~EA_XWX?7qy=hI2@@-pTf>*>8Jn@D|D#0 z<u+1kETK>$w)dalE8+*dgHKn@+ITIu3K&+5T@w2ZIY>b#(|G5=Dm_I3kDp9<1m}9} z?`4MzQid98l!ctdBqr^5adHV@DoNHbj?i}J{YFw1h?JDfKnVmD4(KHQQW@&h(Vs|l z_<8-FJ;&|klUixM?Doq`YP~h6g*6oEHm;(Ap=o3FeMq86M4Q@AQ3#L~c(!jtk{A=m zI3_0;q|C!j`D;Uw-4r?(cc#JJ*wWEBJ~t-_K3bViacT!~YTo1x#9wH1uDN+EdSgsz z?$PL8-`VZvj8W0cY_F-KnPfPM5mQ4fFhGkOM5?E#?s8iS3ufu~L5m=&W*|&bi@7Qt zTqREDQ*E}1dEK&<qm?Ns7GTGtl9NHw@nO4t+&<h;=~`;8?osy!?cEA_JzISp{{T$W zTWIkjhV@=r8EXTouqw|Ph{^J?juj-Wi-YzP6M;xF=dD2{@})%ycIL#Bu`Lc^`)h_$ z0#zX(se+cUCQz0qB5B>ZaH4v{=7YP~?>D=<>76t117N+^MlKqfx`EO=oh*eHBC;7I zGuJeGq{Ol^tiCryt{blLP7sNpPsPbdRY@xWY+jB^j<;*Rl#8^UJu-eVI1DBsc(fHV zQ7u0!Fn{qtLxQ1E-E>&yt3tZ7wjT#HI*7^Hp3mu8Jwp{`O<YwrI~)`isVZfqG_LOx zZ2UxKBFpInDef4boYi>NGYyJCl_;#1X9R(}0H+tN1Bz6}-Pv#Hr;Vv25ye!DDnH;x zd5xolut{sWucOBZJEQP{tnR(+t*_Nuzf;|*@0a?dtkiPb>D*S=mj&UD2xI~(f=F2D zpJ`=PR1JkvZw>f^@&a(5hRZBCNd?1>qW7cb)B|YOxZm_sg`LBX5aB#j%$|@IsZ%9{ zoQAoQu)&2Gu}J>_B@Hb$mC?E{MpEjH71%8UrnT;(p|wy)_`2HNwX(G8;&v}F1TX?E z(4dT|lbmB68jdb8lYV9*Z}y8?sqz{E7{0VSMPg55m%#AS<e6q!B(sJpW&o87yPCUg z6&~keg57em(oJ>zSf{9tYT7o9)>;R6o7Sh3f6T@w+?hP&XOYRWV}43jtX#$lDar}d zQJC+cZcz)w&L<Ki;1K3mm=vUnvA(S1`G}GVYv!c7TI`maZEf1Q!IfdBh{(g7?jKAk z+8vJ7`Ob0&P<UJrog^YsUwgT$7uW+*)l~#fajw&&4K+B7Owfe>U`Px(f&TzD%Pj`T z?<fa};?>&CC#7oi_N?wsxu?<BS{b3OijLJ1RaG)8E+c0lP#4JjqF;VSda~eqJBm#8 z8H-9~5&3l|QcJMtV+HMx#hwv0C{GN-saT{f#KTEoUmyf#SvMw!P)u3s?(I$74JmZe zP<GCk*lrQDPYu~{D${b6^%s{T<rxc(00XH$9obwIvdBR!NvSlqzEPI(--#Ss7L_J^ z#HA!LN>V>5qg?N28@-?+%kYb)^<CiHD=G9Qo~CH_iW)m?l)^Xv015S?BO~7>Njc=4 zbzxTzmczK?MkU_cx4K1AUl_$pthEwpaFPJ}`7`el)x+RpOX-~*P;b`@O%<l7qq9P? zMzRd;{vPHa1M=sbWBK(3KeJdB%}n*7)|(PZBgEgUJX-Ld82IFjJlql|yosNkERd6H zr8LlL3}K-D2hVU}x!xew6%>~Bk&=#6Yb_N(&&uxG^6{Q9V_}?dR|Bdc_&vjtutJt{ z=>GtA{r8QYfAyQ%NwNYa%92YmRg{Kpk_ky8V?(ayL9YFly1M5<UsIwxx210P^mg<u zw<-x}5??tUuz3K;0}X;d&LH`)VLLJSFwz(fL6m6BNhOYjs9!-4vp=YR#;h-h$x?_6 z2~a5&aza2)lkCN!scKJ#j{EjLy7=A9?VhI77MG+uZUU3>Hu{$h$V$kILU1y6mIt0x zW2qh!Eh?A<<m6BUWhX}j5Cso`w((y0r`p^n5aIH_1wZGE`H9Lx!OC1SI}HnK2fqu} z#HoJa)lU>Ky}P%1mT*7-t4T&_#t8Q-P~86j<<NXZa^y&qpj@tS8r>{MDu-@SR9Yra zR}aFhQ8M0~=ByiQM&>u9S1#*sy;W3Os?wSnrK_YlM=}V}X@liS3+#tE1OixdfJr@{ z64NNCE!ZCp{{Sc4KkpE~667ECdFViF(@iWHuFh=C1M{Rtnz1SLX112s5d0ksj*$o= zOvaT4HEqBiaB@a|tVstvNbx*4Oyey^q4o0*`&u)O6*K1GCF7)}iPn`LroCz^Bjw6D z*7Ij*rJTbhGqwl5MmYD+IqI$2Nuc4%b$zHD?^fmmylnpf46yBt`K@q4Jv@g^TJUXL z^;9&}l$FrzRFsE8H%kyMa-f_HgY^g7>&&(uSX7{qQ_h-Or<725#uZD%OduPm_&+MW zZ>fiBNTd_cno&J)jQ}YQ6pW4&0RCSDll8`WhQx%Bq`s$mF{%3oq*dlAS@NjnRWujn zYF_)^4(V&JRFhOmai{zNl0mjfK1Im(2Rt7<9FH0E(|CL=l%)Ksw%6<Y7&1~6ke6^i z<myR!opjbDn6+W2lWNdb*<+THB`lRQeCQh|f^u?l06Z@QbI}D#3eE`x(dzBfs1SU_ zN+|hdAe*~$)290KiVxqLoi(qz`u2v7_a*i?t7W8yWn3frI}oaXF|<e>bM6$MbJyLv zGq~RyoivW_W)Aii=W9f7!yFb5fOc;Sg(LukIqsGv-1IN27*nA1EyK8$tKHJuLl5uD zXQx|uYK2Hlc_oC4jBa2<1I9={ezGr!IN5v=a<EfX-B`^qwO>Xq3vbHzjc2i#nVeoy z@shMwRFdMs&m(`)o;2!BJ+3shxXFLhcN=93Pw0w>h;%0oKo}>%2a)>yGhN`=><%@5 z`+31iVQ@jCJBHgr&%NSr5rCN}W>mC{+f|sqG7Ik4qr{WgUh-Z0FRX2P<4(<XS<;Sp zBuY~W6%omRW1cBM5<=Tlove-s46Hh@_TRIK;<A^9o}e6taG=0y3Uv-eK}mYFO>k^x z7Fj671$Tj{B7|AN1;fyn2k94ji}s0X`kg&~rqP|2(OPp?Rs&O0UHjIirWq@JHB&Q2 zQ5SIt00oA2jlh6US>F%%C54?hCmV@e#7PvONht=`Cz&i-{Gps*8knGv7=fn1`i9=R z(mA`Vt~!sseZ}p*hV9i2RpR$Nl(EsrPWz;LgzOJ2xaCW7-UrTj#~-J70fd#p(<>`U z$_7(rU>dcx9_A`MM~IV%Oxa4ylA%F{U2{88uhu&QqCOKlDe!luF5TYl4XaFR6|qJ5 z+qSCI47IXJIbaqGJ*=<c(n)1+PB~{XNOyXLPyzzDp9`7z5OJzjb%2ygLnS&eQiphe zDnMaIk2{}=JWq@9Z^LlV6NdzqO?O(We5}dMRxV3_h-Z#Y_rp+HekOan-45gF3Y50% z3(aJgs`@G9Na&qDRU!#nX<-_w?q&ma3vr$#xHbuRY&s-N{G!a>$tO)bBYDH{`7<3j zWx9Z?+O>TNy|wEfejl|Kn9<YKTQ6FU%@yL|Aftu|<B~>#Rc64FPGdWcNXN1AbB>Ol zpYvDMsrJ+B7Cy^NmmxncWWkwQR8_TlYy~M-i`}YkHPsfX`l~%Xo(r0kl9DLhmYx=9 zh7s^Hw{RqmNIQqqtS^Fg8crXNESZX0aT1i7fn|!!wXm3yA1Me|%O;tU5Lv`pmw`fY z3tDPamhs>CHt7Q$p6$1E^(LpOyY~-JX}Wz7u?SeuQM$~MGa=19P5xVqsb^&X{kqk$ zd}>ss{JB?V+<Ek&YeY1>ROu5HnO`y30Fjly?j^$uJ$8%V+iQ1h>=H!{@(Z2OrI54{ zR8)^qV~tq8Vu?!bAZH|P7$E!giuRwdJOhjI6mecBgha{6qEw*l1w*+YxA?emaS>cs zJi+rQ6{|U`=x@{tfm4(`3E#ETMFf>LJ5>&v)9^6##4s#HIKVs4Bw&vvS2!Mc>eiez zznMgg+pzGh->aMH1Yz)!#IR-^jo1ea<3Jvc;2l&;2o0+ExQkEQPMp`5_SJS3W3rA2 zJ!gQT_{QL^w3cn7X#)fY$mq<(2F}4gBDUS1TeCF`HHg8kO5Rs_LXBL`7^;^f0{KV^ zYK+v0qSJiP8p_iJ+fTzAUFsxRV44XddT=%tR+1u1WQIN)0|(!Zn3pXm8Cv{@+-}F5 ze5^{;$)b~`+_u(nUv-G*SZT}lsi%8g_PXD6x3SAnqzEC{KwK16%lx2u$>4oDbyCU` z@|!3$+sfLzi){f+#LIAlP%o`D412pfxZ2Rx{{R;{yRjA<#f!N1*Vb{{VIR8~*TH6~ zm6stBSDAemQm_92Nc8uD0AR$25o_V3%mF7T<kdd;KE@lz@QEu&;t)Vms>)HyX}cC< za7Yb79GUNr?K1e2*0lF)ZicS2_7=j`6Vpi+oF(hqdE2^6goUIhYVAIVh;fnUlT(G2 zEg>m#)_U7_py<yOr2NG%<}8e&jwl5~GgXM^S9qtID$%Y6r?&Sdo~E-=YD-<tn(6rI zs+xMHaNL$>CEXk;1&R4XbLXnoHy)XYQWT~N=~mlM-Zb9COPRv)(l~|`fihBo4X6Dz zEn`w%@z||ztSGDP*85(Ulr2T-oAHw{D#DtSrtpqiJh4)zj{(2V1FY_3_E`xf%{hVg z{&tVo&I84z;;<;jAm%{;ie35-evw%J0Dat`hA<YPVmKKb<0NB)<BXhn`gIxz&TCiq zUrz>&3lAhM&%YnBhnh>>S|ZCVg$Qk|0V+n}oN@1w{rZs$a_0Q~+71#D`CG@O&#?*N zz0$_09(g@bJw`F(34l&m-~rA&`1|z4#jfJ}ect_d+7XD7M?O{M&*<@#*Gr8|Ow%{v zmRB+$#x|3Kxc>l8?b3c|r~}`N{o)KsmnL8%$HUL~j9$Gzb=1NoP2Rpv_8B8*Bxm0$ z0Q%%~<jH|YzvW8(Jt3vbTIPbpCA8h?%l51j)9RV$vRf;jj;gw)s$+s>dA%lB{Y3R0 z8Gy<3_b^fZECYago8aA^n~Ry2S!4lHIqdf!b>&#ZJ~(`L;oX(MDJWWIV%87KKxTF| zURr<)U7o;*?*9O6{{U$3z^<63w)US+U3CX(G_?eAQ&2&8)6-K@Y#_6GlS0!Kk+^q~ zTyJLFSdww*9vOhc$&{Hg7y)P1^>cHrWAKl*le<C2anpFO7Al=KYJpJ%D4kiKx{+W2 z$^`{teyRAk>`c}6yObU0?OlGFk}0a0=<je#I;2Qkv5Yo&Rb#Um;~q#ISQEp_rDZ_@ z^>O;pMnjIp<8b)2s(h8V{U}i{prS~3rlPUuW8?ecH9jOGzxPI4Qdw=6rJ9Zk8Cxw4 z3>)L3cO#XY#|p~c!OJM%4zamBN=`2ltVR^(K`aj>(fT`^64C5#CD?3#0ZKS;7EjBc zkqcTw_j9B#EokU)39VmP;$oOgGl^XU+lwh2f^(b`;Aj1q>cG3w#v!oz3#Hv#k}s{? z`Zp+?J1Jcyw*9N?C}v11sr2-$l?6Zw##odv$Rmx!@(<{Ib@m-b`i`Ek&OHfHOr+bo zAAU|6?@gnwHFDF@R98_h=~^db9;5DD1GgLi50AI<>d7y>kfW~JH!p8!ttB7~mOg&Q z(OP~X=<K?iL+V{2O)5vKZZyi2;wRb>mPL^Suk9N$0k@okoN=C(b0(m{i$YcgX1`YS zsojXDIz<8PpKUaTuhBOPq%zge_d3Zmtl~<7vC2|S@F*iOl`0)eE*LNe$DGr&eW1o+ zu|iR-iGyI`jCLZt+i2eVCD~pZjNyNXlv@!kM4GTA!!uBZ6tNn`&$~rttJ6{5En0ep zrvCA*Gf|LAOpFHgd>krm2cK`Id}sJ`GF-qGwhu$G+(y4TP6cD<B2ZGMXsZ%#`*R}0 ztT}u<*6R&v+e)oDrmb3SDQtR`R#~ofEfqaAuA&LOMl*6-(~_Pg@$+v_etPxaWV>yN z;quclY0iRx6QNR)T(ud-V?#k!G32~nDpnm|Fr);6Ko_y2QlqYw<rjv>dbeq}qoD1T zI#*uIB`Ysd!Re%!V#LI!EWl*)K+hQZ^-%3U8OHHx=4APdfM}3tIzQBd(y=4JyJ7fc zs$}UQYBtPON>apmIOh|7m9|THFx20*RpR+nAAC_6-C?I=;3OzU+<4F5&rp)EQsssN z@A~~BJBap|7m9GT0F$5rBg6~%l3FI2t294r?Uw7-v)0L`LzeyD4jy`%&~O|>0s>t; z0>pY_-GS(Yq@T<89uZe11tcU`RkW@74zw|*tGU`T^G?sFZ$|S<ssWNLrWpG<Rv`(; z>POe70157xeKOXvQU#jZYf<b#q`J>pQA<g4(|W&A>FbRztN2Q*O?0%g)2l>dV6r0z z`O%#i6N0Wm9Q4}dxMfrkZ|9XM2rN@te$}_@0=joh+qBKb-+s~Gz4TS?o+S}n?Gs5& zJQ46fXNi^YGBR`W``~A!u0lu(DdqZa>3CXZvWgdP3i$nvwzOaV+I)U|R`w@W>3goV z)RzwY_OIoR^JA-=Ra_d7kWEFtu0!f2RAHHfY{n^61S2?gipFrDw3Q?=_u=${a5JTa zvX)1veIHtpdQ$P<YmJkzzZJc<vEFO#`cJ!Eytu+-w%4*@;@rP7#VFe~G|S=Q%Cayj z%g0oFcL|p;wB)*ljfm)P>d|N5{BIS2ne%Y73J$DjdWwQQymC|G_x3Eo-rE%feWvf9 z{Fj>FQ#DNuuT|N<7Xc1|eFI%eRzRa@A)W~ie3Q`JGYdZhFs@Mx1T!d<kR9lt;GU_C zKZkr?oCZu>pr^S>W7Ul<9(_J8z6w4j{u}R_6Hk0YQb%plRdEe9V%7R+t>V>hl@;ZR zmZ6nnk~ySM%!w)j;~PtCWm`uB#YkF|hA3K#kfIBnSb{+<QUyrTCwoEhMF$0+05cJ| zA+*dzXycgZ_2*sY4&!$E+od(V9opY?rMRU@hw)Z}9Q1D$MAXc(Dlh@VLdWU$<Eefl zz@*}W5ipu4C~V>Vw}tBz?g8=Bj9?bEBq2pfC@va?{{WSX>JImKj;*q4$?1jmD&(z- zo<(-2E)<pIGl<SUPyyq}{W5x<{4mNxQ|lFO9q|u|{xK7$PAv5Y@{n&~cBrSUSHAmJ zU9NiM+pe`iEOD;eY;ieM^vF_O{{a4@5%U5#z{eOR+zLoVnL~enNF%qLb$Gl`Il+_% zb19=1azj3C-Iy~{upy$JovE(&c_I8UU^oLIeq0=%d=dRRdk{!vgYT45<xZAPSNzXy zM!ue~CtPK=R@U2V!iuQ3Dc<00_Zj^U`uc4tQkeaku)<{=ge6wgn|}N4(k#W3Pfa$6 zH1_*#%7&6UIHWU5fkeg8iTB~NyCCz(<Ej=WdW_{RVu3Ge{dq>4D+rX~#H5A{9{W-5 zUux0@J4x{G-OlZ63K~5fBo+!8WJv0+)viT78en{n5`!f~g-%2$rHDBt7cMPWOkp4d zn_TJV=wmwKSRccOoWLCct8UKVR`e0*tKrk(N3;ElN_)+wifvWg5TC}>Hz=bL#z+9I zBq55^MoxHEX%2aF<D+FMWg$Yxm%rj8W`F?`1vcr-eQg)ZO>1hcGbB|sA~qZF3cf_Z zc?>a-e2jbe&U$Ss8tbG2w3Qz1$11>PzWt!Rz0lg8uSUH+ZOZvhg=;F~h)WH+ptmrC zl6N4>H~@t^RFZcEc^F*D&8NR&5%T6{2~NJ-N`3s`X3gKd3*9NRdel}cvN;7Q<eaMy zn;{8u9e_DINpApS1Es1$uWIQZB{a&+`_vCGKWLWTve#8?{oSY5-t-103mqfv5s(R7 z1pL1?Fx>KaA76qL6I=VXjSE7vuoi!Hj45=qcg;5yHGN%Kn`=m^IuR^Q{&JlCatPzc zws=`7CC9(p+g3WI<ya?2Kc~7yb?$d*D)j^qTCZAq(XA;Vb(W5*qL{pt_{R&gE~CNR zn@GkoLFtL91QM~Cx_jjhVX>1Ws47y_k~Sn7>fnQF*IEaPC3)5wx3afdi;BbCjX!H# z$0a+?*Q;Eho)M|1lhuIoGFC{iJOVm{;{BMw;u3QVrKnr(!N(&TZ(%+=_Me2!%_?!9 zEiC9-ih<-DoP=By>wbTu?+vc&euBQ+{{Z8E-t@_Ay=m&0CTbl)2JNU7ok)(Q2p*HR z;Bkg+qk868--g)SGL=6H(^9X@9;zJ)U`C%^W8EEt{;>No#bhG|#xr0iq~=7%DoRaH z=0GPRs_7|7tp<j|$=eIgxUH63cB8ymwZ$ZJ!Kd|VLSeYkl>i$}{v2+ZkQNI&eArMJ zu_$DsVz5)!IcdyguEAC%zs6wn3L}wv6X^UG72wk_a^}uLC*dZ5S1rPjuz&q-9a=uA z4O)O0u)le}20PmouG!u@T|C{;H#;pg(^A{wM3&=0R3(}gsDMVOv(iYgf&w6tGQmo) z!*wsk)x@W8xnC_RB_+;MDYJm&htxePK#w$bcemZ2!1m7{!m%qvoH1lb&XPbhELNhT zlE&&RPNFN_g4GSGk5Jm}lp@hqi%&INw(4xjF73oqM#STAJF;1Z#USS-4z<6B_=Qg4 z<1k1d-**o~b#({UJfHneaLE{M(PqQ1GG-ydb3R&xxDLP?C<bF*u$J4~I=hX?-)@vv z`Wc!*DD}ftRYph{%#sa(_#A@g{@;Ku>p%tR=-xrkoO$B}iK<CTfdJ`HYtF15Cd);l zmez_hEb<?f<zcwU0O#ia0FQ2wElYX}`?NbMT)eqdC{4UP@6m{fgH7G3XqE|?f;xUx z&*dC*k8m(K>G@?q6Q=F1_cip|1Tsr9EA4>j3(~cXoz(c2uF*Cp({}l(qrBW{%x+ue zXlY4c%*b*HJgWPCcEQ>*+GQ2@Nc}#K15QzwQt#@1hT4c_(0>&Arh+;sCAgcNAV}jx zs6fO-FgAna0gpNTI&0<TqT6rZ#_gq{sTgMt3ljRjoBseNC;Lyugtn`aOC175Br+C4 zvm9<DjAP%&Bj4-OsKbKg>t_BT)Pw}SqV(R^tX>}2{9$R$Q>LY@ik^B}*n`7xrFj-; z4<DGs+?bhiaCc*s_8A3Oa6P|;uMMtILbAJoV&rtb-poT<ip%iZu4%R1ZjaJ>f3mv1 z{bi}K%X+ucGtm{4gZ}_MqJ)B+sQxr6NCfUf)E^S;<hjKQO)@AbHHib!#R(BRz&lL5 zCMsSce|;%nYu?Hgnw8MGZ47cxyFU#2r%h_RT^6IeS*WC1SnK1Kk|`&qxl^(jmRee& zwke`JUzO*AtQCgPFJw4${1l}L#1(7_<V(}`=@UJ_;}~u=JMmaTLP;m5<_zh2f~O+z z!K(WOO{}$r!&Fvk87yM5szXgr6$%3ZAYks5_8bKnA54MgJxuXF88US9KQ#`WI(-@` zoIeaO*gQ52j_j~?AQD_r?AN)dsAw6y{5spe8*Q2`U3x=LOwk`++@sZO<Q3ZMh4J?w zf-%oQWB4e<A=138Zov9ir`jx>1GFmP(=*FXa8i4(zOQFD)YJ+wkJ`7hI+pi(uefOn zTTR;9t{l-c?dzZ*lk!b~#fg!!8Mwg7Bc;s2&O&>;Z~S=<jS#a;wSpB{imt5oaizE2 z;uEi|zG<e0j`3%?S}WCx=B_4<a7Q1Q1wIBaPmZD{U{sY7qfM!Qd&FGa3Xn>a%)}Kq zA&cDaZ_vSg1XVh&C?~ek&kED9T8YRqswvMUIp^P!Ff)VWF*vD`@Q1u#HH8O*(wdu? z1yeGqAq|)c7w>97@zwkyVzg<P43kt>)Ky#=ltcKrW&sPF1x=hW0f*b3I;rA&Ni{$u z8ten9wJtd`2qHrAxXNjFwKS;lwJiD?O;gw?^))s7RCfOWyqXKRwUjlpX3tj7X1+~G zc9B^XC0lHn7|6k+ifJTGq>w?+UW(&gjp954T<l*G!xYPW#c1!PfEtR5Z><Doyg!KH zJ3%uIgW*$x<{D*Jab4p;L1&{g6>>3dejIdnZRPD=w58XT@1nJ>hNw+3MtExl42g}; z1eo6tB48dp%!7DRI4ASJ<G*OQl<qq!ZwUVW7IH&3c{#>_ih^7gP`D1^<Jdib;ELgV zF&KPuK2+qA3$T4gweFAc4MvR?jVpfFRocH%=t}!Gz0sO>p<s^f37O-nsFE?Y;(^BY zNR-IC5Edp~*&~n-6rCrAOOuD<Xfh|y1cVR+BwsPGD^Uf@0M$r|UJb+%izQffC34Z- zajANqY@SrMky3k&L7`~%eN8r<sg|o4mUw^yDs#64dU<BaJBt=>3D^e&^?&T_&LcYp zK6of4fFaz6(9kn847|yPXNf;FnIY12+hMUL?C=#)uSKqgdwrTlmN%!Km`WI|Y+Mx~ zV?3WAa0YYEde^%h@XsBIP{iT@Ouk{s*z>oXR`{0^l*IzR%kp~pXiG;>zh~!awBKj# zcAozCMwY4jfv8}TEhR}EIGRd3bFqp_;-rm_IS&j+&!#0|fa-ePWbte<#RE7WktjPg z3Yw3|ZNEs$J|moT+$Z{H3<Kt@jKrHJg|#oc7HaEAd-kaOMOJGL<iYUqVV1g+OX%8< z{{S4JJAZgzS~2Qw1Ip7Qm{4*zw9Ggl^*zGz-|7<;jw88HhyMVM`qZCFL^ojjC;pd( z%0tZ6j=I*~=^WqCwYrXb-Jad1Ti3b?-g;zZNf3h(6A2W9zBWb1GmXIXQNV=aKM)Yg zDP=RUtEH;W3zJ78f;T=Jk6NT0H|9tsNgB8SDZ#o(6s-+nWbJO8(Yn`H3w)X;>0NiJ zu6bjEo?{I?ECG2&Md8Z2$&3&J<2dQAEsstS04dApJe+;~AbuW<G{7gBY@l^4JcZiS zVDD2MCuY-Kzp-2IH%pWi)e`IxP)2e|DmDP;AdoTl$>f|Kv#fl<h@6>|K+KR*4O{|2 zAX1r#1isHmuwuBPh%=CA90O;41svJM1J)yY4~gAV_|sVhlEoZK=>&1J$str=N&<Im zJBD_?F}M^wFGI;zP)VzG()ZZi>IFnE5hY(NB4J?R!=&BRk(@ev)<nbIE6puKx=BDj zA@x*}=Fn-VYDgrST0jdgj#PZX20&%b==dOjP6<?`q|{ekPrbA{jbb@-LXr{-uoul? z$(#U7Oi6W0Co)I{gg>ggp<XHK>$MJ|7CID^nP;uILm#H>4<~yCG99g-9<mJMb}_`u zBS#$l)5gY?giXgv`GG{)*b)hLXQy<i0_Mq|EVvoSWOm&pdDPbGOP#FH*IR00Sz)OB zVyep+V9HWcpOg?9H^x-%1Yt)?7+JGW7KD;Ces`rS{{SFp)fI~zjurCLodg7sC<(K; z0aZQU%_(_Z2QpMLr6LYSykCavE7xl6Pief;)S;`j+HBQl*R7m38BfXS!v=E9SQ44S z2FU0Xu40Z=OCY^noNVmwS(%G54#tAL$M~$N3tXShs=YFfN^1b8a-6P{M3!?D=eS=Q ze-ORGY2Zz1U7|GRHbZOE&eTa51oHI(jZDXp%S(cDz|K0AQp~>UO*w1GQ*GlvnZ%~g zL748E2B>G)YfC*f6^ie0t(x&$S$4eEPgh+h&m~1f%`2VUoVyZFKIHVIAS49-@XjL= zmjoX&M!hR*Q*a_TyjDXJTJ2Pn30n1@x~zrv`e7eCfcMTp&mR3ZHcc~=@80!@&&MUR z0ewZEo|M;1G*5l5zP$yAEp;tQ(E2jLJH;4hg;0>#+;DxkJ~R3bsT^knrB2A27ofG< zYLC9LsCGw<r8pm$YR>IZO*FW;Hqfw+<bKVshnqKhE9o^qaV)QK(}_(}R?tgTEH4EZ ziJ+#ACxzE+hd#M}Kp+lB9V-*r{4kW9=<gjtAo&8`1`h1s^$FU!SoL7=7}X~ThB*pQ zpya!EfglS|jbeH4N5NNX?U$Q`IySFZS}2kaSyrlatP3a0!FE(<19B-o*$2*gsbY8} zu}MW#2cJMR8~x+kU5fsvIHbhO{{T=#wU=T*DJ|eg9!Am3&hKcd9@N@ry;a=lYAheT zW3|K#J0_A=IR#fF5UL9>&j1n6QR9cs50Hba*4&)=a(LeG{{SAaLRg!MmZYsrJH6}E zmj3M;to^&x)mv6-yLGV)gl04|JCVlrI8nn60Vk23PB1LYB>H9yeEzo^3!Uv33}3{& z8d4?8NwB$Z2K!U*1YO(ER*uYQc>A+>x9XjFV-+w`RL30A#AxJ^S%j>JcNqTwhEqS0 zSdGj99cf*J;QR$aV~l2-Jdu?dic+C+TrsB32@T=nAB~^X7i#b`)Ho^<nLrH62?{CZ zRHwMv9bz2P{{SC-nb9>%QDN;yq^7T5mN$+HJ_kGoc{AhR0P=d!r0^^n74v0DN=ds9 zK<;Vl9v1CC#lF+=g*Ot$&6}C%RziG(C5<%Y5@i?dNz)b3I@R{CRn*4AA?hcglNtQS zX&;mTI2_}H(Z3-CR6BC_e|Cxj@nwoqGO^T}wzl7UH6ALh&*CStdWyQf<E-@cbuD#m zOs_RjvkAy|_RLSXMo#Ui{t<vNjP=DWHCn}fp7$}#K52lWTar3H^(CtQde#VSTEDV) z3gx`dskH2a)l`ZZY9*na!sBB`vlzzU00nm+>(g>^a@2CmBGwxI-&i?vXQj%48gv?s z1vPs^$9ZY&cby#7R_l<cq>7G8x_?dgEUH7Y<c<j*sRWO4&yJ|`F!Hdo)hiB=l)iz3 znwHwP=V-cOc<w8RC}Q}6)iPrwW+akAQo+fquoZIc9M6tp9CJpoA!dFF@G^73_VNAy z08XaF!c-rhU)n2aR#bevjjgB;oyq#d{n)vnma|xXCQ4!?prnFPETS0E9^p=>GLXRr zI4nWOCj<;0r6rof*1u?Ba|-e;S`n|8+B%CDdHa9y32|7vA7i-Mb+p$Xz}MUEQldpm zH1z^FS~VND<g=WkGG$a@GtJAjy`kXvWy+a`Bz()HhGjSus}Oe-@i1C=A~Eu2Vz8tn zORDI0B!Fx7ZS;$6YyFs7-$i#W_g&uYZKO30g0{=*$1R$oR-02xlbCimNhsGK26iQn z0Wvbc35l7PC=7l3ts<R*?H(8xTa!v{&u)NLAx`A{Gw4p+L8&$7kF>*L(ibBw4Z<nN zL2j&040R-uo~r|qC?HAT<%lgs?Wbp_V&we!Kkmw{ETrlH{{WEFeIwGp3p;JYF$p*( z64X*Z=?eb<sjT6vUr<||nv+f6v{l}YvTKdfp3Wy5{UE!MzcQ~m+DP4kSJ?US)K6uw z((&@txx>0O@bK~00_}|KR|`^CE~#=IjW0si<iW2N!?bq0R;thx?_-J5f{JMpdML;U zC)!xZ!9d<fV%dD~c;kVu@fk%DWlReN1O$NK5JhsgO1QmAjFS~EY~-sH0WJB95X7BB zFyv@FVqvq<8bIqkB$H9D0g@l#%}hbzm+Opwv#R=t2zPF<0$8l2xjviQeO{s@>yL76 zeW=os>g`oD8jkn~g3m=zJvO*af^$){{{WIeLpCvx0P=I!Kmww|{>(N)K?zBxOL^<& z+r=EdcUMzuTjkSKYa65A?lp`Orb*Rss5w}bNBKC^aIr7Q2e(B~iV6z(ZTHp}DsY!C zy4TQp-TY}1KAYDu!{}8|g`@%2H<ALVp)>wXm)vLSJoCmnXr+M5tUk48O#_$rTXkwg z#boY{eI;TClr=Jd&a8y`jh}EvJ;(lBo|`HGF3;m{c7~HLGOKQn`we_KMBAsk$ET|) zsM%qjny=DjKAM24S90;jKf*9c^PKtX;2kbS;RPz!-j921(3a=lnkpI!h!)njIUPwv zGX#t=ImuEE26Nz#AFoOUHa9WAkQqd8`j~p`XU9IW>@KgLRCfCA+E)8eM7JAFL3!;L z$Z|jxw5-E=;D3}tCW*7L*8~vpcsX;2d_RYmzX)8VsVW3(O>gv%Ro@4H8GW|+yzQi# z%S1}H-AitG=`}u^sHqcF>H4<>sPzCItt`78p_~96RH+E)>M3}v667kjiG--AR9E+C zGx(9v+CRBnveY_%RB4J$Wo569p5Jk@)IP<!Q#9yQM=1XQB~c8UCx&1{MI>Q?9YEt} zDF98E`GFm8aCaZHNbu=lQ!*&9I-kEIuJ^onkNC{&1?#k$=H=Pz-l4eFS}A=>%VoxD zF#x#1QBgdx)k_kQ=~ZqsQ_UG92_TaPZqPKI2gN@XhXuRB`g%1V%vIvK#dvCD&pAft zn|)Xs^ZL>ldpB*1(p0}*)}>z&sw$#_eup^+&y&>J$#k_I@Qf+SLCWo+dh(@dPpu3F zcQ>%H)@gg4%G-RoRb8nWW}2S1B_iPn1T)7fjQWd$c92g5A8fptYj*^nbb`^vB$WK0 zmJ;t*FIpNMd5=*PhMvt|VZKLIQ1UWQ>PIS!6CWgb91+0hk0*|vQOt%3ZFPWGC`+j{ z2isQL?W7>?62l&)x>fqoO*Eo814o0-#RorM9FNzbC9GUo>jX_CCEC>+_&(pXS$#3w zJ2l_8mOErs(OoZ7?y0YYIccDnE29}bloEGwi~>HrP_f)DWSl`O4KG7)E#py<+G#T| zD3>WtukxdEz1VB7gkK+qe~ZgcZfGrf54b&%j@N521{#mIR+IbA6eIxE)_EP4;JIZi z^zu{9fLNtN4ZT=+T=6iihFq09e%>BYpLUan%#gH9S<Rf-(x4K+>cd0-0O1NpTK!tV zr|i0po?1(`le!#RF0`!sDp-t&nI%-38i9r_Pf)SUm^n-g^j;ql6kIhaLK(&UcNZH( z5yMTEjLQYc4VYD)l+$o(4}@3F{{VOS`*owun?~FtZFzB01gJrKH97wP+YDzDup~wg z<p91jvnNWGKvWod9j^W^{Go*`7a-eq>!;tOb1Dj%H+s@yP;hcG<33B4_c_n%N$9W4 zNhCh{^yv(sW*~}ybhlR3yek>g-e#k1(M>!GqX<-ePm%rl_^rswntSP_Jt(g!B$|&Z z-mh~86k7LGQ3e*P{mS0rQ1KXNDp((>1atNtKKSW!))7?-w?C)v3!DtZ<xzUlmwl_h z?GiPk;+ww~3U*3f=xWJh-T{Vs#+YG700Hx~4t#OXIqC13lTsXbK&DUyt+hz<{hAik zd|3CkrWqz}b6(QM%5u@67(V_p;9&I3((1?nZk6$Yl`Li@t5Pe^(w$&N@$oI*D%cpE zcX@0aBZfv~=R6!`d=7Z=(tu??rRd+xJ1%KMWp*^wo@Msgnvphq6>F_AbMEWu-B}co z>Z_<PW35>P5ldE4gF?ulDoY}<*&x8&n_I|kk@DF++8&;MKAfP5^VC5zRhSLj^nRMV zo?t|s@e}b^OWN-2+wL^hDdMJBKMPk;Oy||RJb#tlSOhGiC>dDp-L&|@%zJ0qk%>vh z;a}#YIdAd+GwI#})v7~YJ#+mo{Ay1MoI5whRzCwhUDXm=O3An;($rf!hDw=qsXl~$ z6!rfAX*=uN8xF1Q{-v|(%QSXdb)3~xI{LI#QB#jvo~8ryM#e^v?T`(??im<8HQB== zZV?7jf|39Z4xm(WFS{K?XCE5)XB6SxrNznO@)sgv)}<sYGo+~HQb=&2m8uCJNTYwW zkM?=f_cONlp8R|~!+O%65IRx^t**7%s^y9eVQNCsNU_tY+Lh7#(kUP1N2wcSjAx@} zDR@us;fp8yfU|$d4aLZ^hcF%=vpiB>HfnGjL8gl!t3yUXHwjgef5k+9i6T7SXy1u$ z=i8=+E44MNR$9){zO4ikl|Jh#0l59yTd9=xpi7sStSi;rUohq2XuGT7dB;!rSa~Kx z?(JGtB$9jxq*{v)ee`wiN;q!UB+=Jq2h;p}C`E2Di2>SI*zO8WLF1(GzS-a>tL5fp zSOOS7BX18#1GOIlyGO(23|W*WOBFdtDhbe(9`hZ*Jo(8|>wA5I&v4XN29jDtRrw%w zlhu)q2c~>7o=78tO8%W$z{;zEy$yMOn+V4zD1<Qt^5*BrdsI;^OLf#*n*RWC)Yo}u zTIz`mtcux4P+$nb@v%S(KTP>2rzK5r%q#cx@`%~UGSn0(&8;?mhizcK&2qR>#Hwc! zR4V|)HbSw^H*x3X@%nH&>ev9xQ}5q6Zd`$!=KRBYfwTJ3xA1~Gooy_&H#&&tm`2QU zSZ-mA=lUO~T*#_q4}O)o8+h6tmXR?|{oop*+S`3LrJ%pGJ=T{_>ZoF_o>{GPp@wi6 zjr{Ibz|MJQKmJh|=cg91M_u&&y<xmWgbb;^Q{m(&Gy<_=u3E+`ZP6ZrNzE-z0!Xr8 z`N+!WfPTjS`xDTqNtPYl@9$50!v=EZ0;z2G=y{JoMw%MJO-I~H`fCi93zSs%+eC6T z!W(5vFc4HW1I<dn?uJJR^QieI0B%yj9eXhhT{L6LpJv;e+7X2UTEm7W;^S)Rt-v(g zTAS+b@ZO-*(A{*c^0LGidaBb%Rnk4B8a51A5J6%=2R=M;(?MB~Q!MiLZF#|y6cPam zuOi?ZJuAtB)VYl4JMXLKiI%G2I}C<0g;27QgUVp9w}3IvUpNJTbJO%Xz)MLdJ!!Ak z&&lfxYCGwuP)Ai$Dm)0qfQ9`wB$7F1Esj2&Klp&eThHn5tSJ)0)-?m^MkC6<%t>|+ zii>^C>1;a7N77bV<jh8@p0LSKGmX3Akv$e)Yz#5nK5jV`Qqrm&T%Qd+(mOq7TE;}% zcXIs8lW!Q3?D|I8u3)aJ&{m5Cwz_#l)w9t`)D=)ET#r_nFw71K0|iOrB#xsvhXun- zf0~ABJv_DWq$x<wtc8JMYAS8dN2$<xL@8U{R9nHOv{@t5^2c1V+uE#&T0O6y;(V+8 zT!V$$0qZ#8U5+?&^QBBHL+ewx<U=#5pPgD6n<WXVNzm-<{{X(AQQ9k3h3y8c*B6;- zYc1DnEiF9FO)|*~EdHwGG)jjANT-Y(ZEw@B)h^I+92P48xk}2pa{=ZER=mNhN9IhP z)8aT!5kll8M-YPPNTyu2B%jLL5=j&zSm^C{z@_tBcH-M=yW1=i>O*pdDk|Fe)=Q-Y z3jx11SjpRkD-jGsc-xl^;!Zj6uLFjZ2L|p;OviVj8ifaAVA{vC`!Dg|iDM`H-aZn% zEZJox8C->Eq#B#(O^e<k4$$jf<9s6N8=X{I0+(E9Z9z1ZcB{;nKg0U-Q}0&|3`-=j z@K!Rc1{`p3M_wQJ#qg66+Js@arxTd0GKORXggSDC1gMhXh*f*c;Onoi`yIxxSUHn; zPY7wKmdYfkX77yY?yw34A%<5eH@Rv{+z*INW851|wi8Q1r!@6NT$c)%XfU6=UF9+} zPl38(9f<|qg7JkIz})^Hd<epDtVqX9oAagsz^FR_8@gX@hd8Z&>!0;GVop+ogJ)uO z4j9<@-;_b{P}WB^9oA`B#<Czk;tB!dcZ1iUCM_mOmIFAa>elNKSdJuc{p7iAi`usI z4nvNS+s$>|XmvF=Zu^<1Y>`2wwFaJ|mfuelr6aV<O&ZbE+t2~>ig~HoRA)Y$#NXoU z#y!9I<3F<)fq+&IJs&o50jYXr)2ZG)N$_L(XTbKq3a%=iB4v~sq#zOj4wIWOqN->r zK@^A!;ybsG@nJ)A?tYWCkN*G&%RRq+`q@t5pI%#O<HzG^)F|C^rinn7A(K-AZ(PX> z2C;k;{-E$&QhDK%<@|!9-2~<pxC&w9YvA$Ei+|IWH?kOXaTLw`MjVz>z{pTZ8FMAn zG8;RFEDhtGot@Max@%nN`^8l)Wy+iujYTlt%F-^t;CW;K?&AcEW3OD|W}_mU)RFJ6 zoM8M0cgV!%Ah`s4_RiIb<3{%~!=rnPZ0+Wbxx78cz0Gi2eZp#R0IEP$ziDmt4DLtb zHv(8=a;Q}ym;gu|^-0Cyrq9HD$+UmU3wy2X`-ndlsgu+AEGn|4&S%o(=u4L&#p@SR z<4>tIMS{oMDs4t?8kKGHRn*j>5~W2>@&5og^~$7)!;lrSs8o!V@anrZu)L(Co3j@B zJ8N$kkVglZEoq#kl$7OSNz9fy0Dm>v=xIl!H0UnQ*dxA4OQ>k(uVsMxbpEKOM>~SZ zRxyJtOh@Dd<0Ox8WN@9HloSHVQRPbMTGUn9vumM=z9TwZscw(}0D|Ex5~g&!Q7RxS zB7q4Z!33D&XT?<7whr!h4?}2<@z(bXMNr(NG_zaoEV0s|$owqZ0<4n5mx-Nst9jg5 zun-(%Nx<R^gn$KEo}cvW8`6=JVHZ0TrA?T%Eh_6)ahq~lnXau|K#yy?djuA%{XC65 zW$wC+P9l5-dWv@Skf33mz<R@z&g0<qPR4PgF>5MdkU#p=o9zDpTL_%Nc6MB$ETolp zg%w>G8++1`-8~s?md&KJ)Hhohv)t%uXp(4McSKum$rTuUrwkvSHv$g?a|PH=J;m|d zK?pPDVlb6+Awfc20hT7}<Oc-`EL<pMQ;iNp#Vtf<%m^Sg4#M@)gHCZsJ}Wybbhhd% z-*IgE+N-(xY2|gQlA%&6yGq7Fh@@2lH;D$&9yMGHZeQ^oV~TKzvXrIEIe~W?zevup zy_m!$3dF4}962OES1#o1&VtcPYA6mwkW)%v1npp;iAwp}e0%5l^aQLt<p)Yh^opWn zDQRRSNgui1t_@s>((p*)I!PB|8J%VVKu9Z(l#g%Co<}_NCM^89ijbhn4MXj%?co^w z=$3^O-6ZNXElogwyfRSIt=CZ6(V%$JmZq`-9l~?6Fsq+z@^i;e8cNDb#eCw9K;YW0 z1Kft$^<fsPQE2;xE}79fgGp)&t%}d9sN|)*!5KkM73$k6LpvO+0J2lhZmb|<{*fkf z5Oox!iHb>DK~UFU<*Me7Xp&zTRvuK~qCyXNfI|@5av_K79nt-i{pizLhgtXcO<AXV zZ7l_k&qrj6bFr$Z)_R4Sbsh^BN`40lgk+4KmBa%m1eXoI*Ys$w_6BHD=dNN%m{cB` zHk^UJ+~W6lBK1RVdzI5tn!A+M5JzgX(bJ$WSyi@4L@=W=jBxWaGb)6MnV2Z*t&oX@ zejeNRX!I`!<7!HfC7OayCT6cM);O8*3twRFCv*En+WKLsX=!b>V!KgN)LUIH6jgG{ z3H2$-&fyZN1QM>YD<d#qNNK<XB`8j{>B;@<)r}1uwMyrubDt_&gOe8Xz2fuf-qgtT z)v-ryHI&iRG&IeOjj^#pN%NeH{{U0dv-mg#<pw;1c%HF#k?|B6dD6P8@AmsOpljkA zw-vBjyJ>7mtLb$0^16K+a*i%ZisKTd=D`dX5J{2_037+jjm7Zvs}Uh;phCeUuweD6 zptahX#11R9iBho$QFApU3KuP4Jo(c|;19+3!j_@<V(!FRE-T!6k4oGr7U!ogE)_p` z(-{nV9{{JT8-T1q!Q6~7xB;_F;y8blX3180W!R>np0?C#-c@MwjLr!;c%g-0g{wu7 zlKKk<CqhGMLlp*gzgwNZ6H8SbNu1-52=>VRanVxoauf&4H;9Z^1jR{Fl`#yUOX|n3 zD#8B%ZsLmLc%qiJqOOjWB$8O2P!^HnMSZ6u#vdQg@6<nNXOV?DLy|z)2j{uGAK57* z@yV8_1&ecL9zwU*ct7q2h_|(tj_r58PSk>NP|o0g4kyaAOXn<i4Zm;)RSq4*RtXYi zfNVPMX<ISSh|svU0#;13l*tZVv~Imxn(QLW>4t`fvpuo4)zZ|x8d-#@hiF3VG7Bdh z9otWl<J|PFB%&rQWYxo;bch)%2Q5f@b77~YZuT{bI`GLQ%6KZ^mKlSvc#sSsT#OIX z7(b^{g+L?()I@krX|FH`)NA^;?-m>3mfa0q%efHAO$BQ`nSD4URc}#?oudbj@caJ& z=hTuGqugrK_V!}GjbS8`a@3dFmtmtl1YIA8&x{_^d?frfRra@1XxXbfu5WW+=B~0= zr973V2w^pKh`zBj{+L+fjfM{91d>U)%HYOG&51|~xYa;;QKL0qY0nUylZBa!mID&X zNPqGLh&9lt3SN=hUxNE?w7Te(?@xSg^_TwY-XQ#J6txwvQ%|L>&Ip2%0PNYF)mllW zIbuqnI8s}ag4QyS!RS17)SY2+CCikc5?rR|LTp;}+swz$iS0pg?KQG)?CN@YeOK8_ zj>~qkw$@QKRV5`nyHO>TRal6bSjNt(`1wP|z=<PFlPyX4Y60#V{abBKF8v@}OuSUJ zrhJtUUt+-MLC~6!*HeBZ{6O}$`3+8@J+Zmd>;BipW2e@1w+cz)mD<e|pNDBTADUrK z(?jYM$=Lp>1O_Wv!{AkgTIEX=Q7iL3>r<UR(dg;@v>aQ4$(uB^z@UY={{SElfO=nY zB2xGpwJx5qPp&F%^tS3s`s$ilqMtsK#~el^R{#=8d8NkCPYZ$X*PlKwW9MRV((uTC z?B!S>u?i#?XV?Lwf@>R(!cGl_PY0PcaLi{asVv!2;0owTbIP3Jf4^7DW7Krk8|Iw8 z+a@%z){Bqs*`c%Cr&e~FmX@ji05s65pmlwY!O07Z<7(`u6jnQi%f(DCvqf6(TTzWD zEMo`lw*ZsF_>9RoP{<8OvyICT0iacBtHllOMzg5bw}iY&>Mfd<CxVJU8~Hn=Ab*LU zC+ILbvXllX6#(Dq>{co0no{aeuj}XMKwu5p2_S}8;*u!vHn2Q~_vhb|IqU4`v$^}j za}u`si4ElrwyLQkmIj6031SHi!N<4r$NIYXvyfZH8u_avD3X2qyh^rf>p2d0e5|<& zPSOX|eMdjnq>?|%fWb276@FKHdo~^|w^r*uteQFJjLlH*9ZL}$bFwRq%y8RrpE$we zq?PE=^r=cH0VS#Lm#6{>+fhL?2(u9&?H{HygY0p~Ng2RkZ3|MD`E)wg0yT@#U8uJ! zOG{G(iz>*31^^w~gZ(l_cq1GhGt+Yc(&PB?hfoj;8(sQ~_`|=#x5i&`{tdhJr90W6 zCt8`HZ^B+IvQ!kM)b`0X{vzKtK2;I~R!6}w%^Xs%3PBSaC+8<D6)sU?&p`Wtck$(= zBi(L`_=D}w#3ySzf8GAuUFvT=ywnr|>2H#B3nWJ+B&VrBNJ^MwSz(et@W~sFMhVQZ zsVG@Og+xr@Q<*un_VGmiJAN2+r^FtL{q&MjShek<nnku<WCX?_Br&F|1D2;|8@CUh zBlD@kvax&*h%QDTCq>b;<<PyoVh^?R)rY|=7y&7jRRRA15q5T}Z(343k6JZNRb^Ge zf}-0>Y;QcPYPwV07O^LeH)S5F3ZZvxJ4h{@dv&XzoW1&Oq(n%V6(}xWde78=S@}4n z{_=gM=LK4eUD1_@`BdN%-$2JX&O8x}gXi0&Wmow7_x#7pQi}5vPfZL}PWJY8;|gB2 zewDY}C8?mrWmFXWr9~qN8KhHy$mxbGJTl>iGI}OT2q7Rg_WgX%)*VSC0a2)CsH=kR zHfqIv*0zh+d^?w*j-~Dej@4Iix5Z``-9<R2qqo692c;4-Mx+q1LmHxwnnQ)o;#iTx za3W&FprDtln@~CNYYIFK#5kI;a)jKgSV?dOuSOiN$X$$IpN7AO4$9d(ZKbt6leqeO zxtDw8{-)PD`pHPQDzb`{^c2e=SCOfRGDy)eUD8R(NY%PCwSAPEis4e%!<C+4Sa$r) z`B^n;0_4<H>>SnmAKUcdJ3}#CR#}%RGKmFex*NPyrg9#M6mm3jL|bbOj=`oc6p>jY zvbmh9A-6!S`01FkfU<x=h!DqcBpf$58S5v<V6hlH^09KvthMhdfDK$wo}`OLtA*n@ zPAid8ry1&+Nl8jbAy4^PLWBN52LkjppXfD}(@))NMxU#inx?jw_f_Vb()B4WXnaRJ zZbHZxhC!Xkz%sHPdadwWO#C`dL)|^#<XX1?xvi+;EPbT;c#@fu2>F1e3JC<dsNo7J z0X-dwHGnIxV>?TB{qa+%X#1-srmU!1`ntMpO%&2RtT0JgZS4>k+As)J7*T*Yx!`+3 zx#%mGYYK|7t0dIwHZ6FraZb;E6=gY@LZmadB^222N>QF8#`sz7&Eh(`jXm8>Z)=_< zW@Wu;sH)S90HUUjpcCY0Y#&e4r>F6?N>)WTJqYT`H1P0<ye9{lHc6O}^mhc*R)Ca% zP3ZO0Xoqzd!<FMt#w(-lhhr~Rg=Ky7YH!@rDi%?=$90O34t#v=$>TX4Nu!E{fO7S( zP1}8sH~})<Sy1|&bZ0aM-6^#qKD_pex>|0?bv0&!)fF0eAtLQZOt%Yb0B~5=QW>d~ zbL7d6rR8Gfp5m0rJGie{MA=GGh?CwmW_u0A`R@>&*{Nx-5vi|&qLE;i5~Gcb3n$IH zz|IKZ_#G6vK&WLryuP=;-U6SPD4}}wCZlGIjn0UbRJdDJNXbDu5)_^Z=Yin=06vCN z(jPIVzJGX1l<YE|THc+1OG9+`J3_0YUhL6?AxeHSA~$1$klE+y{W$4T2yvTTY&lnz zJZTD2i)I>D_R`|M+epUWe5AJAZI?Lj*7{1ST7;b?j<!A>95>2CZpa|<@BI2%n2^dW z$A9MW*<ngks1Wt#QSZVDZer=&JB<XEi+q~qR@oX`VWkyPL=<7eGLU^CkV#?{SCYVu z-32~sl0jOSR=qcRQ~9Jw;jqSXf@LJBP8b00O&A990`V*SEq50E@ageq+nIZR+}avD zJ>v6AY`jrgZf#W(#VIOi>SVx*tr6Wy$r+JB7*{N;Po6Otj5Mx3mGG6iR(s(t*{bxh z7CML8KUKfcINucbIf=pc>jYDX!b{DDn6?uN64_iiQWh$fBfgR-40@*Q_r^Y&ziZo% zZLT-UN-cXzVx1xNP`yU@#M__&iIZUE(16M@`G-Ae*nT8RzF7njO&I>8PO<s7jqK{= z%1rc-P^WMYc`QKh4ISuB4a9Ks<Coxx^nb*5uD^D|O&g~BTdCPK6|tutp|wdogjG?; z2*Tnjk5gbIFQ+sk9Cey;t`a2T426{$H%JQ0L!ms4DWgZXJ`a3ERNf9INkTssp5m6I z=5`cQGn~n7a=0%_Lpt_nO=-<3RY9jNa@lHDA69C~X=rOFo<N+*B(lKrmLPsrV5{L( zd=+4LkdlBBkWvk~KY$%g&zvicN}Z3yD-nrXtw!vZ@@8S63<iQJ_2WccG~aEm^>(c{ zt1b~wP%7!CsnisN%=kHDOxXw+v0{mz%DFkuS3EZm#DxUOno=3)3jwyh9x@DX1mR&S zor{ns5J=2QT~r^LneA$iy?fLZUBl8)!#&=v-yKxY$22s?2;C!4c_##n@r-#Mefp74 zX)#GzOu*qZb5eQeeIq~ZCt)~ZWu|5jWfKFCl2~$OyV9>_qeX14_{r54yQACgnr72^ zw?!*L^3znwP6BQxcEGNr;E|jTuGn59o=_`K*T>(|F-&xi?q!zhX9IUTw%fHff=Vxo z{RJ%*A=GwVXQ$p>;B82%RW^_a`56vAVg4MBdX$xjNKjDw5%24y4frKKN6t+cSe=t< zJqwta^f!7tb+7K!l$u9VQq}5ksA8g8X<?pyyq%G{47M}!u5vn8%@Xz_)b-cw+Q(NX zC=%2d^(|eF!hmz<7BUN`a4PLLC^S73w)%^WOHF*$cNTc9_80=<DkvF-Wtk#7*-#uA z8v(JnMynYuL?H@ERZYVO{WbF@3YEepBCD3J?F|nw6lh3$1ITV+fo!zeVk+7V4`$Z7 z>r-3_O3guWf_b9=5(JQ^_)~D&GEOoFkOxT^sbqu#9E&~1r%P&iLwKAV<N}$%pbCCc zp9+S3fa^-cb9T3Cn}t=gN>tcVE6Y~YG!<1FDU7O9CI)uK;tXYw79W|w1fISVkd{&W zqL+WxyX_q!VFd{UlU5aD$hDimv@l-nbvhP~*>ctQD@L55t};hOQ&&`?I)@l66y)*= zQIaq-(@DjpU>9V*w9|78$Wb#&5~5TETC5E!1Kz`?(9ad>%TnT+msDw5`J7=DE|mMp z^MDzKPmBT4UpdEhiR5{EzPE+0Q9`n)DcOU&?rpZ5%xje^y=1Bs@|8u{#wQQyZa<rH z{SI({Pp3;WQV_(`-u~JdnJHP=q`f~ewQ0_m7OM!Dds*H^?d8^&P>Q>dnd32RqzcBM z@Cvr#6o%R|4}-|T=dYD8j_U{X{#^ZFNm5v_Q7v70xpqA6Y0Q%nXLP&I*-d>?M3)O) z4x?>`zOERhf5Nz2oID|w&$DOT4nfWVT(qTRH4dL*`VZP2T4qsM31Gs9LTn9f)|KTJ zA5C}LxIYH{->mJ2#a*s$=V`d%iEO&oo-bPQ9kH^JR3wk{*VExPG;%SigsURR!9-?n zgmH{s1QcP3C<Nw|O#!RrHcub{Ng4wfmlf>p1B$CYB`HMW0Zioo0Fp?idUuA8QY~oc zUaIevlF_7ewxI3ZB{s9u*LYf6UGkB)TI%qAWLXEy(;+`RCu5<=WgS_S@dFDvErl7E zs5B#}xf(d6SNKoDTuNq>jb2neGFLEq7b2JN&=-z%cXwJ%t|6_X)VG@Y+WA*$j)j(- zQ!6^VrsW$W-?29k3vdsfdh&n8ua4X!4258^2j|Q$+)b0MNua2+6>lG*e*k}^{xywU zylkm|gpfbYV3~?)I7z8$+@&wP2Wks%VSCcCQ+HcLp4n=sppKrKSLkY0S15`jkN1_T zLLsf%sDRBALr_5E$a=;*75z?5!%ry3%K}L>YULXjF5Kz%k45}R{)rKY!kOKl#h;$t z;IbKHRl!STcl@Q&`o&xBZ^DmpyLG4PHO--EJG<Ig@D8ie%GGpqW5^c>xTv6zd#y}p z0USFF9=(g%&xlwqEh9D(Xem6wVsy}S6go71c=p?{+;_9{PY>gGbs<`21P-nP5E;P) zgLg4H{2f+9+@9KZFR*gSC2pjGIkn!4{u<@yr-v6BL~Y=eo|Y)(3cQ&p!S_94f7C}| zRm8AHY?Pmviih2*HMkbs2=pKHlkssp4~wQu>5V2~%Dd1hOqEnOby}D1sG2k_vfmz- zx`O#*)DzO)X=O^PYPu;3%S$Sd(aRi&*+)_ogYALW=cKcU!c9!%0)+wyHZ+geY-z*d z<*yAkSX{L@Qb|gI2H=WZ8rJcTb31u&yUAVgP1=1g44R6D8R|Qkr709di3>K?)HdjV z!qYR5YH8FFPb+$WdQzRArN07xHcR8!Z@^>K{{Xm@-a9p~r;(u%`F-(k;f&74aTAEc zq^kush$Y<)KO%^FfZ0j>$pn6A<W8&9_e(c*bk4t^dMjni_n+eFK*W&4H>?|J3^^D- z&*%qR{{R+1IEizV4eO`ud}*`VJohUWpP!yk8K03gXVKcVVoS$i{u}jD)x%4#yMtM7 zw$fC^Dzwc0l|4KVM!-to_)=D9BRI()LDaS$R$1gdEa%h6-Y?uk7cqQ2Se0fOs!O?H zwKoL5j%ytJ_=o+e{m#B>OVy9E_Ujgi(eTUYP~7ICN}H`*V~<yK#K%q>0EI*>1_5m6 zq~bWr(4+WqL8-ZIw5g0)Tux-<sZ!Qbmt&VtQ&K4Vyi$(P+F-j2wMD*BUH<?&-LY~o zt$~*Mf^s)|5I((R{i<F?r9`za=i)kEEq$DnCL`s5NwWs%=9W5*;OYB|dhIuQJ84%z zJXE&IYmFq;D)6L*aVqX3+%DE2j|ZNj_-6>iaV#>^iSGpk0vwL%3`Oq5>}x{s_a0p7 zM94@j%xQ10u7%^J{fqeCJ;rY1cEh`I=^IX>prooxZOWfC)VC)}ltPoKBt*%$I=Yd# zWEIHCzWhJz)(e4fnS2`oFC>{@<#u|PIgK+HH#K2$K!E!(#*g9>S4JRFw&(}Z%S3VC zYrX2@G_cTLtE=tM#+NGk6(ez)qcgP3h%h5Xc*aiya)IDt-8+e)IK*M52F@Mqm#{!O zsN4pytHsA3!X_^Ysq)n6LGL6IO|uo2vPA=va>B7wSkyF5p$n?91vhxf1-<ys^6)x7 zVn|6OUzOXVYj~yM<*zVuUBDW#C-3Lu5AkVswbDM;ma3*~)ij7Ru(`+uynL(2^6Mz# zQ^q2C%TVvhzV~>laS&5IX$6Q;2DfcI-n_MmBeow69n-6=r-t>WwPaINRHbuNP}yo~ z)|!r>C#5qqD0e5O6yRj%BLIVn#}n;t8eFv*Y6RE<paCOF^NC&$*xXd<=)=sEq@=T} zEdz&t5#)4>)kWEU*8c$aX{z+q2gEg-(u(g(c%z28-YDzrcL=HKDP~usmDWXB<C+zd zDy2UO3*oVYRl|1E7^0G71DdaxmliEXK6-MC>i!sD@@18YAz{N1p8$4asTA>u-}Z89 zp`v~?G;OKwH1xLH+!tGOBvUYkvHt)J?HnM1h7Bw$upDHv<7qu;bAP)nDyu!MM%t`A z>qx~oF9w~2;!_lr45ci?cdd2b?D5ra+F#y_7iqMgZs{zyU0H1IjCB)VH2ubzVOX~q zq@=7ei35UJD!nvNxl*Bl;~hj|<q!Y^ok8eNJM!K&UeC%**}0ZjBy}~jQ^cLA9)4)9 z{nL|HQ)}rj7x;CI^*+7A@o}tY-ytLg8;dg{t`x5sUOt^iBZ#Q0GF0jo6erHb{G!h{ z8^ggEZ<bDu^yrbGb}m`VbB|G91=_BYxjmk=&32lZwQZ&U0CZm?iceInR?vM=NXc0g z?^1anka<(pivfsECSS}KivIu;#C{!NNOF@=tBY5dG%X6gJZUc4OW|9&`p-+-O@02# zC@Q0Lrb*hCKgU3_PD3y%imXwcGQngd1CD{>m;}z2Qmseq!=G9U#D))y6s;2h4hz-m zxc7QTC_AA?fB5S79KP#1`W-xbouTUMD=iVw$t3j>ibx|BFv!Ie6Fe+-#-jn^Op?c` zzQk|?hQco?aH8UkVOn$bAR0tJZ#b?W88{TBia`X6RMEOK^`vm_-KuCQ^yzxI)?1Rr z>8roxm5&PK`}rT+>(f}C4>eezWxvaJYcRisHE6c*Uf$s(aDyihocOlLl7Gsq3Gi-C zB1i9MZ1v}2t(~r;zF(<pHAFWG`;A<*jOz_NlC3|b8nIAWc0kHbFn62`4tc=%td1!r zWSMAWjW3uI)fy3ca<pfipyBiQbmZb@uQ4tijR-HMhyEl`j!@rj?tgDJEfYhlr@kVc ztt~s#nVp?ZcA-<WjE}1E^yqmVpT=RPh2g*e9q8Qk@QF3{lM6mc`Oep%duR=szpPn) z<LVndM`pBz#=&cb$8W8sp_-bG5JJ&FB8`H8%t%mGc*!0{dUJ>;HZ@8J2>$@2I|HZ= z=T{ErLSP&^MnI;@9GxkpG@!dsX%u!k#%XKdkuxllyo(~P?TpMaarfwyrDQ~8ukE^2 zF6Pc2y50J<;>&zA)F`-jErn7vsccx$jAd|lxyKymllJGMFlZ)fL+U-WKz4>;IAyg< zE2v?({h}N1){&>vbQ)h(M?#4luuD%>^M@?x#nIWYFzpi_KC6zjK0kp}qLqNa)2_$$ zjE~`eXi<!rKL&*<O_f2#7=|DOmLo6)_9D^j1K?KWq;-y$zSqxl{qE~aM2}5UcEd*y zg@jHraKYnf+dc+y_v<F@&uTHL#Qp~i=>Z_P6(H=@rL{YoM#tG6&EU=-@mQ8pi7pKS zc^-XTqU-m@_1k{p_D{Nfr?^$6Y&VTNEel4p`>7yv8wi`*46dl)=Oid2$5p%sWjMsY z{pto%Djd|)Z`8CI#=Bd>mn~RHrP63=-4&ygL0yF-lD)t9alh!!>RYaQc0{uFuE$kX zT~94)nP9J=xxizow#CQH7IkTeVd;S24rIq#FK4*kCmV;&K3NJHvkH$d2!O}&3?~Ye zKL~|pOwcIx3QYkz*{T`H0?-BAe#+P>H8zaxcf*}EpHA5orlz=3)lAdDr(lcctF4pf z^(HEkEQT;cxL_0lTN$@+y9p{<*TqVJr+6sbiU2(e2-&_5d{Uo`kT_J-)gP3B{wJl# zI-8rg5p=9NZ%tZzSw*6&`-9tkOWUaHCAY#g=i;g3xYS5ui+fSiffS5oEMo=PA>1$l zLn#xmeX8NOd@7`_VG|sZ16k++F5_@HgB|#{*j_V<;$s<t!YHJnE~GN5D_5v3pdIEB z$*%GIIalc$i(d3J7EM8;E2EZrMxc6wOH7R_q6q&05~)9<xg;u1?5jq>nOGBO;~ln; zCK<Ry{{U$zbp=qnu_<G1eOe$mH)rL|N~L0sTtaAAf*qZXnT3=A3~6|#nu7JDG}Y=I zH>hqEHY&G~Haw7-AsIVKGA?$HeperUq__ub<YR$Q1ueTD3OB1nPZ;c$Ck&t^_utRN z6Z)7|ZPCRvuWzcMxYI{2!%<Bjk$Cqk2?Oba&jXIF2~afw$AOB<Q!MvD?{i>Bom-?k zLsBW#BXW^~-<d%e@$dcm>Wabck55=yk(d<_*8X+yh>i=os?^HP?;M1-K|e6uK=I^| zGy3#8T#{6Mmxr>0+&4OX3mxEwk5$1(vSzG?4S*a-K;)lqeE$Hjj-Oi*l-e5kYP<KE z-^3KAptsh<=`_(nP+VaqTr7Zt#^L<BXvy>Qhdyc?6$^MD(Z=x(>T5kj)sa%rC0$K? zl?xRzM21I{yAMrcRT*|>Ap(F&VgTgy4E2O3Gur2I6Pz(YYD$vy7U{q5PEqWu_IZ3F zYcGU<g}F4gy44q1`;V!rW7OBHFXFe`^tDUCh$$&laAyb;@iK#nC0(Fm6=ux%?kM6` zRErCEMBWV&(4Yl8O?=|Q>wfcg_d#fR^(S;SCY{mxeOvHTJ<?#1)yhFrPa1w{nUpg6 zNEwJHkA9+fWQn+y333uj%LWclzHrYQ;#gb^g<>)5N+keXtbi?B-kJbO+B~HEM#<aW z*->_a%h~#N)jg#AUDn4zJ!4Fh$3m_6xWlvZN{O98Wej~hw~>v97b6{pGJg~@vI10! zTpB6Gi3hBC)8QX$IA;{ZaeO`rW@;5X=S!F<8EFV8$Wj<2qzAny*N7E$1%eB7dX2RO zt{pY7Otq5LD2HGi?Udjge^5c{X(~~vyMGkt;~IqNNz4ns>chk~=hh%T@BC5QbuEH^ z-sp>kO!i6OjytWz5s|B*+)G0UU=OBno0#CZJ4O_eR>dT>ygjklL-OVS0G&Z=c(^=> z(%v?R<mlP;R5cb$6VqH{tEm-GN?T%5PBO_N`D6{3Mqdm+LDHp7R#wxb6Nob9AgNR> zSD>!^fvqdjC(gxp&X2fSf3muJS6ROSQQInrDybp0!4!{ow9j;fs*qDM;cHCNApAnQ znHF#2sr17c750c>u{c#NKiih!L>kzrKQmBII>kqbc3y5E;$-0zOx#N_BvDoXf6=G} z5(scwEO*7Gq1V3-x>H8?Zq0DFUOS0-)Y8kX>E?~5q^R}i2cvIBZdQ_>H2gF&Mihe% z^}#HyH{1TsrxAmeyj4_7oBsgC(g*z^nT_sTn4^3r?JDrt`LZyCr71>4)PKl@sD>=H z)RS-v1^`5z+YaH<YwZ!EdyQ_k*VrtT)GJXW6%#c>TGTTM5Hf+8Hz{5KV~}=?@rm(n z1&hEXE100MHmPO~O){#c*JGs~qBrgV#IU&e^DvSnCR!zr?iC!x4D7nm$SeqVCZd8Y zb*EobT`JO!l_fPx9D7a+g^za(!vFwtoE)BfbIe8$hptSef_thZhvjl~)ben_;=#qZ zjJ#aQa}=7Sgq0`~0(8t9$?Ktr54ryU6SQ`{XxcYSRzrAbDg_;N#bq-wag{N^-~=qB z9nt}XEL46{dNT!vKOjtIp*jFL7p=E11o35N#3>VI9Kk>&wd+y{VD1H|9iEL8@8ava zG}pe=*{zlf+tXev?y%C_WR$YX(I)8v$)A<MVHoG!eF+^IE?VWNDK$czu(furA`Mva zF<&lXPGmEy{HhH!=}=85W05*<RO`Op->21Gw$xgixB7-X%TIBzS5IrI^$TR;T4qOJ ze=!8$<YaYX!%k33<|CCNC0IEMIagA7Uq-&W#ha-3<M^hU_-~aO&rH=<*2a|87h3HZ zr|a&L;cyJ~wG<B|tO+E@PV>QLY$*#?B_uLKH9&7Y$g~G9he^*>5C}Ik@Hg@Bq)gp_ z{h+@Q>3^=I)|a_8+E%m_v{cJQML{H0KBtJJrKLtv>$!Z4Fj5G?=nf}_D^gO4ibJ_M zLMl19?>5&lJ;V4`49p2y3c=k)`88<fgxnKIxz*L58al_e{m9l@6TV&BwNhyfIegTY znez;D&r?qssfOPjIDm;vbngt4I3;6Q>HXXeqVVN%W-QJQ8~`a`&Z-(QHfnS=lH<Gw z2GY|dISol6=mW5Jsi6Y3Fg5TY-fs7I7g}pbyQSZL?Q271)3WLdMUt-NAK`C|jV&^I z;K!L2YGt5d7vz;%RezUgYaSyGz$qzlez*PzicF~zQbttO=p53Ow_6%G5mG)j``g`n zcY1yxyL&dZse?;eb;aH)IHI@GTjZ948rlB<3r$pMqL1-VEDsS8&*}wW2mtffC1J$r zK?+OWgaYhX7Hy($1H_kt#UmAgm}SgKaG;_JNw0Lcv&0#rH8uY6++Nu|r(03zNawGj zb-76*!5yu{s_$1M%nsF!5y(;gEDwIGm>$Pq@CuoTkcKMWtObHbj!%W7&^@aDqPs`h zz8H=X#4$=yK%hWM1jDN4mUU$TfET%}dL!2stG{ryJ(s$@lGHZ)rF~6IDNS&**TUl6 zQyK+^X`Ni7MnQFDUVJG8GajNp6Ea%=03jh%Yc)ljYRz95rr>ZqKM{wRoJ{PB6-zk^ zVfhFe=3`nj!{^6`fA7!RuHET-{j%kz^)%7aUv%Z_1{>gfBzJ2~YA`t)Wv8AuOc2nO zKj#>JFN>A<geGc10n`qax=;TAib1t1&l+dJ{>tNV7+h92h2jcI88c<-#<J!jjY8;N zti%>A9M6m5OT9GK$*n*BBlPA0UUq^{S))9iq&@*4<2d~=dWx6D_-Hwj4LP`dd?Qs~ zV7pAE1%-2RohTlTJ-ltyUHqR=ErQh6kKNX)MGEw0MX08hOnkVFlwfC^5<v%^2U2)^ zT$P0^k`MUV?OMBjn#F4o*<5Uu%fjLqAgjFf1ddGYsneW5dcvmbcGCKmDCsJy>SCvs zYI=GY*c4(w0EExG02F!PW2n9!g)bD7hfyh`=C6x4Q1%dK#LNx|@JX1Qc|sQ~G`x<Q zs=&FcGqVa`NUsa<A@NH~Z<AAMKEd~X$)_0-N1yi`tt_z?QqAZL$S^X)askKRt&KcQ zD;Y}aef0ff!_#J|Ny#kcRmHmA<ZdtM?lOER(QEqz(srv+O{n!Pv`sxtwzv3IamSDu zMKdBW!5-PcJdx2jiJvJUU*pn;mCOqx5{Zg3riKb{L!-S|yB`?U@S~u=<mYm%I*R*E zEhpitU(jh$l)*G{NU}yr#@q=}$T=ry<Y1E-h~mjw6lID8lSZfm=q^hLxA1-nnb@SE zbW+Mkj$yrQ;q6Ka$6qQneN9(t^+mp*yp(kshPBpNC1>>+E%Cyrxg6j|r1Rm_efrM( zF~H9ZotZ)347D+5%%p;K2D!i-g(J{>SB?HVjLb@lrBl-`hx`#SfM9gUcLe_ci$tl| zxohpc#ntq6^HfW2w9s4PhBSOMVr!E~6aWa$`gUv_47c?wwEQh64J9BmN+45Z<R<2% z(XQLZGur+(C#;~K%{?TSb~ysB0kCUW+|}WRaEH5^mf>4R4NTBOO$m}{Rzw0CINuux zE1mN(2OJ!EJxgI0m2q`VC4!3S&D(xgFF_TANnEZ5gef4ayPzNPG!4%|eWRW~9?es0 z?QVjksH0lxB3UXSc-hb-lFAvGnMMgw@`00`+52@nij$gjqT~i7+r}&XmBY&j#pQCW z$Vh7^DoyqWOY0nzp{%RadWz2^amQa*6mU%=44+Mvj^Ih~*vB8I->JFSVJ1q;UBh^C zt$w;hl(tds(cQXG*3IV=!PHtR)vGkcRn~ijRYerh%Ij^7UT76tb~fcvxf$44XUD%# zyfrh@6s4t+&tG3!h;xb{O-fQvc#EAXH5dDAW3JZj*+)aBH2(l_4bt7MEZ3WQ+$o>; zA%MV+vB*rW17z(5Km>q6!Otu#{xS|=i=-WVxjlW0Lt(IDU>V<Uw)Q9WXM(F8qQ`mF zP}{Aax@hZUsi&l-iE5xt%+jL+>4T6s`ecmz917H&MlEv?w`U;!MKIjZ-@%@-AvX<% zmnl<2)BdWOFcku!*{fK#zYw-9LGc-<wKj#;RFsz9;#w_t4Nar~ru1qQMGvV{N}%Mm zG|@uX@<g%5bByNC#yfk1R~bSVGzpVG!*q>HJw-@=%r)ATg7{6uWOj09K1`LIJfl^X zBQma#l0l}-!Q+^ng!qQl-ITmtsjS8gPowU}IIntIzkHU<(%=B{AYjnc1<uN-ip?HJ zEWaspw;T}TaKs=0Kgg0&={IEyfAxAku?g_|w$u1-I86_oBoI={%3r%hIBFUYM&YX$ zM_O34jpsvOyT90}ulln}+hJO*L#8T18}^JwEAv$#;-Z8cs@Fmpe0qTnM_v)_AHf2t z=4S_-7bKl-HRYId{a!y@eiDCG7(OQ`VEEELUM`7A3=uHsU;GqD_?i=Aq)=MU@#=e} z!WvsVR!bE-x27Ih0~8+2(lFQv@>nh~J@Jl}!oC+_CAZ>&rW@=ZVlLLz`8!9W7utME z<*8D;sTH%HV)QyUF%ssh)*8dG%h_Fh+K%-1BH!E#rMkA`S#zbWG_XxhwQU3<o}EBu zfz3TgDNq<n6<EfggzH1$919PDl#E6aSV3FVb1WUpkQkP)fiaKbc$#}aahNVMB{L;T zDL@6(tj(E0?2?yDWS|6sqC>@%_<Z=f>;>PteZ1};efwh{W&5{kx>H=>?gww^mX_ai zA-;9JSrtqz9E!}3Ei$WZcKJHe(c0O+DB<U^9(J`qH(#WAPqn|&x@65fM}QY4!Inr0 zt@4@$9iLj})+)!wZ*276X}jsDCGA!2hUePL{@qQaE;gEhEi~5ZhGJrhNX{S7nP!Bq zkRwpQ6V)$^aLEG_O2%}k77PtJbNWZ7J`eVz6OQ3><>OMM&y)SRfcH*h+cSq5lEfN^ ztOs_>;v=~orPLM;QKzpn&32}gPfBW6)eRFa*NyX?@i=Ekjk2tTm4OGSIlYa;NyMWW z!_s8VL43)hotng*Q{W=s#~&Mlo5T2uM9B*UDoHk}AidH+Vj9J@aS{)ByD6eOrJ?UX z6CVsVQ*P7N{k3}Yx3PmYMu3r+17AVGYKtU|`CMhGSnQMJ3d7djh3)vq%a<b`Nv%U* zu2nAmS=)Dy&Yy^VfyMTF8S-%sxk@A<WH2Ix1vQods5By?kyCXZq}G;>+1_n79cy^G z>FMoN5LD6{L-=}lT^r?BV0MB@2Lv8-)aDDc<l|L?;t~@fME41pg(_!6hE+zSP`^0L zxR(>eCl0v<Y?25TEFPX^tFwsa71ExrQ&{c{Y<NE?Af5?1J_m;2dmpD$Mq)C9a6O2p zCS2o{A%g2o3kPJj_58t}&~>6UJ=;Q8O;>D{BB@Ne202Fu<#?QeHzTg#N3jaPf&uC) ziSX3orQ{*s!+P!_V}|h=vM02((VnH>lfIM|EYx}!n>AItNOy+2OzJy5+O7+&bzBq` z)D;d5RXdV#B5<Nez(T?J<H-k(saRFdz<kM5Q{4cNp>CVMEh@pQ7fF&O@d>i#NG7To zlVu8CtVkLWezA77C&QMPeK&RPK9{1@RGQwMO-*u{np#_wdv;8l3T;WoeM}?#{{S%A z&^?sx%)!JHClsJ%lFkKFvA^Xy=pxC+dliO}hZC@Xf}4Y4Je~Y(H;9&oTE(_Xw))Gn z%_Ss@a*YWJu*S=^Nb=Evw2}1y4;^dRnX;2K99T$7Rz6v|%s~a3fYpa9-T=TVVx<v- zTHR?;oE!w@28<q{1}FZeyneG?u2%bnp1Ro&;;ZQE5#fX&Z&ioPxl^2uIRqbWo$VGL zPAdaCP9b>`w2)NnQV3!}qcFFnS}G+fK`2(PH>V?3HM|t}j@_u~W7E2t(@9BAx`KL$ zl8T-o7-<ogdV!6h0Fd&WVF=^j&fgFFM~B2BXA728wcx@MR7>Us!c=PRK&#d3Tg6|9 z@cDstQ6}WITBP2!XWYLk$g+G)d|gp#T|1`jm6q#fDI$()$R?d+sVIR~NkVN_eY<xy z3mg-;<n<;00HOF=rYd6GxhE|>@*M8*ZyytJ>6DpF3V;<gesynOJt7^w_mf;yT&@%M z8qa&w_xgk}z0O!fuP9(YE0WuSj9_QNj;%N@A0|wM$-^a*oerAmOV+VX;&{23tU~dM zQz>;d<iJyQZdPj;CD5JA)Ee(!X$=kA`^4HKRb8%>7OO>FR5CzlYvi3$8j~s^AtPe0 z*iyr0R5AH_d1wh4mMO`Ztj}HV`arxl3{^1%vx>b==W(?(Hhp5=e$a09-@Co9EH<m9 z-j7*Bqb!hE>2GxOVwmcxsNa26Xr{?AM=ZgaK|hp%>`ANQaUV4127$)z1?&4$tZRLl zMpH2W5Te9CF}J17;waQm(O<NUG*Hw>5?lmJ0goY8QbPs=VCQfiNaW+H#uaKx%e#-I zI_Uu>cryZ3`GM(CyV&ykG<9S4Yj;wT*WGBeHSSpL(CUf<N|EQyY-{Snu#QPAv&W84 z@lPD}WbE|uD;h((Ru1<S8+7rHxTM9+!vvGwNe51c?0<;t$HdQT*IM>lzFoYphI@Q> z%^7>MK(!Dnv7FILB6L7<2yq*3J_s1+k#rPLAXl7MX3bENraFG{$sgJO0H^A#p9p^s zvQ<&TZn#gUP0pbj6B(@hO;gkdsZvOiMn;9Vw>T%*<Eys_lafM9S8XaxIXJOmECo;R z>#T57zul_PU7Gl>*1f*fm#A%>tfbUSUli~nw6I&GK~oe5(;p)O>kAxZ0NswFJ4u0` zFAFs|M7qeM+P5aUXqbWFvt$fX6NxO4Nj*c_;f|z5V%ELG*PX%C_l-+WTYSCP=C7(W zaLi<u7+Hw}VDK`eU?>1_$5Ffwg<-Jrf=U8Z!!f0KdH%XVoLe6|6*#GL2|y%(YQUW< z_J)e+?Q+x2Ej0vI`=lx!Pf_;APyh|e`#8=q!O7?8)Y&;nCC|Eb5fAfLKem%jx_De_ z4YbxQF0j7T#i8^Lo|0Fksf$lrXOSf2$Rvg~0CSvy{ebF|FeJ}fz;Nc<gHH{j%$2P& zQk0sJ%WKll(W1WFr;(~i9+?I~^9+n~PEXV8`t)?fhjouw7Y{XADripi=T8dj)-6}V zjB`=eYjFPnIyEdL?I4}4>Jg9E-|3EedjZ|V%Op|7JZTVps9F31z`j*6dJ0_KfCJ1% z{`FcXzxLAeO0b%*!-cPnl}m3iwivgZ9ginlw;jS}!>BBGg?e->C~Y1(_+iBph2oOu zf5?<LB+%+fcOZah#Dg6k_!{qB%IVsk&&@WYr*xsT$ER!QTX~c{G=eCd$W5gcKvBK2 zOJD=l2MsP!%P~nAg$C`Ye;dc5`0Tlo=gmMlLY0}Um*=DYm8^BUxIN|5x>G~LXtZ8v zW7pT@m{Us8q^oSD1JP>Yhl0t5jx{(|c5IRnhf)${vW8=BzFyy*;<Gp|r716cJvAiO zn|MS^to|=_ou^H~Vx_J1tLSa1Ni4E3ZL+LNuT>yW6(cw@Y*CWg00Gn#*|O!Vq~G40 zwTTQT51TQHfoHhqpf6Hutu>BU_hY>mT3+Gld+vw1RmX6lpYcf~&azX_O7Y4H$sgwR zn95m;HdKWNJaxqO4;9B(GF;}DhPH3=Bsb+2r#5Pvv~E3s!^`3FC1Yi%s}k-U&<c#C zdIBlo6Yhl69sTcLV!Mxh*1gWT)M-sWD$i<a__zM>tEQ!vSt(X1z+wu#Bi44>oA!W7 zBdkxiy^G)-mEsa4Nm*R!znB)HNuhhWI&D{t@Npg^#jvVcz(P{wH4BXkZMdl~N<{+f zf5mTbwRHxt?X22zvK>9F45E(YwK9`$sg5Y+sE#(;bLzyD(LX-QM%<PXJ*VK(C1DK5 zWg#@9YpA~{$apR#Q}Jk7yYkW45O#a-5dQ#ty=tqZ_ut3EcUYlxHA(VsbzrCXc;G2e z58M;)j;TGC4gI{N6J+}N?ENbj&NC?dub53hKIrf@q^>sIH{43STX2@Y_gwMQ&s7`@ z5!qxI&N2bvff?hG$m>Ai^GvzuN}MDbvC~=_JuMXpauQ{UbCa0bqWd<(-6N(NCr^Aj z{87_tnfoiOHNS4}_L!roy<6_KYKrPBZ9B6@CV4O_DdOHX?<S!Ga9wgb-O<1}e-J@e z4xZ!(%4m5|mOcgg#Zg>7T90+JG3({l>`B$NG52%6AGB{^^cI%ZUBT7bt5Mr-b&^rT zS7Wo&SJ1T4!X#BK_^8kEBVthQ1niIy3Y^pXXTq_ZDtJl~nUd5lLXe<#DPq=lav-UE zI!b|-W+)L~%HBQd@;0R+uT=MQ;ay%TdzHJqELpIIhQ~t);Qs)N)Va9&`+uvd(lL0Y zSLLV7{{H}249LTyycDK;vO)U#m++6YJ@fcdrI{(FulS0gp>ef@nqJXb0sAPqrrcv5 zdHZ$4iN+OoC8hbPUbKb|9#T$FwGufb8ud5vi)Zk6@jv@GdsE(xHQpV0-LCT7WwA#j z_RDqKwdM$@u1-}UtyiVERv8Q+%HcwMu2Mn%PX;%NOX3rye7}^Gj!OM&5*#}$ms6;X zvfrZ;eDu6_bK*nx;QTpuv#{3tx5I{^)1MK&*SJMh6`F}wu9n?oNR?0GuKuD$GVL-b zT1DF=rC67WH7;v79tAdGY(h#vENJBP8uf`O`1A((Y88J5yZaH!8+M?&d~0dze}1)Z zbUTr2?qtLFea1>FkEv~?sZ>J3n%OXVXd-lUe@-P#h9Htg?!8smp2*-A!=<kV43(D( zV|6F~L05WhdNfkIOV}P6+B5;hB?+xSd%&gjNdz@DPm70&=kYtMVC|NMrR`>~rm*V` zNh85`)Aze~;wWmZ@fjq7p(^P2yl(PFb|jIys2-+c4602>w>%+or=c^{7p(_2Vk!sH zGk(W>9g*2yBB}f~a`H?=DJmgUsZV}|fv9lpymEf+uI};O>7u2slA_g2%?vb^Cwu}j z#J0dPs>Hhx!v{I~bp?m~z3Tlz<r-oF)Lf~rL2w!M-(!1-f6}*mCaId_sv3r*Q!{=a z192+K*+wVkP@s}?kUW9!$%x3V=%g(Y=B0we6Kh_fhJfr|4y}6f?WL}fT`k+4R%m8| zC*T5(Nmj>&U>I@EK<Nuwfdm?P(k3KeWnxgJO(YS0+oUjd-?+5;BfQ#sT2w_OmPl{E z{xXI)+FFiDz?C-fz(#Th)j|99&Ltx@8AxvPQR*GMU(y`GEeM}2b5C?BCV&G&{{SE< zr_wrw@#U;4wJwI!x=No@>DlOS_n2tnmab=aMusyq3mPh%Gsq%OUmzyn2s_D9yFKv| zZa;^ohGNl`a=`DK5Sk8((fo~K7q&kKu-qq%D~s^;W=!Hw%O9IEzcXl3ZpT&?hM(E- zSACPV8uPaLhS7Sr-z@jJC4vf?DFo>hh#2iwP#qFgm=ZEZN3#NS{>;jg#CUq-O-Pu5 zMtWAXZK~~B>8?H7tY#~N@kuk~6D?}hsv3rdj7H#%V5j0gy4TGQW`j-HC@Ls*M9U<S z)WDva(^D?R3>S~$koXPG!SfzUb(Qvuvnb(MfhYVF`AOd8I*pGyHC{F@%l4{w7Q-tL zPIUs1q=8C5c#A5;3$@7r2%!{L>d|p(YkJhpSgs?bhA$3I+-=96N{>1E^;|1LB!#Gc zW_Rdf*~AvPn3W<yq8O%*1+7Om3^~Eg)3<u7S|z%E;MBoAd(|VyYPC<fC?YMf+1!jV zVuUjm`zbl<4C9c6fh<uvtDE_GK)B3I)vZNmDN%CPaZYu+*IlUqQucb7#c*01F{+`b zmEke9H>XobRFVsTNI3@~vUhZ~ab%XwLTdUG)Z3*XHDS(j^gg@O_keni(NR{#Q!I1T zK&TdInM$w=jE5(a&pdEI_UP}&n<+PEt@&*H;d5~L2*_w1i+C654?1SoN!z~QX<bij zr;h6O+m*7p;hf?sr%6M4G8Gw6RYxnx^Y7COCTxNM%1BckL%q4!ZKM~4#SA_&T*-*c z62~nVv9UV8Jss^GM}y+-<!Sgn_^s4eZ`|4|R$FY;eW=q`YD>9}w&IuS$)~&A3|pPl zwG{-x<QW6;5dZ>+@J9f`%rgwkLC#1@Tn6T?fiwWog8>#k+41}|<Rv&nWD6x^Za@hM zvw)uSYI7PGpkD<V%C5`tQ}E?zK=kz5k|fcyD!BeEHHG7hg$zotsfwADJ~C8fcs*X{ z<tgt_>RFr8Gh9X`B|wC?pOF?9=KfF-`#@T*Ux<$J+^eBVDvPZZj`*k!cF9?%t(9-L z-#Msr@192|rZB**2QVbL^7nqQ;-xucwF7wX&a5>8?G<7gikdqGlC2s2Xy7WVf><jb z82XIyj*40aqW<st$7Nzv>Y!j>O95Rg^tGbd{5$K5+;#EY^oF3k=>0&EB&5*Pv=2OG zfXib7L7ytXj~{NRouZXFHA9zp^gqM=ZUY*AMlA^eHD-4k&^SAr2i`g%+-~-3PUZIQ z)u%gwZ?|3Uv=!0T(B5Mnl&Jd*axAfoqi>Az%eUrLB~CNeLB_F)n<UAYix+zIE*h`( zk4XGE?A{B7@iC0ylZ!1SD?zJ@6tSTMC<X=TtHoids+x9GC7db{3uh0{9zBPg{{TLy zGZwi3kU9!=s3VzsZ6343V{-~6pJ7%PJxptt)0${k@bIM1DlAONlI{~A4{YUJ{Z2le zNMZP##LFWw00K!a18P8PY}@jP9B&H3sYq5K2`W(uO`NGtLPq9~&>F{jzh~dV_L|ZC zyNAAdyH3&8Yip|dwrQ$Rfp4Y^NRbF6hmhkA03lFgmL%)lkHVBlPArb}%U|;7+BUsy zADZ9Pk8OV!#pdvg83PX_fIwy|NU1b;4ruLtV*ONV3sOcQyCcTCh+3H$R78D)9Bw)A zJd$|#>qtfwB<2UMpR{>z6N$=)I(0imF!-tXJgn|DhP%Gqr_xngTfG`~2fN#DaVpbI zsAGi-Jv@+=4>K|b^x4%|k+AMpz+mEJ;Xy$Tl6$~boUK;#i7Y3~nx>gh%n8$K-Jg{% zBZAuBx;k4_{73fAUfWxvj-vZQl4?6v$rc(5+{((vn7Rz+Fpk@I4X#%p?f|vPP99A2 zDJ5wrSQh^PB$3vS(b_H@j^dM(kHY-+0TX4pFZo2kO-02Gj-|yS*6RB`Wj>1S)gq?q z3hhr_6j4CoiHHgccW@6QGCPn!$I968)>^5Ind(7hCR&h}Hp<C%=0!P%=NovKv||y8 zC?zSFiHdEMW-Kw?0YZX<SG-M~yArm$@9I-d^z^TGirpNH1dXzZrk@9p7cN*6$mDqA zu1&^%Ck6PqwKCT!DXUU!))o(9Tb}Wp_JfCsQ>0;14pk*<QK1S_C5nfoTBfyeMu>{* zs48f5^=`XnWtytfbh!E<cJGy{S|A1vTPq3xN#Gs^0P5>HM4!b?Ea^&AL2EjWo(<)! zRk&U*X~JRTrolivQi%scMz2^1t(Mo_PUH6OS|`)5XsgjHaWLJ`NlKBt?Yb6T1CjGC zJ<6Vw#^=il`Kvhqi$HY0Ku96U)RwDweIg^XSUK1}9Xk&&=O%G_8nSh()RzDge;A2= zHT&tKYP6=G_*m@TiS4ajdQVLHg7s|E6!eKjTN(Lapqe=j00oTGNI%8ts^l>diQ*V^ zr}1-C;UV4a`s~;8F=oMGF$zFJ08m{3HgBl$sMy6x(mxG)c8;6z7eB;TRZAcST8AMT zKndYN7#Rd)<e%r(ea3zy&oe?W;hD6!J03vnD@L?vcn^p2@@AL{mqB|{&-5kvP{*N9 zfL$@7^|xu)w)dJljRn_DBhrMVjz?2M%+Cym&H!U9XO1>0&&`1^*^VzSA0UJIklBm7 zgRevCVxiiu3V41bDFe8xGu&P1T-btY&lfhA;cK_QiKR6a_3ECeU_S#RqYont6>vHH z-_PHvq{>1`Qy1E|(#%Qaq*d28Vw@_kzaeVqc}E{~zj*uO+AFo9n!%+k`p(e{N-5|s z^$RT^E`=l%<dF32%FLw2DifYV4m!pAG4RWdaIBN3;!?1hgoaZWsRcw3O`LkXdpot? z6uS-DTr}J&(=tk?2F>!+l@_Zk08&y5Fi<RP&jS&5FR;4Wy(;YMu6`J`Y^{Z~>lBjn zVQC2QOL?P1EGgtDsfDly`9qF$jy1(Njw=qlI%oW}niP;2*}+h#4WkF%Jdwmc7U6Kq zGx&VuPesLYtR~;>slPAfp*8CgR-EiK{{Rg4iw}A`%ia4|Z8Y7gf5cd;v~BX5<95{! zLCrm}V`4Ts{IeOrj3W@}+ykEQz7L9#iuuK1DK6|nf`u3hf)6%lv`cnJvbgReIaoX# zvbE~{&<Fi$FO+Hk?<&AYKl_hYN8kSRYb{0E?%Ue59Cu2$tF>u+U@UmaF+ikkX`c-t ziZH56FpY2mlJTfg`^ZLM&MHk`Mi$nc8b3w5Hz`~bg3FP_u?0q00ZT2e<3O7S%s>M( z6B_;}VwR7)6}m=_M6IRLmb-3~h(f!Q)ug9Hs4JhXOVyp;$EoG^PZYHSE>TbcfzpEg z9oeq&8Q2at!-fQ6CrwIMJ!_a;{{YC+)eMxUPX{pz*&339=|%g-j_p@&X&6r&E*3=? zIAvlme@`BG<ETC(*{(4w45KbqRcV<dfoBDYzV7j+O6?8;YKP98l>!=sISlLr-rV<t zJuPdcuAZK8FoJliUPx%Be^F&p0~3#Id%iiq&PeKu+6;ap4=75Qm1O|;z+x0>xz&IX zCBvl3ny|MSN!#yw3xnX${{XA(5LT?U(OPG!tCqFZ6*A1QDC6ZEWSpJApYZ;jBY<{< z%FBbEzU;Im&V-Xt^&=~h&bqWSi{RibSfr$duBEmXEGR+z&Fb*y+wE1{zQ$`!JKP@I zU+V2zj@1XOvup+<42o7tS>b%F@W=s2iH03X@qk5xv;Co#wTY-KprVj&>~1S&-qeU5 z+4eID#Bh_aoJAsJrGQR^6JqRGi;acvTSMGF%jhqNp2K%XynhTDdTRyFj@?mHt$S@x zH>%odW<ut&(4=mb*zAnnpXP@JMnd3f3F1?6d{YU;mO(OzNuwwSxg+xs`wBTB-aj&a zEBrRb_J0s?n1v}))Ws7n6xIhopf2X7qL%Z$bD$NHIzScb9ZZpyJ5*p2a(t8b<BWCb zpp=48nhsU^`T52szr+_<ETcyjV!)n6b7zflQC7oksi<|QNXEn@A&{|b^T`{IagH(1 z^>5^w7iRaj)VwBS!j&Y-K`sb28Vcw+Y#P^#TiTk=)me1q2B^Eq4JGp3M(bL_H<D-L za0;ND<v=4GdlEV7gW66Xh?qW1v2)?n#s2`ad|FhDNuM=L8$j-PGm**7ZXp(?_>#Hm zyR`+vYIU*cnBaOUI)yAM1G;PvQbGBcM&Bs%0XbvP7!JoGRJI2(cOyz@IeTc4$M)WQ z)e)B|Vh5GUI)~)vQpM#e{Yh%oaa(V8inwOYD#ceUY)aJ4AIrUg3x;5zkVl@h-Ut?% zIcD5jrTn5BgiTRO$?kwiYCUSk^g0KAQE7f3E!KKnAFMS+&eKnNxY0x<h6#ZYR69zj zti*iH8j?O=8PC_R7XJWJ{BIvOv{*h7hr}6jX3&(~EDDGk1t0$a37eE$yA|2ld|Qi1 z!AwvIQDQ+901{c~?MLx0%?H~4r$u)K^c{6(sl27$*<I^HOIJl5Ei7)xR7U}f1&=OJ zWan@S4m|Zt_#wl%rv>73uxi7hGA9Slm6a@zTxZN(UDwe$NqD||MEG@!;ruRq$+$^U z<x704M8Z}{V4Tm&rGNn0sS)k=Sm+8+n1w88b~LVY>2gL+IsRkq*R4<zpf3EsSn^Dn zib_gB(eLQZUX+US+)aOE(w7Y}Pk6bkYd-m7G<2JiX7vJ$w>v-^v!5jAsa!T3lqo|` z+udSw1%+U=q+PWhHK8`p7S=3J!@q6cy1x)Tpwc$^n$=fv)RZtsTArBvEDJKpHCEzy zF+!6{K^*Kj9^D6tLdrf$P0f61eK&m?KH<p%YD$BEJcA!}se6k;Zt6t0tL?I}D!+K6 zR7-9{5(xb@^w=bkNIre+KqG$`Y;l}*Pr#%hDoD7l@7Hl>5qlW5%2G_X2FAs?H&?Hi zhz`kps<xZA?|YV^G!derTi{5<HO?4=>#lx4S8)XtIdT60WS)%0;NK$UBs<i(qwV^d zG*3>%O2sIlUzmq>525yS>sXw0?x($J%Uzy*HE^wqP+KZtxm#z7q2iH{Os*O?QXCF8 zx4MQtGCD~hFXyudYiizJ%REJJ;HAzIt`6W_zb$p~VIHi$88ze`*zJy(?ga;THC1k% zxzR^e_*#nE+WDy2NCpEV{zDswjHyr<FSmhnNN`jQ2;84j=@n_yjHP_3iK<IGQ1!n` zTa-}kHQzqo(06y@2enCEZPJ!aGpla4%W~9IffY0YV*db!M`A!wvZKZc{EifUPPTkL z4Oq+~F;YQN$8}&cxU!fv2yeU_CUBi46Nv8`7Th~mp!V^{pNe0FElb`10E=$mYa12c zOs8dTRP*agt^Ny~Zw<1N3V6R442p!hPR^xcQn+tbNGD?fpLXJIBLgW#DNvZYA;S#K ztt<;WX=o1vfJfp!W+)LYBo#O2XL7=q7c^+2`sUT!OReUFd^o*Lew_W<YHA{mNt99m z;gcq-3|t{!P;-V{`}LXpD_>}T3bgi_AWjOP3@Fv2Xu$qcc8j;gshyf$GF0Bp0wrVt zLZy|db#iCtqeL&P=(QV7CDQXrJujgW!h}cZ=g<H*BZkIs+2<c#I@N+}ks9BuWhj|7 z3=~gaCgevgV$%20$sMvD++1mvoX0krvqL0#&en>R6@mU_4m|n&D9FVnN|=<LPP@gW zVq!{34og2VXM2lSMR25#@if!akU})lGT?Eu+Xv6T_B|N7C^)T;zup=M6B1OjtFyZc zd(teoU@5Dq?ES=<Hz-PKKnmnOJytFW`=D+zIyZ!rs}O|}z$q{1PY9>>%MP)_rlBgz z=vy5_ru{|3nzKatak+jroY7h|RW)={`ibhbKv5)QWkz@a?F1eV1o+A8&`TxRDRJr1 z%b4(v8w`}m%|8@VFmF;xI^}1vW*n(ldiYT7CGxuYQ`;Rss5J#1leyl1ekC}VEfu}s zjeS|iKg4+Os*H1z!^zHRld#FcsaZ?C1z*%(lT*ev=^S?(#*Q5m4or?#p+caO-E`SG zu3xAXiw&%=dd|(QwH>;PPF`=Cf}$vBsiu)xVKmWtn;l&6s7t%;I5}g1lgtJvDJu=r zpFLws!X{d!1(Mlr?MKtXfK&}=%?<Y+$m=L{gj(;o!a3@NSL0fO9wLg3VrJZNg3q~s z#q=LuqsI>_CpG^7f80NePVkN{h2rJQmf1?u3%C4$vr^R6qB;1f?e3rK_OI=}o3PZ} z>e}nu-8HJNqN9b1zP6d<e?R~+7R80|**SI@JY#40do@ep?oM9v#WnrUT_aQBvw=ZM z1B}EC$Z=LX@}ub&mrcu6pneH7F_oyIO3Dh$?R1c`kt9`a>{u1ZR`g6~Zhle<pB;XC zd~n9E6We*oRWccAANYXOeb4V6hwOGy!G352Fjv&PQSXKQ1#i*xU5L?|?wnh~+v>F( zk_2tBTq-H#nPZiY2+JE%MhBK+<DI>27}N>DKmtMbXdU0uFwPk%NeESlWACr?NUqxI z&rkcqR_Ll5Wn6+e>q5#wEmWj4e5HmJer0Ys`7z}6nd5MCB;x{tm{Eqpz)<;hi^?%M z<PyXNB-Wht_5??|e@)yf&1sT9#E2}hNi@hq4Wds$6yyBhrvuml)!zXnE3I1JuR3~| zsBt-j=14Uae!VZ?Eeu}_y2HI)p}u!-x;?em+K)r)%WM)@(&<a-yzNfE#F{6LRe0o$ zgmD6{4<S#Ho~5zeeK-tO8ceS4XK`^>7u=e=X&m9a4B{{o=B!jR7pNn><4QL;BKU{+ ztnSZ!wEqBY>AQ`8(pT&4zLr~+ZB4sPQQD-EqH}>AOH!%|mq=re_>7>bnm`Af*m2xr z8H*n$5J^O{-41PMYSr8Z=17XX*`*Q^lq{%})#}8OSuM$ld+QWB?>%iXI1jiMAa3Wu zA544W?~cBeEfgs~dqXLgF!xQJt;y-mHN`j;F?vPXB2|fJ#tGz%pT8e)I#<h4Q6k>_ z;hf5K3W9ZEquY_8hg~hDYA&m&qN@)OrJ^-ZvJ8jgjJLPGew}qSAgA>A;|@;5S2_V7 zLt9>+7uF3lys|@1$x!YF!Vot&Tw?_P0Gs*0k?3-O3kUmturQuM3I&?n%jtg@?Jw-x zr;ewvx64&>y_&N9t{+WWE;SU7B`qCXc4%cT=hPd{!dYP|RoWm+4UDY=+TVygQ-n!E zIAZdGRHYEa7s>$qq=GI8Hqq-Ehr^U$@xL<-h)#8ePzAxuP)M(VqxeTZzAUu1z0>~y z5<T0s_X_VtcD>psSSq~~SmAYAbXbf@z5O*il<eGgvmXb6Gr~JbKZ@azjl>dWEL67C zqXueGzONMC*kFzuh|QHRyb{b3bT+=VsJSm;6jrK{rKcs@Mw9~k)Phj%$sinL;1T2W z>R_-B@86U{ppwb|0FCYZ3)pLD8nN7Cpu4qXj$%HP;zPWWo1-9bGtNi1*Q4g3{Lhy3 zx3dYuOj=~Ix9&~L)QbU1X%=Hl>FBiwYi%07i%>;#)#mqewpCb`n6!q1o{FBSt}4TY zMf^)6h65!xE^zo}Y@`WcU*=weKm+D+parW6!(wJinzVq(NJ|oE>n)HAl@n0T^x7dy zgiR#1UV(R<?q(UtE?t$ep98_c=cEvvj14#Ur$|2*ge7^>ug$&e;rW5(9RRU*uIa0E zZjA0_O%?8GX)Ul<TNupqQ_C&@ZLl+K%P<*X&&m%t9Z2H4CW-h}OqQRQoM;7HRFUFr zUMM(z+hQ=hL`cLd=KQjIswcW0?_tr1CM@q}z9w}}mY-1D^%5;DmU?eaK~PT9P^?0b zg#Kh#lOW(=k2nLS{u^N@@JYCY%_rq74w-?W1Eu$gUyFQS3)(C;9Y|w3Qi&UYpvnZ; z3mrqy3$<_fs@0ljR9ANXk|RQsC4EI-;nG19!iJyIH3w<oOEh2w19lrZ3VN08zZFIz zS`wu?l27n-bEokDd@t<MzYtOeS!xs`M*cmDLnp%DXR4~N+LK${uXjpKIcy6>MK0Ej zC%YK4EUE@Kv{8uIg<La84t`9WOSD`^B&5qqd&Z<}m$hietBqbY6=4D<ujR}tORzPq zb`5H`D$*^rq)9=1yF(Qfb!%70>l)hD9=&B?ci0#Vh|nNhAi}6$pyr!66-#oejRlxC zwba7ql_X|Ra5p#i(4E9Y7b}F8iH@uMJw-LDDL||=-=0NzHl%<bmPN^8RPa>igM-oX z(v>IWJ@>0MS-qGKDJss-`|3Hqw5<qeNv`D5bn6zP(lr#9RGt>4lAe`I5a&2);_`q7 z2lDfuwt9O8iG-9cn&{%z{WkKB&PqyBMM-*^7r(J?klm#Fe=S>EYwk@f7XdVtY?9NB zwXRnR1AA=wPDcY6_Z>Ss7+B@}zfb31NKBbw1ZFfMmTqSB)#4`jva?(?^`^wp+$xga zS5W}1fm`@BRS?t99CNas1j`fpVDs{^9Zz;c9#xu3MFm6dS^Pg}2Z`Z=PIQ`^HC!`> z-i5W;v_u~Wkkr@wQ2al`Z(6wPskP0T`wLUTUCYtRwN42KcGmnM1;4@zG2nH5D@j0e z%hz3-NQ#<Pq~)%~`nYc&D4hQQXc`0GT7eLTRIAYXW=~sT1(pgA!Uqa@0g{9gF_3sZ zI%5b`H7d_W+1}Upcw%8a(vF^_?)Sg8rINu{b@VLu))BNc>AaLBNEpdJ*Z>p!vC*?e zM$O9C=-PFJDrCX}ow>aop1oMJw$MS@d#%Sx>zOU|)%5l9(wX*peoZrjg&?1lhD2;1 z8-eOSis2JFPv!C7@763Gm*NVQjZD;@>YQIwqmuR8Pe{9#-s>BjEk}BWN;Z$r%beo? zl5^w6bK{OV&smol?5gIYG@AbQewQ2T9+m8G#F2u;5sE_}Z{h0(ja_c6iaE^0M5q@i z;C=9MoR2^7I>)$|0G^eQfJ^A#PcK;Zr(=F5aQU(lrvo<cO)0H?BKCGG;fF~4O7=SG zK^C8ik66R1>1jpwD}_teM@CgzSt;n@Afmy&b2b@^)J8uloLc)Q*-ZE%K*>C)q$rX` zN_R?ZQq-^cMiKF^<07yeW>`{$?jQ2As!$mZ&2kW=P5i+?gsCAUFbbt(yq4QVCY;l@ zEir6Ml?9&PM-3DUTR*8eiYV4K^$BJ&NrfAo$jRJD>vNHW7R%5zJfrht7sMvbTBgiV z&Z6bcrlz+w*UqpbckXSMNLT6hrlFrwT<WSZZ=k0rWvmFmy+LOLM+9eR-a*{N1qAWY zA#56Fy_;Vc4JlGuWU}d_?aUw1w~0QpzciXxO<%R9u8t}TWwxGMrC31aVp!C^($AN6 zINBKP$s=kn1E5pF!7s1YBqU>$S7whVw!L9JyI0WnCe3WLQr+$M-}y;RLeOTKIsIjr zDMEiKP_Uoz6aZ%!0?~)z(!_*-1u5>HuM#sSDQhUrLI|-P4%9kV#w`V&@29(?aqRlo zXyUip?pORR5R-dTdX+<TCAcSODg$5=Fn#*+intaphvT@MyhYZzVFtBIjXKoTA*wfc z_ZI`>SUw@baCqqiG`VZeDMcb#a7Au>=tN2Vt6#xcd(nACdE$!es0m^)WX&@&C?3EG zJ@P(L<0GxN4ZswR7u+DL=gB8ZOJZI5hZ?r}c%QVLqh2G3%L@b#!c0H;O(Y}>H97-* z%up`+>+1!k!Q1^4cDl<$eym?gg%Tf7#J<0$g2Nm$GoK)__W)qRO~Or?jhdu@q@?$& zR;x2t>(Vri0g6r^he|}pNm@%#l1L%R(2B6zks>C4hl>Yf==)#Z4(NABTHox~%`sW~ zuFY<usD&#o5I)&qhA3qWSj$r@u2%s*Vom|*iMV-FaB5uDHdl7D4pwda;v0yfVaBBi zPjs;+j^s1m?WKH7Qj1N5Q|qa&GdHPqnpe12$?6#yq-G>8=JGybGmMf+^VT=TG3uR% zD^jW)3R7}vT`3y|6N7#i5G6_sZcR@>4H|{b2Pm8FGDA~OPiLx)RvBk8)Ur2)kwXUr zf0}s*$j9r|9}ZTN^C_4I0<TI}pNNZ&B&1PAhPGf0IaAM^bd&aL>nm-ezIs~Ad8nh7 zlIv)#wADQ9tgkFl8DlO_1eS*zzwLecxAq^2Hya2AkuWG(gWNWhB9wc@AL0%aP2pu^ zcaWg2V(B}RLi*of9*3IEVuB>OTdC{>!op8URyIjM^R;pE$&bpz2gkQtV&npI0ZQB3 z#ul7HTOce)zJGtylNaCFfAJR35v0C4qRR5H-TQA&5S)1?8A;E!LZ9c=xk?XEl0WUQ z+(m9&@TVy%1%-|Fb93M#2h{%nv-kF#X}HXqN4p;loytI4DI^JQl2r#I0-}uoW0v5_ z>@r85g_$iR5Tt@O4eH~@@KQL8@RO8~<mye!(x7hC7OR-5{mAV1?I7*#q+I)j@S~?S zjgSXwi7j?0ZmOyq8IVxPBPX{dNFa_+QPX%16+<vWNB#*uY~B%I!S=TqDu|l2wMJEx z)!Bh^-XQD_^(KSj7K1`++g0ZG@XMmKzO%Jf$5$CxD`+K#qBab#B{Yr{;ea?~=N%?T z2~tvJ%g9l+Q|jfsydu+!_@j@REX%}VCo%5TNmifoDSsEWnvt`+ee~_uf3-vFU1Mg0 zKx+Doi;c?H2c#}idregV0y8fdX(RN@j2GO)cSYb=GYK!{s!Eh7%tD)5SonP+SF=A8 z&HxEWT4qYwebNTuRb;c4J{M?WPSadVT5g&jQt4f8%OH?atzY3PnSl%df}?6l#yCfO zk>jas7ahaQ?gbR6=%7-39h=U+k+AR&iWoVHNmwZ^Hc;B>vDh%9JZrsPt1R`pjB2ef zb+GCKP3Te5RZ=xPkSQl85(O>w+yTk{T@%N9J;QjOX?WR5P!@4p-NlR6H!cnFEUsGd zG8f-a01$hllULTH&@?<)F7dn-%?#AlR8TAt8KhPW%u{oP2N`j-zqcKEZ{jDwdGhn( z_-Qji4pK6L3*M9|8vy#&HcrfZN0o?{5iwanZu8Manwq~Y4@hOzwMlW)O<8Rb)Kf}b z>Lm^Nh+HSoo_36TkbSzyJ28g*4-@$lkM|M?8v<|9gOct8i^b1~;ukv#CQE#-4%8gE z{UBGgz3JCoh1WLT<M#UT_$@_8A@tfnWQM9d<>07@97_YLvOH(VBn%y-Yn(qB@+Sq! zqxqgkLtcj3Mtj;$$>2EF30ylDD>Sp<M#UuAPC;;bh@!pg&@t(YmBU`?d8|}gPWN@J z7V9k?M3oh`II<*IW=27XKa1%_F6JyFeZUnt^(SR}LO6yPOhOb2Xs028*~QpuFCUk` z6n-0G`!B>!7ZgOvWD_k!Hed@Es|7vY;-f+!T6u1D^vfkBG?ObiQqnmC1K$II&yVNy z>dHcrPHm`t{o^S_#I9CpV27m`fYo7F-LwLatVms>f*AXkVAGoCRonE&k(eUaNc9U% z8zuufG{hB5{NsCZpFVn|_VbBjFqjFma1xBkqw;_p`5T@?tX_K`D~4h*vZQem1hC}V ztxLX84!>86Gpaj(*&Cj#y;yr^ZoOCNiiD?HAnx)SiGe%ir1JPBj?i<+z!)HPQS6>S zj4uF*ykUgpIW%S$4^RPX>CNI_wUYQ$+;Y>xr4-3cfiC2RQy=*14~w;}mu>zP>+7vn zxhZMuC9sG>!&6$zO9LW;GQ*Z9DtB>^K?Dx64~f6kANoA?@VP)sn=F7rQdX*mv1EsL z2DhPz(xs_jO1rSRB$@y&E1{>}COuDc&|Rp|w+%mKxc>mk8{K-vO;J@z0T4qFZN@l* z08V!2ck|DI)+^b5%yx&hJZe@yiKFF8luFW-7h05m$yF`0bOTcx=fj@GV)#xiJYS2X z%0dasi>*o_oUtHNLPnh8ig&|U+osUd)7y>CD}?pXASOH~rb96$harOn;0^{)Udrr8 zWJVO3)6~lp05J`{-SlF>-;_=KcKC*jX9fH=2oPp1Ax<<+bS!Gx-mzWnzY|@;utgm{ zprX^-;uuaN7ey;k@(d5+rzgfTK_`xS(0|e8tY=G#?DyB`<4Ey<!pqB=TcEL_Jl?^^ z`ijI&-7e@$+|5NrbvAQlg=r&<!xZJw2rZI_VxP?4Phsb&JPrg&PE*p}JJ^2448Y<H ziEfk{)x(UtJA-X47n9+?;ul=W+}lO_N@x_l&t<k&U2d|-qJP3twJTCdJ177FzP2r_ zNh>6gV{yRhyN}_<N=kv&l+x$Jko_V42g8!AN(|GM`q|40wF%ai^Ml&WTSqh|-Emsg zgsf^OaGsLZCf(6vJC%zNc^qVn^++-qPcGVyL!F;m#rq#FS)K%~oQ4AX7uTbMXlJRs zY=+TAM2#ij)Sb-}1bL)qISSY%6AV1(Bm<5)CQOv5l_mX%Ckca?nZGykq;Fe3&*cmh zy~Q>=d=|T{iaXt{<(eutc+S*+1f-#rmwE$`K@2cQBbQYwrD-fnR-Fd=?dcr<08f@y zm8sv%y}%7n_UU-LUkYCr-T3WAnl9Jq9dkC3(X~*?R~o}hNm9a1?U#n4c%E1osoLrr z1g99!LvaozKMg5HO4`5i6s5o*`O@(B1mJjFZYfgnfRZ_|Bb!jSSgn`7=UueF#I~>0 z8oKvea-`LF8j5SpeQf2Q@ul+(RF95H1fwzVt)C0mrF$R5juu7_6n`xAAS$2a1qs-b zNUvff%fsTAAp^J#8o-0MP5Q;p__m?D_P5~F+it&zu-)}#bTik61zN5LP-!XW7|TYg zjXuy<4Iv~F1~YEj_K`eOv|>c1Jh@cT?sGQ9{R9ccrmG=Q@T*r==ggY&jv`h~1wAZs z!xagIcn#{16YGzl$FchLBAII~m+fsWtY<?K5j5v?xpI~#$bCqr;u!9?>zmLN6Rl)H z9IS6r()vw+s#G6+y9d(%cpWUV5Y2tP`!OU^qM)FX$3`HU{i5LaYNBg}!u@TX;S$?z z(M?Ml@fZvv-T)^f^NzFJFbSEiQdsXz4^OOG<`t<0LW&mZPQ=^m75d5ekrXCqz=*`f z9Q~g>kEU`n`gJ3UnB=72+n>92j$tI6+1bZK*7Y9i7MtPnp=fmXb*k!*qXNN5?n&Wr zCM8dwx8I}qRLqmmqh|#42JuksP9AjJG|4g(-k6mH)~?JoXQkrhd^2`$Q~XNYZrV>k zU$mM%sdwHct-P!eQB!XK;c#b@40f`^9E`hwZM%W%WM3J>q-F)*L3;CHOKHjD#?yNx z#HP&og#i}tE4xuZ;1Xy>0HGvB{LvZ<M|MlP8dFDEwL@yks3ETX_jX%kl$5c0kwS?{ zO%s;fiV|rHO|-cyg#~(rojo&GnVVn@1Ztg};opOha;WB4=2oiQmln`f%v<|Sn?_br z#aCEhzv`N{EGS@&7@=YEnPngduz(%8&yFy84;VA4tOX)V3bK)qEAxBb_J|L??G<f{ zT~gAtFa)t(0;_?CM5KMgjx&?(jPZ_vQh=!}2frwv;5EfcUQ_v!Yf5GeX}_829P0g~ z^p%z_{b@UGL~==4N!tAjPrLI531O!MDf*JEKU3C~@cMdF!xF6=_<Qn=tA|ZFRvNjF zg=^7?@bQbcsPrX1nCupb?G3?<^|ZD-b*`-!a~GOKQ!=PO_N0+^{jrX}IC#GiJYThQ z@rf*z&E@$)VO!WotJfr8_&3TQ;Eti%x@vbGF&b)1u9)p!oV`_4R9h`|(o8Du)s<NE z(ltLKy0%8d@BtazKmhUApm6Ccsj>0aJoSp2lpumz$Lnu8#~rmle6+RWRHF;))KXGb zR7p=H(YTG_a+^>bdW4pZ3YA=eklg{qdn&oJS2ZkigpOgIk4-DHh;IwpNonpb!Pw|+ zO-t{-u{T*LtrK>#_uL&daihK1q-gE28Yct(a=$ai8HREJbys1voJt4eAW7j;Q!oOO z4f&1#0ElT5<V<H!VOkA2bFbXI5?VWGww-C+y*C{!sX=nAdfIa$kp7&s=@|~Ely!BG z5%n1vQl#g-x5VVcqG&Wo4Nw`2`1(e>*^Cw+Ec_-FS&&J}rfkU|GwgJ)hLOq3*sO}< zU2c&U8ENNgZJGCsY{&XpL+9VElMJM-8O3u4an85WFbqx{Nl^+7mYPt#Yt5^871S$G zZPwYNc&Y^MVUcin+n<=9sUPYjboLbqe9X=31EUbHn#RHU*J{6{CZeZv8#L3f3gJXy za$m^E8S%$Y9Icr7?fOEdI96wK^z(_Uwmzh(s*yyYQr74c{Ruw*09=pn(CZ;759$2Y z1tG_HJ!`MG*XarDmGsnidU|T5XQP&!!paF4W(NgNu>=A?PQFmg3XY#hX;_bzkSs&9 zJ^Dv;zh<9)dj&=Cg<GNZ{;IcWtG1u+443Uzmp3s*9loL#5Y<tzEhzv|`b<!Ik<8@d zYb$ilGla;SC?#Vsb|C6Mfu*&G3E1p>o+vR`XG<fxq?&>aLDO1_f*rw*B=>h;)m*h+ zu&bC{t2GV6tWQ@hLYFROou-mjKjO)C`Hz#h{kn69V9_p0WlBpWATYBHj9f9;xs&HA zbkGS?l3wQfcx)&cqA0yil-AlRP}2S~QUq#1V%YLW822A;tb8j8GIl0fC4fOAkZ&H5 z4~fM{LKik+U;#99%iVn-VB3ELS{jIiQk*l8LzH507-PUZ`0#pnjbIli6sdCn%n8+l zZ4dDNEBJ{?RI^|UT9#wY{NtiuvoA$y{^x7%>UQQ-x6^8Sww+X@xYa8<(Ni?79aqS_ z5PfQMxcKz>AZM#5W$-fQO2nXnnNrB`G-}^NLMas9-*$r)gv4NH$yad`l2iq1mQW{^ z2mza+g4<8RKX)}Arm$Hxw`p}9x#<}!H%X_6QL<F53{VNxoXsvsZOR#6Z@%mdhqGOk z<0FZr;!?EB{G=8Ez@bZ%=)m)|dYj@`#9qd)v$M_ubd!p3xmf{HyUGrg5CbKi=9@=I zdn?(Sw{H7=VeQVQy3*Yww*DdtI(XuWY38PBo>USqs04d`Qlz%PFaQLcb?6Qg!%V3O zlA;Muw&Z>C9t-1KT{xMl#K=PlfD!>CMkUBD4LZe6`19<YlB2WQ!&J~(EHt+ZbWvPq zsE!@26miUv;5p@-nUoSh0B{GtQJtf}qvWo7LlIwA^K<DIe#T?wqIxA25`svrk95C5 zLvI-8*Tb4=nl9OQa`mVtrmDV2TW_^l>0J_6t&(+`NhVO)e8eIO8Abt}0X_l1IMyC| zxQwMs2j!4Tzws;qHm=tV9=72<qLO$K!c9U_Tm-YZd)|y3zEM{hjU%X4_FETfEtdTW zMy+$cQo_HzrVPi{tzaHVk&om>Wy1h6%)d4QfHUl5)#BzUYAzhGYd0aO>FW>^R1zo) zTc5gB>9E!-mD9LYt=x)BYi6?NB``{#N^pru2RU7y1Ch>K!kpx1B2r!y{v4==4^P<B z$|9y>)$>6DzJm7a&&i;+nJ6nX{XM=ax@*Lh6)0KJSgr(Mg<aUnjQJTKPNUM534uxs z8*0>Y{zP<5LS>YL-ZuU==@1R($9l2f{{VNG>Y9R?)s|=|e-kY<WMxCf2yai@Dt=#n zrm)LHcgS<SAEDo^HHA-64wQ-=H9DGHzq&=M?bS71M0C+tM*uM*(-|I>;g)QvBXDMK z&F9-VKYpwD$%z?)O{yvz*w;;<l$ARM0I@wCwy5b>spm<4Hnl``pYnEwf<~=ciprX_ z#|rSl8q)~^w}8y<Mlqao;B|ZKHV?j`GT<}brl;A6UO9?dm=wDj*gv{`3<P`?cK-XS zJDq6m)jqDecE3w$O21O4P*cSzGu5zol8Pq?>*`99>>!5T9ivcP*B9B+{#&)QqgA)} zw$Tt>R4F|w;B@DGS~q-UcY4{b``0wlYU@6?xZ5oB7HXTlz9mR2=Yi*W97!8AQod?- zNi0NDypgzvd|@P!ych@sSbp06(3DG3)E0q#!}R?g@mValpHi}0w)am|rcsfCxX*+1 z1wxLFT*NE7Xnp#=tq4-uh)b09dfd5h6X6Ehp6R7c2WhVwV%JY?y+dlNg7s5LD!Hzy z7h0x<SvI1TK7+#$1ju&A4&X4V0yBKY1(y`Rt8OSZuJHWKQnF-Ak~X;N{L8q1v9wev z6!ZaW>0+6h1P<T=K_qfG^PJ@UGCIuB$NvDhs_J(X^Jw;Jz8eYBrHP=DQj&Fg2m3LH zS}IBkR+3t3nwTN^8G&FGc;_cP4hhdh;y8jPs0EMa0hQPl;fWGd#rgimw6*IVivIv- zKWeU>pzO^0*ICDTq10NEpjJ`aYhW=ouHLJN<Ott5mZSWk3<vPUIN{0a!@_VO0minc zwsIrQe-?QE0MExO3n;o}fE1uf3Rh4p9fq33x$!&kPog^q+l@io4QW!+QPRNeM`@>h zwO7iGzb!mD@TB8&o&W@%20ESvD1A@AA-=mtQzA;{OcKzVfX0>@w*4s;1L0d*YA*R% zBJcjN?j4H3a=$_*l*dHV#croZV3cw0V35NY`EUmIY@S)VVBw}YIiJJbA^cpxl?e$2 z?7-?gJv=QM{yF=%J+rgX=&N1QslR*Gh#E&tvd>Q#^&V+goq?axMUN)~abuD|ki%h* z%m}zX?&dzgtyNV4Pl)K`0!O_gixb}JV_J%PL9A<?Ofb{JAab)T%)volAdtJ87(bT> zj;1C}C;(Vc4|Q&g#wCh7nG}a&<R8D;t#J~^V0(pY(7p1})OV?_l)8Sh<pk8%0}7gb z<STmFTx}|?k};eN<AK!=w_FhM93ELD{Mke(bIM$&$I!H2z8!Ys{T_Ogh@^?hQWOa^ zB_sv5r8?EhNxdVXIy%0Ri?V&fto$MqRa<r3T^nd;^w6AV{M@cb7#{iR{{X{f{Ja7K z0!eU^NOuIEChJJXvCCE?j-@k6Or)V8kO^RgQ^2ucbcj~zr|7j!1*+9uVAJ}B;tEsS z1<sB(G|ca~JaRDI9t@I7a7R4nI42$9vfmh_=&Z%Az_Smj2UpRfQC1~s!68W}Fd(Yn zzamY=1us?+P&@P5-9_0Ch$-~_$)dQ?(@-X%S!J0~r=gJl018PJbD351@}JDg7;r)A zE3|TitOV(0L<Nw9u)EcbX-9arc5580Rw`8BfUKabf^-1#(9}I%nn2#0f_j>ZjpDsn z%<MO-DdUaK2_7)bj<J3*Jp(MMN4lFar|RE8%^K$omzWi-5)B!xsk>j5Voj{1OQlW0 zL6+YEq)LbYJO@Bj59S<s_8nJnvQ!nEfO3)^^dvsDFLtAdoSQI}Wl*IXsAgaZ^FA?e z{{Uyb3_6G6tF)HbAxgS4QF5CPIz%?DB#gr@j0erRKq`HJ`t@<_t`{~wSI!PxZET9A zZ@g1`Z8#1eFeK2o{{UpvpHI{m9-k<CZ%surR>h@COxO?U)=`b87#Pebe)%Vi^|PxA z5~c#X)9Fh_3G)jTJv8YPtpwDwFjgUsa;J`c`m(5)sz_6u=iffC_=`qL2WBK-oMS)C z<FAP@U?A)7#wxdYY`+wGvr*eL&tn%vcE?N1NADe5ajQzJjVJ);@YHxK81OPfu{?mI zC#GiNR+ch@n)Dp~ynLBSTApEH%IC(g!EODYeU+A}bw_q%)L#<az%*i(eM7IR67y5H z)7Z%kJjl?&9|VO(ED1k1T|p%O0C7*tPbxRoIG2{3l9B-NuUDx%p9@8N?w{;%_&wKa zQ*hJvJI7~rGqHH3u~!+XljQ&pz*O&yryGhXPzcUPJ#|ykl3|tR(ARc<54|Im9q;{- zpAg-wmg%Uw8(m}X9+8EFpSbB>Rk-JA-eZDfRbjv(7D9Qz-HwU*!D#t&8AH>feL2AS zRE1|kh$5~VFx+!}N!BR?>I?Ord3$r*J65IA710Jsbnd94CLqJeT6&bq9GrZ+Pa_AV z;qd8{Gf1QLX6gKRn3BSFmmh~;&66;Knz$@$YcVZb!__{U&}&H?6x4S+48~TS<)nq` z>BH<Oe}Y`hi-Lb6NsfL|(RgknGEoId&X8Zb^&tJcW70i>{-NgKm-uccE9Oh63_=yP z&vcsD0oEfuS)r+-(Z-(7a-y!&Ub52uK9*rq1w6d%jhJDgVn#N|5rM`4>lFB#*&IBH zitPstmg-OYfLw<RSlO<i7#rR_vGC{OX9?nxQ-Z<I1eVGaNLaaIk^{ZL4m5qMoh>Gr zv0N^e?O7C;*aa{#8Yv7;^Qi===O7ckOCB+vu>4Z-^X1E(fO%?CtD3#iE_w=qL(QXV z!>&S9znC*6AhxVul^Ljc4dSr(g6gnSS}nO*SQ%!N<($M=?H&jNC4T<^pzGBi2~##+ z24PA-N|ZJZ!HBn~sgKRi>i+<X;pg$JIux$_xo1!DQh@@Eb|Y{a+QJl9n(9dZ039tI z4RtEb8j@cG1IXhZ4jY5-{JPab$*`?o%d1C}6@qb+VhFw3zBFdJ9HFxJBh=mR5lKiy z1J#x!U=tDG0KafBGyad?p!>kPHoZCT3zVfTVp3H`1W|)K5pJhJUE<yLp1ND6im+Uw zP_jUX>mnVpM$!DZE;GnGvGyaXu0C0nG@(>}RIOdLc(marsR}9wyprR4QuG(AUd<Pe zvEBF7^jB1~)@m7zrD}yt@Xm<n$s>U)aHAN=W4FgVdv)dC>I>lq58@a_@nlN=`cs!O zOCIQSQP|rpYSyblsWNCQBq;a_{_V4A1iuqKy_%<5P~GftK|y4!rHuk1D^Ddr$S#C@ znP61ldGb7-s^8Nm#AzdhF9*a{^QEZN)j<WA7po+=eHylpLiPqv5#v|@l+H7zOC)3` zzSJpVYNxH-h7R!zcL(6NzVi0BS?XJkmABe0mir9ao~YH_A&z*d>B754ASZv~8a8Pb zEN*DSu;3Hc#o3SQE3({EiXIokaX&I^l+h$5O>&hm%mJYy$6)3H_>b8Pb{B=rT0l}< zK2i=+{DH9q5*;AWkZBx}zQSXnx5*nyhEGx{N-qw|<m3`LU>nYP2j8zm$(rf~fp31i z<I8g9A=F&40of<PzvEF~q%u@fGn*AdxsFMO<lUTOdBO3Ybv$(T8F|dvC=?rkw~-V# ziw70MEfQr+&T5i(*5p^rJ8K<!{gL)fMW}m?@datn-NdTx$8jUk)HRmNx8O}$QO!%J zt@Q_dA(wVqnk;e#aqZN95<-CpPjpm`s8V{;BGr&cd4P}(w=M|K2PN;e(D~n<*1vD{ zrACjL6|1Co_38{Q8q~X@pIu{Oali8qnECR2ngY}E=BW!62hUygc%H*f&JwikPYN}( z6#Zfas<qc?=oiz}F-?08^bKlcmMJNq2ndy`Sv@vJV&DWj!A#&~NhJ$;fMqNy4KJ_` z?mW50oN+{aoXPJyQq5Z%zV_v*h8ukvRYKI)YCSsjD)FeKYO}JdYmb$~e8Y|pDoDu4 z<EQ74LC8xN52&TbKR5t!P?AjavDZ<^zMQ<ZiOaSW)7!54Cr;UDDQ8EyR6Rsg$A94x zRAyWZpOi5x`VOM_gtbhNxyyC{2S0yzF(KKUS{1~Sgp8$6>0SQ-bKwRweY~>wAL6@A zcIy2}Pj#_W>nbSfB9Sta#F8wohuEtsI+5<ILF28@4n)qyAxpTI<9|b=^MLz7BuwH{ zSuV;a!J${}TJ9x%=UUnHa^5PXr=^$ivd1LFy&{R}pp1WneDVN3i{q?&u$WARa}q1% z=|_tH03In)Q78+Ix2DfcT9L<E-9<YUf^~wTjin?kl;NF!n8zf09y#k!`Gwhow?`cY z$Hpp4l7T6}B_!AqDEn;t3)nOi*lT8}nA6D=C>#<IV%kvW0PZJ_NdExr>6w5M<*DmU zA>_(k22x$M9xlLJtLP%ucT$SwZq_r%ZjjZ-TS*M0M5ZvRz<&+L2P#*=_v)J_cu+1Z zO?~e1J2q0pz<{k8-Lw{AX6dv6+v*}V*eKLWC0S*U2s{>T!v~%Y2pRY2MJqDH?C<Z_ zXqv*3nK=|lQ|+g}IJjR1ky|UTdWs9g4^?KG<!*|h9xy>bT(a#TgTUlt2RI`gS@==_ zn=L0*4=AT`IpihM2~!=ab<@-}qp3oNK-6mLEnRA*w&_0PA(&b&R%n)%s-{GX_|Z<0 z0Fy*vW=RmJ;n0!_uUbU@A1J9RmRUn!1Jjm=`3)Jg{OgdkEE19*N^HjWp}0EICO*{Y z`7Bg=uJ^Cw)pqKe)Xx*NaYrZNB3+<KBFI#2a!a0g`AU#ghd?eukm}?Q>0eJ;S}NE% z^5z620ZP`4PQui%i-`BG-f7D$v*{W1ypet#p<0-)5fsijWTFx=Mm|dt$I18Uf-{#7 z>OEn(zyy*-8M$-h-)N`0y6Y|0pIgzxHBx$7R^)`o8Dd1RvX%vRC>R3(w&F<h(+cjr zxAGPHLAWX3EofK=Fe9xh<5Rf$M>@VMwU(~bzYzC&uI*TCc8yP`<FjeXsyYqACx(KW z2ml4nOGv4_w*xYqU;qZ6+3W&O3Hf+3S!gUv7Ai?<+J^DyUJsU7mbH@QS&i#j+Kuei zEdBogy^`x&1qQQ@t|}{?eZbEQ4Tfllm5Sv7_9Hm!^V@=UKNrII&*5=M@5&|LT3z|v z9i!6yqvMh>934|-5>l+R@#psOiUq25EyKB*yIjq3VSCeBZl)W36ce%hE%#H@Kc^g% z{{Wo@GkUW!ep0mLV+>DQ<glOu2j)H`2DEw45VWpplV&6ekKlkbg3jOe%eJ?jb$Yf- zrfeF6OIURMRZVL>BoZ5?WTFU=dxermg<PEco}`WP=Z+$0b<{}ruB~4fYI;bdvh{B} z__x{~>+LYwbqzj~r<OK)tszfWT}w$A5G?d}6%iQF3<U{UP@wrCn4diczsd=veSPU+ z2c-W1e!7D}?|(zqCcPV{sAkjmO-A(QO+ig~s<qVCJgi;eSQX<^tZ=HO$pjB_JawIX zK#~R%@d!_O+LmbbuVXtY!^dP~aa=lDa+XrE9Omy3#laM#HrK3iN8&2hx@Wxo(xenm zZ>JZD=8Jk`VUR4PTz`b(T;l-kVfDva2W1q$^!X)qIYs{fUXkQp)~ZzABRx`?LprPm z?M`60y@g^4tE-N-5f|}Vh?L1I%91ijBOn~(`}J;0mo9)Y_vaO~kY<pA<b8GaBVya? zAu97BFq6cnxlpGbPoJkfBqfefLN`9gyFyg7k`zzcz8;>jI(F5nV3zv~;r)57vko{d zkGI#)`?_j!GXZAhQ1HU$66tbU-C{M=6$aU8g0@DI8Qia<hT=_<47LVN1_;mGbo{ER z*~R(zbB;(_l(0kB)5Cus7;BE}HJbf&lHQTjLS>dT$H}=J!!FnMZ#X~Ku1&#-veJ|J zxo$V=F9cxmDnQP}8b91SH$J{FPpEW$rKz6JQ);;-?$VSo&jnMVj7J=Ruu@1Y;R?F@ z?LR<9WmCgSyV4F}O0eZm%|hmZ-MPh{M+2Ct0aJ)cQbMQ+WSvDj!i-omg6%?6S7>|2 zn!U$Gb+|2D?G$w}yr@-Kc>o3f00;!QKVG!1z$FGy#LG{3D1zrvVEY;$<};6reVCoX zv8Mq&WTs+Lv=jdT1qLO7qJaf0ToN>-M~hG{DO{7(Je4Y5Ie-{=$$W4)8!_Pip8Zg` zt|4=AQ)VveB>UC*L%cRZWyqI@ls_N?YJHB{^NxxB%Z*EC(ccmGS#=fD_cgYoL*AgO zs^02ihBu{|<jOiX`8Z@a-Okg&JQ+^Qs0>M0{U)Pke+mo5Z@2Okt{$AB&SbWtq=9#L zEa1mhdjZ|cf5l&IZn~DiM^&cPZ<jiIRUBA(t*ZrSl7*NC^&2s@D@daZOp+A@e64Z9 zVm}>(3P=G?Tfn&MaQ-oraXuUVlZ_&BiAyBx4rH>LjLz*$wg-5zwOXf5>8&+Ms5J(n z(|T&#R~kyPSKO(oB7l+e#KuNaj;^kZ2_P~MaCqubD<3Vz65T7~wQmvW2~_n<Q<Xa~ za>rFr>sty^vCq$muKo5;xmINCZlBk--9dTObumR-Q(>!)qB;s{b`z?zrc>rI7RE^= zE>*e33%0$W!b!nQ2fari9Rq7oe^}PO8+KzGiQ<@fkMf8iDI^lgF3KQOwVL(Xv0J_x z`(baOuGZbfdU^}x?@TSSOM6u?5yMvVFiEG1G8SU17pdtIsp_(l8)#m<@5S*3Mjl** zxJvKKRuAM3+{uqdah@SJ7sRH|oXTJ2cBu&Nf~z~+*b;oC4SA^}&>B*+QQWF!x@ua) zW`xiA$YMc|;~2{~^(X1nJ{m!vFd-e$_xHV9ScacGpnSzXVE(?`Y}bk%qqJ_N)Hi0= ze~P(DUiHN$Xyhu&2?e>q!+uke2pkMxb!Ermq)SMWLymt=kEPk-DJU}toiNe)i{7^; zuG)<Vi)BfDtd`?srm3vBRZh^zOx2CLV<NKSaR3hkA79tcRpBfEqLEzyv>vJJBfUrK z`aN`JgMDKtu<QFJ##kzSA6N+<7bzpbBtSOS_9G;N&meTvsi-oDE%u>tQGRe_$!gD; zA%^-2*P%8w>kF=$qf}VwueN)IJ-X-}Vx^Ah_;-)gr<3K1p>S{|MU=lDIRs#IDkP;! z0V>KWFRlEJoSGOJh+-TU(w6H(%(31%#ozr!qwYQKve|XjW}{stX{WbZuPJM2)~zBc zC8}r|K*{PW&y$gYBO{FDY2A~;EjVA85&%&*(u^I>{;qWq1;nQ-=KQxVD#4hr8xvEd zpp(Bp5tb_+mZ0p{!``E|H*e`6mfsEH)g4S`T4@t(@j_;b8AQ{DEj;YI%1B^VDrb4@ z>XcHVRvh1%AEuESv>+1Y`LzMj`RD`hYDEc>uBHfMqA|)MnXsi0DBzLloR7*e<L~z9 ztURYtE-mc4)07!CN=(p0vjJc$`ZXM}ec+=^ZLHZz_>bX<V@V3e!wTf6Ao4Ms4t#Xj zDpEpI;aBsdB|=(MGPz;Zy#Zr)tJkbcdPhdk-!Ixul8UCfs)6Z5_lZ;mFfeV!6yxRc zsxy<Gd>nNT#W;||$Xv;QBv=do06WEtvYY~M6UBZKSW>G}%o}pUasU<ty`tATts!b? zYF<=~l#Qj~oCA!p7yyzpkGcJN^S2Phmzb~0Ie42^!}gETj?QqolQ7c0XMZS5yAwv& zdIzI&gG~h`iM4*1O-U2iX_IxL7Z?!jH57()^`i&s$Q*w@xx@I5dD6_s$Vdd#(Zlbj zq)7JDv(v{8oUA*1%!?w=n<2_}xv<tfN8P)@q;B>K>#S1CHPjORf=u-i#mONUfLVgz zF6?+*^|;ENIM2(aeZR3t{EVF`KPV_YfN}IZ<A8r18p2NYXw6I9Z7p@DxOV$a+$pS2 zG@!*L*1DlgjWkg(%v8#;M;dO!Kk**}IO@goQk2%jv$u>lf|61QTjpnN0Dls9<;cao z_*2tI;m2P0N8uw}`ZHEhEhewgRh2QTe(|(dAS@%!RJ?MGWqAa@ILA*WP$fw<Df(O( zkHpS1GGZOV!F>*Gjkb5>YSCD}FFT7KweDr|mfIya{GYX`w$UX%(uJeXtoxT6i%8OZ z$Iijv06LSw%3Ee5&+ZNUC?ZD<Co&k+kygJxfwa~rHDi%`v4)gVMI7%@>a%4xMuaX; zoaMp)0P=e1$eE6OTKzqf4;hM8oD$F0yFFL}dI(e8I$2Xn>FfQh$u%rfNwL%);LMWm z3%kKPSbzB=^yp-iClI8&C^5d4BcSy)gXUpO*(jLQ4h=Q9p$^AG%C<4otpls-Ze4@Y z^VHQxEcY99%}{t4GBo*r^pBvU>Fzo=7^I?HsfYo<QEOf6)0XiS!eyaTr^%S2Gbt6< zy5@i}4paf<yfk)_{iko8#?sVR{Y!1q_WDRFD;|2ugmKfzCTB05t@#j+4nH9o`sK)< zE=uz#9LuA#pU_j*(Qe~OnetI3L%U9usoaM(dUY{IHTQ6Cz3A>nzV7YbvU-E7?@0v` zvvUQSkdH|R7{{jsVExeJ&s2|y+(#_=ctDpDl2oU@Wrj#+qXP7eFR;8NNy8cLb4)o! z%OxerdR(}2{xG_l<@kFnO&Wq^c_(e-nOKr=3C2&;tdS16>MFYcYhXifK}u71)-+HM zz{_x5#XWbpqk*+x>McO9P|Hy~X1*~JPG80%QyRX0q$$rof7hY11j|&pY09NH?Ok+s zgJw$rj6ie0G+LD5BAwl7f852x_zkb2gX0ghv8UnYr%SAKAf^h+C0P`eY=8mvJn^1B zy-0Rv6*K)q2nqbl0+i)ZOLS*30ovIF@a#gY$pzS4C|mh(Bi5a@7MPO-w)?q&3Fxws zfCexGzw7JJ1(>Jp;A789K^@{f`{faBw^uNd2THz12m!I4bAj)lw^OT@3&lc03l}5l z_laJT_G*?k4iZdwU@%AieFB+{NG@E`08{?ZOG$M|qg6?R$a!Pu9d%Q|Adv=VJ`nQ_ z&CkpoOJrjwuFFwCsAq(&DKtyc96xwK$qbcHtFR@N$oY`{xcVN412UY}7?ddB{oy0j zR6MWiAKnzjnCB!kK;z}x;ef&C&(t1`NMU+Ip$SNHq(GhT>`!I8{{W|{EIr-OcI{<q ziMP_rRDmj}oDr6Ws1Y<rAYdy2#t0*hiAEzjoYkONm~gruXWv(j5&Tws3Tn@X`WgF! z+RKKn?{{qSUV2s$y~5EFC|pSEw73|;xCF7lV5DW5mdfaT)&>eW_F+TgwRa}l#Q|G> zo!9iV`j<@KZg#uxrRWQ_3m7gi0gOdDx64x>BluIRuj!G|Gv$XUB9`dlyqj<ynnBVy zl-UFjUn#waYcaSwyFWO9wU&>nc$USjX>B@sSfoFIzu9QYQP(p!BC3F7!1*~X18wE8 z5j=IBaX*KAILuxr43jEi??*)$fJwI0zSoagb|3nk;`nsSmBbbJnJHl8kD8Z%@(dI! zP_0;ItaJCi+8bHeZ(onNPztLI?joSOQo7AWTIw=ZI)oSwJ>q?`E=VXb)}Ps&58-fV zkgJN8tG`iS4=BU_DRz)?o;5!ihjhzZWpDui0Kn|*U5|`cmyWy9sZ@ZUSdHU@fzLmD zdw!VeetHtkT)sZ*tY(XmG_6w{B~>i$S(^4|wVP65uDvewwG+)$qN6d8&X0iETmo~Q zxX9z}(8J}22h@Y;Dk0z%HO6G*uOex3TD90#hPR6)*sG0mXuA43nM_jC)I1U(kSe}@ zVAvi>4f=8I)E^d-gkpIfT5ZXW+r^`Uk@5qz_1trBO-16uQ`)RHN}UI&^wzDWyIv}4 zgjY*z#3Pc_OAtYjJ9>!96oY^c?lZN3u5r14KZi}j%t>MmwWHazZTUo`=|~FslG#O9 z8*NeDCtx(ZcPHU9uzH7A=^IrxvhAI&w@qE+{AG&1T}M?NQ&Xgltt#87=ERh{gYJ5t z!m6}<O@p(q3{LFM9(Ew&6ojm)h;Vb+h<X!G3xm13aCIvJl;TpXlaj?MLxLW)C54!Z z(yo;YN#7W9+B-+ue&N`z(bQiwJ=;y(W3g5`0F^Sxh9=Gx%|ffm5J^5zFd0S)u)}41 z9mPoPz7H3|!A!MHLfi@^DIlpS?;N0O{{SGSt5?$eBaBJo9h;v$Qvz`bN|P{ZK}lu- zf^#t{02*9|Xz_=5imFi5>mz)`R75lR$Wgh6+=a$}mtV5jNrV<-mY|MjN?&2M<H6FU zW=YI}LM(ZZ&Hn&VhuuaNTScv5XlkX3Fr<utyen`B&VEyZKikwN0L}QBq`MxR`L#Ua z?b=y0C1K@<0HhXSU6=|2+UR!HEw{i$&sW;}gWU~5rZqMH07uJ7rV3WQs^cvr)p1W< zO%lcGBd$SgFjqTRs0SX?({WOz;^at~Uzq6~SR3w4P`I`hXW-^70DzVym=+X$OAEfb z3q-lzO=+pFcZlv6i=L_1H&|19Roo|%qM=bn0<+A<UIs85#yBU>PVp`eDN873CXY~h zMSBOuXQ3fsnzUyRd;W&;7`Jfdza&>^Z7*2TfPzyl!b*S+<zU=o9y#-$zg2PBLXk!K zR<_raS=HK{q$OZFx$ZQk-rgJE_m$D!rKxS!Y?8sEZS5fXVDJe2IzJ8Bq#*Z+Q0GVo z{bppqQZkYr*X5(rPH`>hscw39Y74cxxh<C{WD>_ba5R!D537;`cp&*5P7WC=WV0c4 zK?D#s`VS8%j{g8vpCWo^%cR7b7Y;2;YgcxmYeTi0xw@xJ_YT?JYdu9OYPw5|mC>1^ z^&@vGRhC2q7DBG-S0jRY(l9-l!%X2x;e;QRlnsd2qrG~@i7SXqT1FyaWvCbN`e*~z zD(A#ztJK=6{Jp@`_qi`uI`Jhe%*wGWU>(Y;pktlS`t?-eos5&hWhF8kxi_u8?Gao_ zNhiK&)F#hri}U7g(NM(lipJhxB^N8a@~RIceg6Qt>J?=rIpvoFL8<rQyg=dbN>Xzy zzT(Yx>inajpA8yEu{SQ$Yt2F3dp+8}RaIRQmf2THTq*wmb<@Cz&fK=#yUss4ups~# zIqNg+KNsRMafp?IQy>Q+oyq+M#BUmxWqUirXJC#e76DR~LgX+E)V1$I@Y~(mL2B+c zy`x`Qnj3?Gz0N?+Pv-D4PaR3fB}qz+3G&t|MFDGN8geJ-FA;J}By_JVa!0aRiBLFi ze14w&EJ{BzfH?a8@apF!fl<o$7wJIcHq|j?`!{~N=#Ja>Yh75X1UIW}^wo65lhjn1 zP?g-i>T+^FU$N>_35XKbl_ue}{bIe3mziM%P}4*A_joD3d~j(UMXoy;sPDE-Nv7=D zek;0PX`_iMo@wf-RyC58M<hl>R*Yd^V<2yEIlUe)Dq@fbAvZSEezl{1wb@(~5|k{Y zg%tqCni>N11lKWkBTB*UnfR3MWlV2<?N@iZ*`w??Ic2y~(XODHHKh*h83HS`VB}@C zjAZ?J>nY;gdlw@SG)hqjdUUuvxi;Iy{{V+}KZef3Bj-v<m;iD$=HQ3x5}g;ths5Q4 z5z^he<E(FVA#sbX9k%3HalrgdJ2pPJY#u(G0FK#XWhJvY=05)bYWP8F_;<o)EFYFb zF`~Tbw#RrQ(>?s|rGBl^8fJZEL}~ALVM8dS()P+1Fvf;Sl*vyD<(fU%$Ya6U1~b&p z1MLng5Tzz*NTbkQ>8tb7EBsHfTssbgnK`Ox>hz#yj;?%Ge$EX~@j2dWmw2>Cb31>c zEmxX4Nh@h?mip;sYN+YfDI`xxb~3s}69c;;i)0LQs_-v}1Bb~tbs(!LATbQyH8+Ie z@qdQ`SQaGTlh8K(2aI!<zaI%Tp9nq2?B<))o!qc%ePO6+E_Y?O&|7M$g}R2E!xGfV zB+bvOH0Cu>qx_VoI2~8}XWC3B5ts9Z90`Dq@n>zE0Zy+H+$W2wdX5+p2HBiH)y~cY zS6hX;)u0ndRYgNRK9soYO1P(yqavD`t?hLt5?qtPl6S{}mB2qQ6r3pefQ1_mzk^f8 z48?&dX$%Nc)7R71#w@qO-45+D@n26Z64YKsm$cQ&<}vClf=|Rq2?{ok<3qcU4$++8 zboO8nUYBpXb>~h(8OAH-O_Z{0n?}2Gwzu$zvReCnr@PkORbN*1w1-UHqpAvpj4?-5 zLFq8S^6us^IT;{v(8MUqI#he>^@G-siK(l;{{VeJJF{yR&#|^H-S<C2)$4lh?&<#1 zX)8r-*1x#4^TJ$K6EH-WOCyiNOAIdrCkpXQlTu3(`i&xW4UFvc++u$W0mxY0g*7+v z8W`>?<F6UvGG=gT2@952o><f@fGS7kOZS36{MRHC9H9N8)z%GP@e_O0x?4%rXsdnS zPwCs=?%J3{RTQ#STH&mBsZq(Dg-|W}s6UheW8wI9Ntg0g{ol}UrK3IU>ZS}nWi8ZH zpiL@sF>8L2&zexGUYNnMKtMv?LU2F6LF#^Nyv50(+t1P^47DIIWRP{#{kp+@zFBDs z(n}&u3uU1rl|$sO*m3;2Vq!wBVYHQzowt1iA85+Niu+YhAc{F;qMva4ZRbB;I$>#} z0CK~Uq@)08Uw=q~wUud}hKnf!D$D4^{ISMMkUyDU?mCgfAm$}b{Ql6)qunSx>K*() zc7hEBVW7He8)lrnO;Twjo@hNtoFf@kS&(oKmP~zqU_ABALUIKZj`zO-4q;N@I^161 zU#I9f#gw9gDvDYwMV1Jtt*VA35MnryTW(8i%RFUc!5je2eB||Go5Y<7m{=q=2O*=- zC?2GU*?T>}mp2ZS;mImeNhRAUS?}i{m8w}tV?+d%5Lh{gzquNMn(KI^rM5H%lG{p@ zVrbkdJBMi-Yac2Mn^&I-4>{{a?3NmAj2foR3|4axL3Sa4YSohGS0qLu@z0LoIJXy- zhvDQDVdaTVKPrkzQu3QA0IMo$Ndfa0inBApG}BA!H7<8DPZ=S(=MBf@`+l8ebDhp$ zO?Kx`80o{HfKaM)U=EuXW<4R|*>JefC0%tDeR&NV)6_`}jG-cRDpA~c4htzB7#0US zj-4IU_bEo8-r}3>3Y#ShN6Q&^(YY&KTS^vUA@53ef4RC&fwYd2*Vnrqo2sq+RMhmd zdST`Cf*Ch~xmc<q<yd4O_rd6-;%7-gLP<`v-_5~<;yXQrpEKsnK$t*KtFsH*;2lUX z-&uT;;Zr4L)}UM}WZJDQLG|VY<+*pudEd@*dWe{Xs1>Xg5azGG+w+d64=5yjl!0po zdk$3B?+#WwmFAMMZr0ye*HEg|q>&7mcCgzi7{LPo<HwGJ#Tg$gqH*r15IjSX`iMIT z!|{_Ps%{<5LKIX3um+ljFV;HI@Z+tXJssR1`9Y@db+*bmXs&g4N_%^nN?{akhMs3t z+Z4pqEUF)AbuL$d#!uRnB}P)EDTRP~*O99RZd~3ird}dU)ykjwo~51jaiv|wNUb6b z*ZTV5-JN|Nou{|Q1QAulYKfIxM*>KpVn8?;%9kfTapasAfx(P68B<oSm#r>-f+43* z3SY~zPfvdLiMO>ff5@7P#TCK`C$&n;S3Ckdvig54g*<%9gg!I#1Y!5;2Nnd<ma7Kp z_Vt4jlPx)$!H2)n;KR>w?DvT-H2T8LWE9$VonrhPk-Th`u(>QG1qs|v06F@v+;rvz zNJC4R`HQ_Q&F$|5OwTj|swb}WexAfgoz0+*<EAt|x3@&sN@{UUQ6Yf~87SBQ&Hx|a zktB_j@~4B)a?V+bLWSx2Uivfm!qkk&Nudk}TfV{f7v~Wtb~}@%J6qeBZ&mVCYT7|^ zgxlhwRx5CT)67jY%HQOqPf-fWIB3ZSI~S=A7sA(z!~_oMtLoJO#`ZLO?F?flWJB#x z{{W*3bKUD4X1;5Bokw=R-){*eRnp}%R!AdYsw8;}xET4i{MZZh81~OwG@cuhQe_7W z!@|JUf|Yo$Po50Fn1wP)H6RmhKn&Lb&nRxR+G^NE7=e?L0aPcP0iU<@`f_?PLo%}@ zefq#EoPphR=CHl#M+_eK06{9)YVNditaPZskx`|_c1Q?pFh1w>9(qKqIVULwhSu`u z=K*79Vdady&|6_rD8$*FnDwP-Uq{*vacr%on$ZJaC@Uj^RGt8KL|B5tNck(Y;QND) zG1LsMDJgRlnTjDvK9P0ey_&^IkTD?up<o@rG^02*He$qiMVT5}<Dx08HhnKuK~HjI zdaH!9QW*tPk^vkrDgoRV&IWvEsz+-$o)pRxF<FWUU_oLi+Z*l9vA=u-;9Ov0@dJe^ z2vR~b8Itp$f?Nd<K?HIxDnNxY>e`G=SAMm}ZmLq>JtMoJj|-M7jFrm_mCnv`Fmu*_ zGl608O*aI*!R)XBptUT2;{O1fLq?BFVE!UvFcTDx(BXuVnw87Uq?Ee_v1JqrzCu+j zx6DWkV7l3J4)I9@w9+H&c=k6Et^vqDLJ#vF-jz05<gAIKya@-9vs0GlhVipuv2rmo z6FUx|q$mbZAc74+4MWMf?@3bM-5qBg1KZ-&H-Ey<2ca}6I{`F%NDAN|oNxvXc<O%% z+a4(;B2ID>qkvcnF*I^NYQ{mvegJk$6^LBvm^6Y(C6uIxCCvhxtpM1<PT=;!_fgya z)7>Petv20Psi`O;riigwnnF-|YoD2!6^Qrwxb_2k?AH>Rh{4KS-_Aws^`)AvdvuS_ zkBeQN!0^t{<8WLwkt$JSf@qRJDh|$AmP?xm?|<zh&=r~%!P=jR4%Vt$_1A4=OK&lx ziC?DbAy{RJ{{Y39!pAVrDppT()Re9uP9UkDLhoa>_1-ePKMzTf@)bZ^T}3t5R<Qbc z+y4NR&pfqNu*)5NEb=sj$MR&B$Y|I6C!Fp3o~#&j0K^v@`c?YD^JSodO}+axf}5qk zMNsy4PF<=N>q*=@rQ*6+qa|LBw!(^#M=BA_RS8=Kd`^r$Xv{1K0FIeXB&!xu1F-TX z`D<2fpp@Z(mR8_qSDvI3x9WCi4r+^)eujd}>Lm80tCB#31N=fZ9Dg9Zc;J2dbMOiI zrehb%-n93%W0G-M<rygqht}JY`vPg@7Q3=qn&tlh+=Mo(oN|65jpw1I83PKq1%BXq z=imHar$KS7NA|$vd>jpIE=^#54S~yub7pgEy*V=tbUsB|g2Q95{5N+qR?$sLH3ZZP zYow4>F8M2~Y8C>@ga&RM{0wbEM*#Jn_G^m#(q>K|HEiA5zNFiwY9ezFGv}q0pa~(Z zcVzP-*7AypP$G)9;T0Tf9lp~-mb!2N$FC+gFykHrWDY)WZn1o3F>|JwtNhbcR9)=$ z=ceYH81x<!1eqCd(!d`nAW(;WUrO{G-VcqMDXoZtj*+S(g#g~~9^L>La6U|)M(z*a zs)Xr>@dYYT0G$~1YP0SId}7R;DioGrRi(!<r;j@ZhnhRw_KO`=Yg=cYCnAbZNFzk9 zR|RGd^56`xK5?9JoPCVKE@H&6q7CfNdZw-`d)J(DOyY@ZBv4b<$Jq@Nr^1G!mrr-g zvlrTTNNUESrLGF<&6N*@46B2H4o*4u>PLl3GI*q>50yz5Bvk0mYsw;cyt2L-I4C=$ z^xOb^FGscG)Yg*kg~qm?VRx_5E_Q8LTH9R_Bz@cDD}n4u&$nCQo=Qi0y-1HYfKBV+ zw)OOAgzfszD4x4*lNlvP<sRq#UOKg?l4w7etxJ|b1YCW+^bt8}&0@6_FbL`ym<%w; z#~%E9a6a8cPg0V*{eJMNXd!NYQ|A&LyH!ZetnxnN^EN>rr|b3peIZKGR)?j^D@Yo@ zv+sB}pw&WFL=N9E@r-W&0FU-`{Mm%CYW+Dv0s@K5Sw8gsV8w3qwK4~iMv4_c2e#q% z2c(5%41b7q3mKRQbb{dvG4$AoiSdql=$=5S@`aQWQ&I2Tp~^5ONd!p2kCC?{K0JMT z>Lw@`0ueAenEUA(;Fj>i@RHjR!h#5yO8Ur0_(y`jm=Jm<Xduz6@74tb5>oc_^wK#k z@n`!v=ylcBe(iiT#JUf<RHemrm7xaJsfKdPxZqRFGH~GfXY!n25Hip~Z9Us>y`C9D zQYGX9o}-v9^z`(OMBX&)g86^#cXc!+>qzTLP|)10SBg^v<Zd6tkJ?z9Dnp|Xe1b<t zOP;*tPfJ(b9^+eS46@MEWxEFyzmDW;^s9?R9jiVYDs;?MdWW^&hrU@S20PB0xFSjW z#sC4rt70mgZ6#7b9Z15qPee$cF(u`i^)>7K#Lgd$OIk{2B-NPiYUQpfd}4?9JK;)} zYjM_EE$DRIGO<^Nu6J8w&5{}sQ77?j^9(37^<N-7dZ-8nQw_xd83I68I2caMzmh>G zQ9`Sip)5mIl-ej>x6{}CBXO><Y6~4spVTortI}1%(Z18+j(97Bki#m&!8~;%hk1)h zXJ;qNN<y&H(gt*n?HmTPvpdsg+J=;cdIwdmjgsX}Uqe%JqN|Ar{661O7EnM6t~nf$ zkMQ~X80=)FO(LvCu3u9UxK!lKWrLHM8n9wbXm#k$(QR~BZ*MxELRkBqO{^^T=D1YU z(#LF;4b#HtU0I7OU??E~$OW_Dj(WtqMe!tHxT^S6{4&%M0V@Z(GcyZ0EJ>w+-Z#&L zp9Q$5YZWzkWr=A*TxJI<&-`64ZP<<2G<T~-{C)O^Pj<qd**9-rOH@*_#YNk_F+=!U zP0D!VOk8hQSK4Y;Nqt8YiU?A_#1n9O#=i+BVg%Vr3}{qt!s*L@0j8}NzAf+zw9<IM z;j<+C#ZZNZ`CuCWvNF?hAO0cYz5H7IVOzV2@YPqRW4@dJ0CI1u4xrW5bY7%1lq{aV z;jIW;4pN**zr#)o?{;QqBdK1@@M){!5{q{M0U*?o{{SO&htT;8L~{EpH-yW^uN5e# z<Rx<4x<+~fl(U^pKg5d&^E>z{UiWXJrCEJxCsu5nk_qQ4$I3uC_BsAtdk$_OB2kzC zpnFtwt#3|I=6ntoRLQ84lFB<&-sFPJZb7K!5`Lz%8m%)|bhWf<^iqBzbYcNyBq=*_ zpDejPG1V)xc!`O`6sS0Dt*EZX{ENns+fELNxB)s-x(Ih?0>INyP25D&@IPA>FL*T_ zhJ|T=4|3DBk-JnAg%GrL1!7T<T!_fQ;PdwC<&47>V(_yjRD8j1V23>mMmao$U@=KT zyTLaEQHXkiZ&ONvv&F*hWdqY&Y79>$9D8=l963Y_k6^zp)5jR<?TbmROZ)MPHXTfm zxs`BtCAM=~Y1Z)(cH2eUtF)Y@BBH5J#?&lsKny>KK~u+{CyaD5(uGSZA^zOj50^e` zhb-V$zFUets^W$WBGU90*nwCTX5F;{plKv;^HF`y0ngC+Bc&WFN~`QcCmf|GGD8r+ zxdUBamG`td*fhoOTxvTejM3J)FFL06Sskj`OADxa%9qN84#W+k49y`S00)p1w?ogK ziD=HY2&cLa))$7rs&ev;ZuSG4Tack<eH(q};j`i&Nna(?cizSKc82uZjBp)0Styr` z100c~M<5_@>cv~gBz2?UdqaXUhFQtV&2T*qonuXvh(dr&r7#~&>S%p-fZqH38+=4* z?!@X|?`wYBUUVjkwAImmB5TVfHB5$ige&RNmi3u;$f203;d~R-YqZ^<!r~;1ETv{3 zi&?CF&_MY7RH=cIT%DZ#E_WA>VA*M^TKh$ARe#5{z{K2qvITsB{{S54I0vdmBNLDk z7y#DWL$`xCfjD*sZZ3K-z<0~D0LAxXQBO?_IN2w;t>(q2<htBrx0|&odNhCG%;t1Z zO87a(Is1J&7HU#bsi{aLq|}Ol&+n8-OBjhWGA&Gys)67rb&iSrO<U4F=j$q<Ac^O$ zWHIM)1xQ1c&UrrG*y^c)oRmrdAg~a;M4*bGk7rF!qeW|3qDZPtF@?jCo=Hp`WM|tA z<DyihC1X9vJN>oV7L=)JDnYCG+O1bE%tuJE9kPc#kMA@k@iR|KssRVWDCgV54!euQ z33z2KR0H2wbF!Q}=ka_t9U_^JI|Jf8Db^@HxTI@s#;zGRIxDFI2R}dCJbk|79y#j4 z!ZBiOl+8%E(%$tjR~O-G!Q*CO!S5uU9OyyOqQ!hOT(w@P687Iu`XZu-@fSK-SwoeD znHEIZ{vnnQJ^YWqRld|<Wgr|<sO$?k9&hx5_)^qne3>=!er&~?py?JEGeIQl>rGcs zWyIA$fEs>t6l2B^3><Ns<AK#eVn|bv8}_-~&x}e+QOZZZe7B3g@QvDge|>LP%{!^J zZlu#%YR^$?)KjIcVEj$SrU|4-V)f)!^rPD};r0x-r~x<_HcrH0%*;f@y8;J2euG~@ z5&UXdRG>f%4Y`4L6^`jw-qt1(%Y3g&a6`yskwA(N+naGG<WLtmz~N3X2U@(DKoF#g z?`p+zWU5hv*Plxh9z@%@Qr$i!S5w7zt$inQxm7~4%Q<5mC1qsI35m17AHpkx<37h7 zQZZ7@(6zIWrFuh?(1{-;8g~OTTDGQ+-Z3_JX8!<oyIZO|V>X?p)VFDw8j@O>+(!(I z2uY&|!wwag<ctMk2*Yz7hoZ9w3gu7PpuQkKwq>Y2XmrzWuCW8??JcaJsP4X+xc5s} z*{!zvXB83Ks^vwwKcKr}e7MYea4_4rPyiSQsR@|Cz@Z|qvGVbVL}AsLDp@6nW_RlP zu+Zrf?{8tWXx%TWYGIP5jLiis_nP|Z!HDUgeaX1zf*HwG0I_1Y$;Kja7`d|)<T(}$ z-WS*SiOCo=$|R(9HFM*qGit?Y(`NnOPU=uDnbUPv>!MnvlCp{wnxz^Vb&`2!^y37q zd1ig2Vlj-j8R~s)he1m)@)Z@+_lonCtgM<l(x#y5Tcbc$vD9$MAv%Xu>N}m~p-8b= z9F<P7ASoIw%-gm{8mg$y6+(aj00vZr%vzK#8(X&~);lYQiD<|MR|K<aO-N&{?W^k1 z85@nhsyL=wf<ZD!S%koWDj$~_KG_(~c;Iwgxk~}nQ}0cEw0wzVmH?dq9K2txJYhvW z+NziF!WN;ZjieGn30Eq-0x{&DCyu@gG%9|7SVY`9O4tiGzFRP^@Z(FUZI-!SxJg)Q zSs56xRUl*Xf=>!MLbEB#L%`{hq?cwQ-^Tv{SBReRa~g)EwL1^PQm-*^H+@QY-N93z z*Qp68T%(kRD(CO^)*fPl62vGuaB4YI!(Y4;TkcgA+J$N<UP>DIDwIbe$;wN(6+4DD z;GE=rdir2w<t#_(0?EOgp#ZT!+NJ1swdi6=)>qriy0=hJRn*l>OA9k4IzRB0NCcg{ ze7u8$<3F!g?#X@)Sb0+b0d}nmz52C?h~Ct3ejuqT!U>90rl6}cT860)SJc2=*0|EO z-RcRJREkIj2r3+m43Bb7JahHqt5*`mq{vWU`g`|CsbF||)jQ^t8h&T6v$MMZFU)}f z+RVjGah{?Xy(&t|c&bzd7>!&5j1L%L$Nqg&QH7T_NB5ZJBx_xD>1#!|43&J+Wh8=F z5<t7Pi+HtSX6+eE*JJ)BG{aGi(rJrkrbbxT)K<4%U{@+nAbK|%f<QR=a(n_SQxb`5 zP(cSNNwuH#e+ofdPCzrHk_tnV(SfQ>b=rpKlvT+ilA3sSM%&3&LI?r9z&_vcj+>i1 zFgdCQmG%!fuuG80%9M&zS{oZwfxEe&Xd$PHoL9voq8b?ryA8P>mU2VwJB~J-AE!x~ zF+`AbZEsyE?7+&9mCMeGQ^aUmpbmlghl>r`%Y1b(UutPgvcTpTQbLF1U8S+)<b2+J z@IOwnzTVE945>+}x!Yl8b4@DO-aQ@tKVd@T;xTG>%o0UKl1(YHpW8*m7mN0$?3LQq z-N@C|F=^`PXlmh>>pV2pni}Bo!9fK&vi>dVW`*V2Lo8l_{E8m}@Xj8ulP+|?2&)n+ zcWT%JV)0J+s;NKglch`Us026))T9z`b|J%54>#V%c8;>sUvs+L72tQGA`cC?@Y{|- z1&+=~oRiR;XN!rfl-c|#w#}i8;|+x*=Uok+yU;T=9>>a&GwQoE3r}ad$1N#^R!WJL zNlmh4cB>{x<Z^rr9-*Y-lvY6&wy(Z|yx*)AWr>Vsq`3FL>kXP_<xxpRs`O0?)(t$c z&*<BkK@svxJSCrTA1g5ffsx>J{v|n@V$<HI@O}M+D+-x%(&-iFYTuV#NQ*7o-`;mU zJ`_meC6Ls|Q!5}K$b&8N7bQzXcMJ}BK6#JDP9b2Zu@rBxf6}ocQWi4`gVUvU+O%s$ zaQMOPEhU%Xt5ny`6)Rk=S1V0TJ4f>*D%1WPsLA<;*~cf@MlsaiWHE2VP?n)^T#dO~ z!S2x)#H6VhWhgKqnvLnx$<D#}#Ak}C$z5WJsFvSTP?F42mLnl3rvOe=$`>q20fMpQ zgN*gNNt6l%)@3B>{#EmeLg&l`DJ6pomk!zrUFvqZg3T{POVLriRFNwK8yNY|7*WRu zk~!ngPff&S6;DswsCW|-hci@4&r@ct1y~bmli?25YU!vdFDz8c0=hKWBswS@9DqJ< zPXu7&Jah{2g$h|n^rd;j*nAqMCS0-|+*4ZG+v+F?f~xN5YAK=gtW6!7kfMO%J<~Q0 zak%<CO$R3%{K%bD{PQcmrEe|XvWrYdyQi8*_z4Acw&VAOM37p}22Qp-_%o@<G% znhIGhaI(b<1YpAwu{jy?=LC5Eoo5}bTI30GLO~tpCC;N}F}-{dm@!i^Gv!WN2~vL| zvgkoJBv{lD1G?JVZl$x|>gl4B*GC8O0omkS@AAZ)0=QG)U=Lx`7i2h;`TRjL3z=@G z43I5H0{KX$+q`N&5O_`t3)#F*CR%^@Ln?k^l%*|Mk9avI;))3%C|pu9?KD>^`#rLr z3QDTU!$}Nu%8enBGIoFm(>#oL{WyEIyf$tljGR6SS(!adLQn}&RSDkg``)*K{s(rO z4}t7dsoY9(CCZcYJL^(X5RkCJN>gPH;oX2DB<n>5#^rOjSg4{cUsF0!Q^v%GlA>1; zsOQJdz!G!s^y-C%kg1czfCy8zR$vLH&*4HkM)`~4cywXcjVg2iRN;uq0W{s(gk8jU zL%m&r?Tt-`v2=aI?zNw@ns-86;j5rGI+_S5uTK(39?~b3WDS@yG6IxmK1j!?cxD|^ zl7s<~C<69jN)yvkM1D8+kF+0*#o}>OhGkAtly?qY%4_+8wR=&-O4|3o{gu|b8na8^ zd;b92y1JWfER^+i8gBh=s!Plm8zYtqm6UEd^q+2BF_G%<die+eMqsJjF}9vN*I0m< zrT0*py+z*7v3>1l0+Hro?@t}Ns^>19s{CE0D&|;<ZoyTM;ewnI^6evm`gJ!hYqE`~ z?GqUboW+MTuC82w+SQ?gTHnUWC18?O;~36xag)H#GEeh8Jc1Q8qU8JX^VSWUl^AJr zP*{R_+iqw70LHMSNV*GJf5~e$s27SmZlI%$=~XSSP_R{02_3_{EI|ZDRWrMcN#t?S zrFei+q$4m}+Vo@5j=IDv%SuU_a+DoQq@A@ku7Rx+*JdtueM_yV^v;#6x*K)E-(OQb zy1I&RY}FAa=V`FJRahv=1nwxiatOpn8kA<pBsgmG6{v1Uu7E_24q^~oBog&*PtRg> z570bXuZUeYY3(1wuV?fm)%L3GMPG7lL043<G%D28Q2J53O3m|@nzk}nDy}0uXFX$G zpqy~{1xk`T(;Y05P3~&+ZhFz8``GLZp^OttG5}F}SkSZdpy=^MtLPHbXt3KQN#>Ta z;wGz{Mp&?C#_W!GIX*mN>DFC_NkYG}0LTad$UQa$@1@R>uuYTl`@l1ro00`csn8Mt z4qKc_G?P=@?g-R%i)6Tstxr7FMI(zGZGD-*=Oa8|k3P^V3kBy4>9w8jXKk(nzPs5E z%TdaX>D|K$KJU_`jiVi5Q&(3d1+u2?JxH0qh>)~WMG+VVQ1O6Y3Ah5>fcVcnbA_!* ze2EkW?klxycWrt=_@xPoNCv(PTJ`|d`Rx+#!!_oD@9{J6`)#SGf$dW2dt7tVmLX(T zsa4qL*bv{}son|0k})##i!Clqs-b%LGil)!&O3}(;btKQ$Te~ZU}!e8v)95sk<fPY zML@_YY{r(OYVk{PWhy?)y#0Qj4_ma!X?&sEt-8mbW@IT0&H7u)IWyaS_-S2JM?-9> z)4FQisW7TmcPN!jk1-c~bLSb)oM)>v<mx&5=@mJgK4Knf2`SU)-W=#@sf!~)den5W zM%3C~!%Z7=^2EF!uk;>z5mL}-Lx|!gWi2s8btCq?8PRGDK+-R-Wwvbt^2O>VKc}~j zhZ0jlMg8rpEKEyfDkql1XF7h+duP=G8M9OkBw&%mfRDNPv7hVLRUsf>($L|?yX7wh z>8`ZoAU##s5<CX0hA=q;kDHHg>(J@K{{Y3z4vcEwe>3!@HHC@ZEh|94LvgT2FgVQD z2%87TfPDLOT&yZXmS`ykbi!TSY#YL{w0HMU?o^jq*=W@JM^G{6{>bR8gg#hz<S5li zK0kPPrSATbs19niB-OCz^FtJpFX~P)_32>>HE3kWSS&}VZQ&(vf4f~~M|rhGl+52S zNopa4k>mVw430egx_9I#2;$`{@`4<jKTf}<(OSFbt$q=^?PaF6_cryWuk{pL+Rs?G zN_uL`LpC><B@EanXd74*BRL-ZJiIzo0$<42+drR-HoQ8er~uB*;QL1+JA>Sf3EE9V zE}FNQd!eO@D^pz(zL)VT2<AG<Wf@5n<ZeyM!w1Jfs{+hYZG9Wxy1WT9hs;nQF!xV> zn#2jMJJ;Ka(^-7ensS=Yf0zjBS=7l5d~3$axV8nAdGdJ!!5%seb`{j=E~cmRXgFaL zF&`@uprjjF$1q9mo^5R+l=m}9Xx&wAfoU|wHBOT<v3mt=Sq8-9=Y5*x`RVx^g*&T8 z+=7RxX;X?B0ae-C+uAvSlQZ*AE;LhBv~brQZ^|itr}%ZgS}zq6=^1C#Ryf!gB&jh$ zRCDGzBMwxR<Zo8m*xUdI0Qp#%WIFHD-_i|4lb<k4tMa!Z)z!wK=qnS?X)HDNjV)WI zZ;;Dose<D3Q>3ygf{vh`bB5(d^FMyDuZg{!oj)3kP8|hFDPqYdy!3DZY8pP9N56li zFNio!8N=scv6E3LNm(N@f=dxxuGTL7Y+_M+Jk9D(rfSJBHig@`=LDQ*oN@{N-DQ$H zH;Dz&EmRfmyEiuS0zEy%J~D6ujH|~<N+16K-Xw|^6@6((gP49AFFnlBHPlx=?e<%@ zlr@f#pWYWau6GFIA%_<Gd~-?;4oGtzG0r&aPwX#b@px&-#$rfH00dQuHmCtcg3bo9 z<o^Kb8{>}$;<#`0t{PQ7VQ@$XAfp_rBx|)9uM+oXzheh#dx3JaPipw~(lcM9lMvBO z+dHi+a!3PhK`l*0YG0g%I49dZY*PZUG7PXlDh)tvZ?kf&c&CH$C1CLhSwn<Whq-2} zgW~Q+@lpOY{sgs`!al6I=s7ighr9JvwgGWjELQr6>Zz0QLNgsj9GjXlb~7t0GO!21 zQaX*oaFt=E%LT}KF)d?v_G0>bVTa-v3_>P<Z9yRSi0_9Fl(2F(7S<|1!X})rJ>+W0 zXr2*l)7MJ+rD3!xdTAGi#t!L}bMKz5Q!&!zVsPPK<hB}riMg<(W%ICWRu*_bbOiEi zi#1)Jp0<mV+}oL|rkzS8u`=!4C0`#*j&cY3y!C3Hq><96_T?1ZDnL<En@+9P&*mUo zwN<pV8ag15?OAH-BvK<GLEdrzC!BJB-_bZZgXPqRbNoL~ONj0=QbfRvwQhmm&A!l0 zaiyWGQzdo!lF@F+Ng4iWrCfeSWj`!`_DAOW^U%1CAt5ei%t$=Qt9bk82Fu|1siow~ zQ4g`FGfJJbixKc4+<w{Uj`LspgIilmcb1CIXe~3;sf<%iE0vX(Z~z}gN+}$HB!i6g zTjRV(1u!KhXac&^PhWe*_p<$k;&{1pCVYh?h7M9{<n+>~_jv0a)8n5(Y5GdFisvYZ zk5Cl2nPXx`NJ3bWp#K1ba6N}riTI2(wH)&wQr3$uAK`}-np#>bwMQq=9)#97^ZP*l zFz%h*?S7`wHtW4hSm&sPTAnm9Ktjx}3jlK?e1iqO`M}3R@ZLLzorp}7R2P2^c74E$ zj~n=8cy|e2HxQAW5CvU|k46o86V5o@*&779o46As4-HLKH5~N_yNe`*ZBW@c!Nd6Q zPI@P|c%{kV-~&|<Heu86;~#tu;c~J3Xo;aEC_;@7K>VALH9lx>+ICAXdZ}%57>TC7 z({Bp=s|u8S{X94J_Uc1|VvODvg=$jVtlwy@d`IEZFx{lYNI(e!V_%`DwxaBM#nSkU zw^YHcuC+AOkw;5WQ1vh>=WJ|c0F0hklZHQY)J76&N=Z_PNT&W5q*vz1T1YI!bM{O5 zMQEooH99Rr6&|7E+_#Q@zeO30klTAN^nevC0#<6|bnESeT29T<RL$Cs<Up~4)7;|< ze4mymx#0Q0KiBWnR~}Li4wy;Nws^bzERvOWrv`-v1xM&X`tv(QG4EXiTXhnTRv6=& zmN`^8`FJ?N`iu<!04|F7bBGJTfJG9bC`amAEuS4YSdrQVEhD_7C0@C9s4aIb$)MBW zTUJe@b<OJKK~qpJ64`61pqUh~-~2Mb5#TV~ySxqF+3H`k@)Ef{;Xt9w*^KK3g`oG6 zz=|^;0pUZeT}z#!(Wj!ZS*X%iG}LP-2lGh8E>F-d@J}3{bB?GJXDwmP&%bnug-JpK zy@vHZj5$^%{?vR&(0nJ@?^^QZrR(*r71p8)Q&LjgW2BN6`Q=$RNURut36zhc<a_mE z?0z{iv5EvZDbRBji0&@3FyN<uwx4)I-hbLrsy-m>wcWJs=7z5AZk)JNNl$&c)!Y`Q z;Za6ZtJ1(DjU$C*+{)5roTlJ5jGnCBsO@OL;LOk&T2vh!$P}mCM9;(C#H){S6yRpE zWeTW+roh+}rjg2RQ(qjn>$Gr}hL#G*;}OOQ5l0a!pbUJV1@`?%O5k#Wu+sivx?J-l zKe0<iQ@67R`n>ZcBPteBHDI3T9u$T4{`dDz$}HDQZS<u~H2#EhOA^T>(F3<@5;MZ! z5Kn+T`Xx9iB$gEi${|-Ux64s-SY5wD(WcqnEq_CB)pAl-+GCdFb5kP3B$dV$L$2=_ z3xkqRC$0<(rGU03{XP45d2`QnZEXb~Uv%<?jjDA{q@#|XQ|cP3`Wb22p@0ugD5P13 zB|rg~?LRTe!RzGUB}oV6)1A-j)Id0#WXXUg^?RS%-c8aw6aN6C^`Nxgs{Nz(i)N^^ z!+fGmDX3Z)B$`1K#L{h%CPiQ-hD2vaf%1|o5WwVh6WTe1r2y5=y$eTVAOz4e`rpBw z>(V*3@j;`lnt!`p!lHuLNkeR=x?E|f>FLz0RB*>pB8bsRC1X$(LXvP4a&exA;G99w zkcCv5`|*qJBZ5qwfc!#`ROz8=Hp9!^;-bqu;+Y}zcAdeP50oe)oO@%AtxC(BsV2Wz z&d^`J%tN=8UzVKorD2GAr~YwG5<JT6%5n%8<R7r(q9>9lXa^>L);J`j7h9HgubuUM zs?nkv)~KciaHWWO7v})R-$HTY-}>XGCW2HFnvc`R-JsMIB)Z5Ovo(!{$5R@hsFIFq zYOF^BD}tqR${BH-f=}3-f1G@D@(5T{-^;*525f+pB$@-rownyu>kj6wq6sIOmJ<u^ zT7(fDrXa8Rmp**|0Abe?l!hjjFZ6%BaUmvSjVf&WTa&)x9d#u}r>JV%wcgQP4Lr2* z!fITy2^hoxBb<VAPt&F_d~*b(2^eXBt;uQ<7_sGh`!Qh0_AiOyaLL7EaOE;pWfMge zR8)ciVD@?t@BuWk%UK;&L7t(@Zjh_;>`&$c!Q&s-t1l1Y{{Sp9!EwDumYT&s6^BIP zluAlcgSTBx`PT2WNz&P?H5Cq#yG0O+hL_Z%sNDYmKdBkqoDU-sE=RBjo}>7mXp)tw zC?%C$8{gh7{gmu(Cla5W4ryq)e-hvIQN1G3d^ufoMvuIAs!e@zq@=J-+uOyD@ZgCj zudA)};))0%^6#IVoMATZVgTwB3yu>d6|9y}_WGEH?Y;?WohJ{4OQ<O)(zR+3`WMhe z61v4KQ5ssbR+<(eve^KTHv$K?<vjFiCgw}1)a(4**Xr;@=}8Q!fQww37v=Y+@aa!5 zA{xe3sXvE_<o^J*g~Ju&=sbAO>C#I3<qp@UUYnM*2Bj<WI9Ylzw<qzVRG&Duy`UoF zr7YB%cS~Gp?yGZ>rs;N?=H`;JiS~u2N;4TAv}Fqk1B3iR2qPU;J6gOPw6yQ{1*ysF z%bwjMVfaOb<EqDe>>7e_z_Nmzf^1o+R0A`9T})1TBTHG&cum6fr0Q#`>E)oBhFL0^ zV|Db@GX@z{bLyk2x{_C%@<^%vGHAdiLKod2g1{U7Me9nz{{RrM*e}K6=VB1kGa)D> zH{?jp$Np6g4(<(OrkakkN84>Rlrf27yh>lxN=lfblY0d|pZJwIla*n)Ml-_<=a^Ki z7e!J!&<M|&1msC_+tSrxUrU}*1zMgPtfG=Ccd3KYj<Onhi9UMDP|`y(D;y6~VuBcP zoP9X3PC-kRA0fLnJ@~+?2z<yI?`~UK)ewhJ_U`FJUXsw??iTsuM4FzaGD=JefgFn& z@L4vKw*Z{^9Xo`@IduU|#f9!0U3_Dw48t;Q?vL!eeb9R6P*^n$To)@<vI*T%I*~_| zinA`jzoX$C;CRP9Cl`d4xp~k_`ic!tsT62o6Io%0v1j^ypy%R_sr4_yZ*?|_)}op# zWmJk7uxO*W(}rGrAL1|c>+Z^^O5)O!YwfeYKc#CQaWs#^EBwCpz1vskp^rat?iDu) zVg*D>l7&(fvD_81KhY$C^cd^Wa#WE|s6L+g#Sv*zl$BkJ4XPZyjqVQcb!d3$s%`SX z@%l2z3IQPsRfYyW{Em8QX(dH2NE?Ct!x*%s67z(2i4GpeR^6-jg%skyeyNBohyuj2 zC_a8*psqp4<HmT-dX3@)B`QhPy}tb7f3j1hCmktD9nchiFn1NL%Q3Y%Ru@4nOj%lr zX{1@=J68ZP!QGMe`uX$KaVU_r7gJ?#1NP)KjW-=KZ*gKt$l>{b9YO3s`Ww7h9Wzl= zbg~<a(5xG(rhya^KvJmtGvp{xtMv2MZ`<5T$`XW^XC<gw!G_gx{uVLlFM(VZUKukd z5F01}-JIsBcB7`AqBd&_yfkn%DQaY-tZ7lB3<mx2uoxNeH!=Hh_2?eKU}h^ErKI;$ zbL(Dorus#n<L@7lhu|s0sMck6E<2)@G|r%TMECG}PfcRJ+b%Z>WVodz3r(>=`Pyd# zcR9%lN%8NFwa<p#l$pl(uN0kxoAT$&l2Vly?$sc(F{ny|^ChVsJN~IS?9LPLtA=o# zrAfriNl-N<AS!DR9*>ZkG0R4&2Oa6q)mme@lf_IP8anET>Zqnqqsg8Jxej>e*ncd? z;tva4>`FMSkaFTqXAj+)7)?v)pr=qD@lZA*Hva(WAL8nG$79ien=l+s2^l5>QltQW zT8P<5PFlTEp?f;qY&KYLb@IzaPg8LcGD}rbT507_a2iRZ%#Zm;CBIHF)uLERQ=F;& z@7G3;IL=?pm{foVKqwy88*K||^yL|5o;o{s%DIS@8QY8<xa51|Ir{WMR1b6qKXYTW z0I@L(?<qpQFQ{z~M~Ej;TOzdT+R9S7g03M|2yd4zzbgO^LCWnsd->|c!tp;bA1s>f zdP7D^Xt2a<simp)zOWY+j)g0$krc@)$g>FB+b0+&?lOM(9{o+rn2@Z*5H;u1OG67< z{#cZ%<*+mWy&m_iH1Lfyw@oE#^*<kNxlYL+tqbNkJz9hR0J2~TA43F!H{|i0jsbn9 z;8^@r$usy=^rl{+l$@yos&-KMpZKuRiFmN~KeW6n1uk^HBOyyrW^$5LOMsRcf3%me zRd;GdzxZp4D!QwU{;Fwax6{%^Y>d*e61S=gz5wLySim5VGUJ}BeWJ#pODRwJmoHl# zE3Y_=;g~e3A!G!EwE-X#U@TdWK2Y89LwUPue&p&q4P~D9Y_i$tDD4*bYxftH5<OX^ zT=`jLov_RDu!(WN>lp2v%#$S{1=_4R+4nw8&NW`fVVC%InW=K8sn~c}2LS5c5m~#1 z1=^QGX4hSDiRj~9xSW9=BvFuYl^-$p9y*2W#FS!j>6D-dFU|hsgGXl4EIe?l4TCch zthpM1nrJ!?5MhtB-O#)0JC%mla<|o9>tm3|1yG2=EGfzP=2ao%&R7%3@CeUR-LdRM zj0CxulrQCjQW&W!G~CeXL*Ws9neBqHD^4C_bE|Pe9qL$r%Ukne@n>r3>XJ&!m3#Wt zk`V$kHg-lxFP!+!Hsja<)<Za<#F8D0($t_AHPz@%2JxbVFa6R%RjW;n#=b4C<_&%s z<dUD_pRlzwbfM}Z);7lxr^+0PsK`w6262ynJzRJo$hk;JEEF`|L5p0uJ_fdm*BuWc zaX}50UVxWx+1k6uwM%_9Ej%gc>8ctq?$f#%m7X?c+*pP~o=HA^_yetvDPS7WgqE>4 z*WUiI#qQjEAnDy(J>Ju)JBxMEI>wS?_?<kIF;%oMZebNf5;2XZr;q^4BB#o7jkVN{ zu4*MK=i~a1ujvsA#D%B2E4`VwxCXSRo#M+{{t2|@3~@qx<J@`*b?}hW8bOXe<PLH5 z=vOl604L7AePMmY2Otm!P2Z##(|j9t&Ml8$+)nB01;@=CcSwUc<BhG1{+Z{Gr(9Z1 zfz}Z(5s<z>LG>EG{&lnt-@8fJ-2`N6zQFun>-AB&swpk4Q48b#)N|zhIA5<xRL4E? z-uZtJkHnJw*L%=4biYbohS33P4~U<K-2&nd{{RBs;oC?h0o3<N=+7DFV<A)E@w?dc zxoJr*m0O*CyabGF)Sxf`iVb~OUcNMQ5fr}{T6*fuE}7EZh3#_u%y&^<FEc^GIsX8n z%Nl{?>{34X2d<Q<FOyy0_YnN06(AL@xr#H}e@nAg6I=c``$cJ@j+*`PncJ&fLXy%5 zsAaMsK0s+!$kQJu+;Q#4Um`^O!N}yy2Rpy>3tl2n?<lAPxjq^PJB=WQ-|=PepQ<8_ zZ$A`Y2bb`wsQ&=JJF|3?I0J(fMYI9zK*v3N4AMgf>ZAHV%9SlBDs!a>HWht;caODS z8-KIUO0&}6z9T*pE%Bs2qQ0N4xLwSwLEHjU+J?|D4oh>9jPul*B`5%(o5ST~BquXp zLc~`3Q_q=-{{X4~0BN`E^lHg%bUKQ+xKJ=?)ouDNdEsM@6s=t}BLwlE1N0{jO$bSQ zKH{FC$E-H%DeoN#{MTXWrh|}<S?ZsRF2iar>rY;LJ8{z;y7dfl(pc)?x7ClE{GnOT z;ZU6IJgR}?J_<1@8PoFq_4Fg%%mIOunK=|Hb`C4004(~|kAQ{^QSqJd!>4E2r+ydv ziK%B~Bu#hjg*<9`BL!&aTL2N@KaYMoVHjXR1d-x&>A%{LawSg4lF1~EDtt)-tx6pj zfdDoC03P~2Gd%id;m@#IYGw>xO8Wcl?odF;49`~tWcl#FT#s&t`7T0KhYv?l2g?4C zW)TaPqM1vn{{V_ZZpT;J{!{=JKObGjxxiu5v^}7*RFE1tY}x{Xc-P4}H4jkv#yQ4v zKdL2RS!X=7gzsG5qTLUSc66E3PI9tGgPwn_V@(gm7sZ{~sw1Pm_ckb;hgGy`Iw}~+ z$?)*dk7mc8ZaS3tDCKURf4nE;t!g1e5=gnb^LO!WuMSqu?R-c{d7+<JUVbF6mPMUr zs-t~XRcaMc3>Sz?2vZYqM*XK4&I#k8RUt|styLV4K6P=mw=me3l3^iRgR77ljkWvC z{HX)|$JUza=Tlb>mY2Pndfw`SIPP7`p1xSy{6)9CYS}pOtH3$WMPy1#xh<`J&F37E z`N|4HLDO@gHKDyITW1ji*lpd-7r|4kHAVKS#KegR)Jr3=;D8loB%dQ6ev(9$4&rLw zJoNoxb7$w4l_g!^Ye%8v2@h&HCfYnruT6K<H2R0|x9b(E;ZaKrR3fVGsMO_INd7Z3 z9uyF8ea{~KLr#}5Q&6OnkY0wMjcL=S@il|RNtK0}DL@seh*q??NYid@7en@AXj|rx z_>|O*tEnjOX<4P^sD{3tx*EE5Najav?xdfY!YB=nGv^%8J}WqrBofIAC934NDh_XW zx_mU26+U`W`A8SAtvZta7UdmT_=&pc3ucMBO)i(x(budDLyLVfvo<mY?SPa1%y{Rl z^MvE^3IGKbHNKv9^>Z3>uu^5AUC>KChlZN^M0@Z%Z__uv##pXxYp~Qx)z?-tOGyh! z8yeYxUF04H&^+<{`ag_f=F7#ZSy87mx2InSUjxA+WPvpRdRM$|+@dr1$X!2avl`lp zN*K%#OBf^o5kNUDkH1fFtW?>HC_(RO*KM219K_%xsdCb=08s#!16r}GQ>1c!0JK0$ zznc44%M*&Z<B9Mia!wfjzfZXFo}(pCm}Z0}Y(S{gSNS##YSKM2F%k@dugn;0M`ocP zyk|Y`TZBm@mAj`$*z3CmQ)nXt{{Yq>Z1lBarWqO3T$|OO>swqw7%V)gYXu2#d|$7= zkso(r7L&J^YNuyey=ZoffJricemb-KKgMRqAd{idsmN#na5XI%x5qgMPiJOjQOlVG zZ+36mwti4C@O@;CU304MHH_~JHkOTs21`jQ3~}xRWxcbJ)wkk)H3JDMQgu+X&;W1L z###M0aP_Yrh@1-qpfD_3IR=yhjn`Nl*Kxw%6YWnK3j~_7i2)75E6o->_#@9&F3c}C z2ttJbP5Fq-z9u1G`ll9FAHHp&U|2ql;&r52-uuTb!m9CnS&YRlbu^#{QTakTfWFE$ zx!inl)NF;RUBwRbYAo0Nw~D4BZfR%bs?5wUQ{@#b`f}Q2l1WQ!s3c<?XFg7RVE%_b z-AT%jUn!@b*m$<j5vE|tn5m!LId2y);d+^>bPl45UGhWw>O};9g;prO{xSgjbQd28 z7&TU;d(thx3g+bP9vGtC@+exzU!+wZirZbj+uWKOby&qAx6?`Uf%1VPf(Qf08OOgJ zJJ?Bz{1G6N-ca8`%nf4s@zH8u8cK)%0J{Uss6MRK>9i*Jf3eV^s=uVWSMV2zgqw=` zA)rMF6+zzH!tGOzdH(<j9XZEw>Jv^K=*eoAe8-`+=xeM~uvkPZgG<DKbEaaF?M%5r z8tJ=}&Mkey=XJQuwf8H0*4c#(H6dNQO!KT@xKDkm4o*MB$2^X!Qql)>8WMf^(g8Ea z`5wHs_oPNW=&UuJyS7<f9IIU^=Kx?ZDHOv1AG7B_*Q2;J%#+ltz);_w{{U!xcoT_f zX`lz~^xgn8MS`9xJ5<z&<jTylZ|8m_z$fX!z{k_|%nxfZid&&oq1<}%k4pR~?9jlW zkeawJs{sE1tu)#ptxHa@)?cK^Q&DiJ^!EAKk}z$<&lpks`qVgNp)U_DCc!ECQt{+| z(X1<q&maunF?xe_U#}x)4+B)eDrje=iA2vFf5U8oI0Ga3fPc4A3RQdh=@qi!IY4yl z@0QRw&eF;wNCPu2&zH*cjlB8b2G8r$sF)HR>Gf`&P`Pr@q^f8q(Wn>-?#7U9MO6yL z1sv?r)xeUhs&Gb01Y{HK$;au_Y9tU%iKn)P%)G-gY=1_xQN47I@_*>*Y;W30YpMa| zRnk{L+m1eGrXvF%P(QKy^<C`~grFS>ZzwWKzGS?_a_3KbvvZ1_@p+_-{mkyo!NiM2 z4x-c$Ng!P83tdt&N4@~YI?m-{rXvu93LMG$)1bTS9;)oW4wAmjCUnrr`AR24KyCJp zQO6X<S>mT3$K0{9vZ&ynay`6zdFyLS5mQV4(d1PXC}vWCrP-|fw57@PXhStTl!+v% zfiX?UK->-w1mno_k)H>rS1|+@{IH2Qd5JkkT5fJ$m#c>HXhUm-nyMLg2<To<!?7*$ z{!mJ>_TwD%lPn=QO?C6@5|V_u$1JBSX4_XjrlV14f40G9tkv~aN{SasNeZM;s5=Yp z1NGqv!5o|dN$NL?E@ZhXn<R!#x`5PeF3(m2oJ@9ShD)E1lY^W}vnU1)l|hSz5F8TD z!1A<MEjQaMbk#IcTP-4@c4x}XmfQCL<$owVjQ;>$vR)<HTvYLpOi)hZ_2%zSJ4dd& zHSpVn;TADUm6>9x&E80@*xtL^?-B=hyIoyx?Utpy%}mc%N@;6Zyb}>BjKjtbB_2Eh zpChNcBvT_Afh>@gBhI$!H8<r9{AC9f+4ZDd1SzSZBK3QoV@RT&lF2krG=73Il8!x_ z1PuQGOnYOktq58P%B$1$j}uH8NR*S6Ni?zQZ?>wa-YlMrvRo5L!xYd6Y2~V(b|wD+ zHV>!CKG;$Ghu^HDwfs@Br6?{K0k=DKx7Iy(@T-L(Tozh@3M(YPjTl+WZ?rF<(iC?4 zjMr&mlI2|^J7w{Zq>=K0!6O5oe0d!KKeaPvpvq5m2?08>KVJs%FuoV?rwJ<&__ZfI z)hXRoWg8GQ+1R&8t2%;`Lwvlz2#zS`o@L|)^uamZjxsVa&(}R`IC-GTNfj&s1bqpp z*{b3^v5zZOHfBTJ1wc{t^=5K9RtxKC7RyiD%ga+rC@SsH%%UROOGeKeobmG#p@`=@ zv(p?hYNoDyrQIqz)r%iTZlaMl+P(%;D-AwSVK!P>>0^|QD&QVNcZ-SenGv|@okMAK zTA5(DQ@Z1o5zQYM{T>$M`Ddy}Zql6Snn9={+K;96jl1Ay7M5_>nJY^v3e9@dHF}ZC zktFs~TCGvtO<P50ss8|*!*F?OYQ&zTNh23r;YJ9KD42OaGjKovn4DCTD+ZaW3`h(J zV#8hg#<Xfh8{!XWWa4pODni2Y5~DB|PkbZ<rj`JAlu5547e?W!4P~lp#nzfzt)gn0 zs<Td%-l9EA%EZeuk@IW{imC3w*yJj8T9t(k5Q4-DnsYVL_u03KHYRu#Rh(08dRJZE zmWYw%0rbQZOC3CPsMJ|RA=V(}vNQf5vVup^KkW4+q-RMDUw+)P#Buq6W_Q$b_va2( zTK@o3Y3HWX_jtsR(8MEWc=ohALENg@KQ2IEImUS7rj;QIKQLU=$6tR~a#>I%sy?Cm zwMAhpcPa@lly`wLdeg~H-k87#Bz-v?laP7-y>$UpLSHNHPlbMzye(=`?^@=zeJi*F zZ>&r=8+h(#V*81t=$TdS_KE3ZImRUQ3r28!pH?`y=f^=|X8fFdxuDS{{6B86M8;gT z1W>3ELs#o<tRsQ;%6J7`bF}R2DP){s@)5*&Ds$kBAGcnPLfrv_gY-T^C|HSKEqQaC zm0<n^hG#DA%063XmQ|yVb4e^sAde%4JGdO<=D`@q_Qy+9=1D3PE$P3Hqyo7mM<!=# z62g?D-u{NME?BAQ?kQVut(Kw%DzYO+0(z*gkek25w2%q&j30ihJ+{Xy4a3UhrPY{L z;<r6$%|O+>YF`F89IR&>Ots7*0ShN62_=9G?DYh(4ntB1sx*zFu*V9^QA1Y@Ed&xY z?<VE}los4b$K~;l1FUZY+bm)ZX@DwOxCvsyw+&INn&}?4?PtO)1};bb&oq?>Am&o8 z2j&V=i&Ct{Bt0D*l)62w6(wS*rmq3pen=)i;s+m0fI|)nk)C=3wHTCQl$r9Am?)A5 zEe#8kp&A}hC-AcXtS%a65nux>GL#LJ^$L52AxT(gK@J=_hBb|J8pl^t(_8&$?ZAq4 z3J!K|B(~$g<19yxNc;7n_J0p6gz&Pti2*<r9=B#-=D=&=7!SmrDKoV_rk8=kkY%K$ z2yghLRbBuUSAJ1{z8UW}y*Jz{NiH+fLv5Co>B*U}0OJ@K=NQgEPoU~6;VjAcgO9^W zm=TDaj^$XXX<AkQB!CK&nIId~6IO!1t?tWA#dbPC!AMfkhMJPe1d%TykW^XC9WO&p zk$LsDlG0kAOVC<it2L9MN_!}ztXU-Ayf>=ce5DTjobWN{IO}MW#iwGw8~*^RWc;il zd`2o{$!1xSu$-wfO>-$skd%SnB&C`^F@6i;`2GuzVbj?38JM-7mWe=;nW-g|CI$c` z49Zfelnl-k8YuMe$78)$(!o$<RFRdLrY=i{U~<e)(0lcjVdmzRGP@n_#?;WY`Ny(& zmK9Q_lpo9}TFxj~(t)YU4QVT9f|4eBIik)<XF{95Lykrdp9dX24Jdu-t@=bV5Va_z zxP3=gHRr84YY=yOrKhN-qrB79i(K?ljh>?5l2??#<+2VqJpTYLiNazOg*dsU{Ga%? zuwt;TlBF5~^g0?fwXa)5O{A>4+qk-l-zBcH-(|0Lju|JJ2&Y)f0hv^WJ3&4$PI3n) zt0o_bN|}h4AqvW+9HGq|2dU@{gNTX}CyXr2N>WsrX(}NgW@P9er~=hDtWQ^eh0SSh zz9lWoT<D6cQ&vq%wTm4^DyqX0OC-LVrsZ9Pk&}_NvImZwosXIF4AM$R%Sz6rm7BZF zfRav?4FX`Xl9z)<C21_RDF9G00WNumSflP^ryY^)Rku#q?9yrJt+g6cdfVZx^`MN? z)XBGWGe*)!A(Ba3JHTQ5MCCxpi<83@KMb5g){s;j4)u+P<zS{`b{oXT4;p45xGKu- z%)||D=9dSf9ceGb9rEipb+u)FyRu$%9crvJaM9KyGeWR0riktfDJ55acMx-s%mz=N zxa`@ujC8_76tkt+fX&E}%mz9~p?fulMiU1t1qDkWvXTx{SD<pk=vnrNBJWeCwPmhr zCA#%zr8N<VPva^TW5>76@0@f000*e-1pH115}aCrQAI-vmat|HD&KgK#!Him!kI}} zO&HmLE&(rN&VZdFMeSE-D0E$#dEt7x%bK|t7l>|}ok%+w7n0Cm?%uh`+)p_jM|R8N zGWew6iOI<<K18`8>3aaH3W`ut@y^P28cz>Ol!fM5BQe#q0ng>R>Td>GqTg$!wn6jO z)I&5w(`As5c>LKPueZ0;s!k4U(=Y<bQcYd2T3(f<d@mN<RJlq{YxJS_UoRMuJ{2r9 zZ}An_N}8IeDX3dlTLT1KzZD8sY0d^;Xz{@#tp~HXfjb!@TbC+H<luu^8q-(gVyD^} za}&eotu_fEwWu%9dEKMf#R-w(gqbB15JZD40^}U+A6`Dk>z=pCm3Kd^dElTc@`vWf z+vye3e&W(xy%_GXSgwLR`fE{0l9==7X<$l|fszQ$27GmEnSw%{HwG&bF##o%&+qny zR9c%)Q@SlR9UTb-llWT6-~;c4a6h+0{u}`88%}Wd%}Flx>jZIXi)4ynYMa6WJpMn` zAE)tEx<}#E8nlH400^+_Mx;7Jb%4tx(A}$Je3SfI8cBfn^0A@*{V;^E74-Z601%~2 z?-KRi_p}(nsBUu7Dz$Z@qK_B?qAG|Gj~T$2pdZT}IFyzk)*N6HpV{}PoLBDT*)4UG zy+v2T#*gkSI3+8sb(Yks;}~F-P{!crf&5Qw=ija>LR1J<`g?CEY)eWz*<PN<NaAPh z4ETK2dh14Ct2;B<F8f$@G?GSA-&djHrlpNX3|8Y6W2-PFzcLK@7~`UFw>hdh8hC37 zQW?rDcI&BN-DybkSwF(3#6G>cM09^`bq21u%TUy{D{a|Q$x|B@bRijtf2i}p{d$m- zlslRW4u3)A$`B`|3f}d1VbbNa)sDA=g!lvaqSKPYTdy>Aqqg?tkbrOZs%ke;@46lD zCP*hMx9x$_reZR8#rcN)*jfEycy*yY)Ut&EYPEXm7-#^yn0&PMBGscMWxjWRMadyx zUOR1~w&Fnt0EcMcMn2!09Qo;~vrR~$8f*`srmkRBOeSt@z1r#sdmS8_kS*4R6lz@+ z+Ce(hU9KI=)O7C}Gsi@fw&jqKf_+o?hENE{<@<EZkV1(?2Z!sfu=MGwl!8HOwa&IK zKSQ@TOK!JzW|=D~ZJnF8SI+U_CBEtk`+<>{o>d+Q&VSM8KWqf4(&XK*PtuUaClZAv zGZrW;2fgpVD8`de(a`K$4{!TZr)1s$YE3gp?g2cI^DM4>j!Dm5%%^o}=MJc6X-`{z z%p1+0SEMSh*S*n6=A?sL-0dh7Fj_oQBM+*G5my*E@$Zg>pDz*^$xt0@UV<%snc`do z5;(k>vlc86q@^Q6RSs1lph9T1WyUEU?W^@I&3PZ5Rw{VF8{Zkuf44*8WMY)0trI{T zl1uN+uWvZ9;QK?_giBndaN@K6B+VeFVL;v|n;1wml(jTf6#k9=x}(+wYG!qe@(3de z4+Q)B9(<mXaI-#V%ri&?GXNX!^@#jOY%poT{uWjSNL72sGL>63K%+C4hg8v5nv)!~ z)D-ZNNeBt$J%}fk$Bs!j>PVCzl~_}+NT<q`G_u>gpjcY>Ewy!eMiOWXtYke*G1GCJ zk`8^meZNnBl$=6l9_jmg^M+>UM6f1M3suJ^=KP?B%cg0q@<Q{JnYaXQ9JlIv<jIZ- z5^608RKTKh7bdzBruyn)$^0e!O2OKXh)Y#n%A%Qcb!72r>EnbKX&{<FrPz2TIXyT1 z6pwzWeZApG#NkY|(MazCYzVmV9HVOZa-l7!8IEQWoS`JCgEF3>fu$(Kxe<8(0BDx0 z8dFH$9)d_?stf5xB24XOaG-e3GtPR>I~9gA=b;L=&&z9ctXcb6iTOAsEdF9?rA>@U z-?OsSQKvo>bQN{YT1oCQSKVp7RnFLDsdtYcp9E#T`-f3oyW)XdR&r&2RMYMC5n$|w z3TOIslCWHddtA7FqOl!(PcvR+r=`2n(pJM=C-Eueh`LD{kl>Mz0N|02r$u&MV9qK@ z%GGgqt6S<Lov)O?k|fJNn&z4@){Hk|(MDTA=~_ully(}Z5ntA2j;Z2C1o%YB#(447 zrx;A-{MC?4P>>1pXXy~!FBq&mq7j)c2=1tX<mb_YbB&c9wbMdcdEGB`u=*G|s1gR_ zIRK6hPtzaYsN7~7=8VVz)OjD_?M)wevT#n(VzBcHzIuvH-M+%xRpHvBvARpR*7_|I z7N@;w-^bO{L+drTR29qM0UlRy$paZ5QP4ahYWRHr0K|j^goE97@v*4WT_VxijxspE z3Revi3RFl_fE<y?kQ?^aHR(@=nqI?doqJ_{V+HbcnmS)rN>NR{5p%t;?sJ(KdH(<i zKK)5{i;DjMtBFfYyo~1Lhb&Ia2R?BL@Tp^g%ARwDs$l@RW(n6(S9YT}I2P1j2^ag8 zveTXFlUZ2ag5#*3qMC`Rr4p)Akl&qhjs8z)2i*R>S$G#~C1LQX!65_`fRn409gTyM z@tJndv-9y>Mt>8Suu`&A#0oH_gH?yOh_k&FuupN8mJ+^{l}4&YEs-0_s&?ZT4axKM z#yH8dOHe{WiJ-9M>lv`c6(ofR28Va&Ym0LHu>-_E2|{6w_o;S85-41Z@=w!{IQQw* zAvwU)m(%y!&>CeRB&lVdg4%nvBH4UD>Lk*=wZ2`ZmZGK+bd(*DmLQake*QTAN2WOJ zh5j6{?*<L6d21GbhWOcX`23YjTL6_gCV|0kWy|u4)$vz)i&W}eJ9na{Xc?#)IS__Y ztg<o4{df8L^zUNIe-AMvRs8%t(`dJRXW}yF;-yW)t;Ry;?8K2lU)|zZ_+^S}PRwhn zw0EbvOwilu=JZ3$8cQoP7{DLp7)FW-@Ci8>>KC*#49+B=<0?R)A)kGyOShF^95nBf zz;M!(TQb(Gy%~eOZvG7x3t5e~n)-;=0VDuhsN!VYA%y`SDe}z6b20jyo}uKp4h!AR z<^6$3hn9k%9NS_^p!4d{T=nu(Rn<}1%(6RExt4Xw{u{Q?6psMl9Qnq6y%=Yy04NO) zPvfj>zYjY)SBF+F5P-{|1dR#nt$y%vp=qcont>`X=?BgH$>-ag5IFmupfOl{=QS(i zU3H`RM)Sc+F!H;FxgD%Jm$T9;Z*ilRd+qj)ICjSsZ1k~ZvCGcVll9;#gZ}`ZTSpE5 z0Ag7i1L)1_W5AuQQ{(3)**VsCvWs$U&Ya*5-A3Xmqg0V(cI8i&U=NSwpVS_Vn=pqc zF3}Ezt148Nu7KOa>L6}<m?`V0w^i5D)7C4gWRgIR3FIjRf({7j(3HKJl=t+GP=i`% zHDkH;+>PQxy4z%y`%sTh9{FS?3#JO?RPb@<lha5H49ET8G$le-Voss$;5GU@UH<^t z!|@s2e}+Gby)&+RJx!rbrMSl>uhqWWrmuo1jBmL55zQ9n;{)axI6Xpkf=K*2rjkxw z03O;by_Vr;h+^en%JP-F7IG+0Cp5Jw7n{8LGM`qisu%g<s=D4QtL_&zrl<ToZ7p3y zlS%0)XY}I@BLy3q3INIT)=a!?#7q-qQAsW=T=X&RPS1982`_|yrp+Zt%2G(~k_mk& zPuFuC-@5!5YI=)Z4c|rTY8fsSEhJLYRwE#Ui5L$~QMfSRXK?eA^y=Fq;#M6?$v#^r zm5+ndlSi3*LGYgugv6P0=7lLVsA^mgM<Z)K;sy0D!AE&?=9{?OYopQ?9-L_K)5BU> z%kKeDWdie)<blWO)P#?SoH}I8o#uPCn(Op<ft&FAiAhLWN^=VbCiHJ2YHalpUv6Ep z)EZ9SA5%h>T50Y>(qicCvmsfwu6O~*C(hoixNbLxo)nBfeMuG+K4qEWqr~_`{5e9W zCm=6U<%?{WEHA6rRtIzlj<dP8D^x8Uk+ot(j)+)DMt)`ce4u@SKgeFve9^-vOkSxs zyXrmCFCPn4hn>gpGLSP9FjOA5=c`|=byv0;9=hW-8q-j(sJ}M$Jg5i4kFP#>>l?sG z%z)*K{e3;wJ=MpIoGMureLejISYGdThVOjrpK~p?>PEQgTAe>r9ThxLi5_VYL#n9b zCAMG>uN_;sc2rJ6p2Rt|y#D}r!+TbVxSRy+Jb>h+(eGk?nDhBr1cF&<gp~~r<FK@N z@&|xC@JGMD>DIv{WnhLR^l0)d^{53gVc=VkEI<_9n6z});riD@tG*s>Gf8cytey*n z%9dF&3!xH+0gs&Rl@3l059!uj+U7<n!4FM5Bh%jrCxgS%f~yq=1A5ZC7zWfDd|U2I zOGzC>KEPwgInICMj;M1mj1&+DD}KH`K#k)EF(d$MQOtYO?|7iv#ibY9q9Q#dQqJ)l zjtI*GkEaKcdF#*|GcU=Ozsxd%!*=!={A2Uuh+)L6T<KH_3RrzXb4`cr?-FLNAG>>H z+IxAHN#3H)D<U~LbNI(r!RMS2&yEP{Cx^>O#^RPJGY>4kV|cgrx^%(Acya+0moc8~ zR?ho|)P17Cd?MCMW6|CB)}l!jEp}+?BXS4}P>E3SV?NukKjR$*+MGPOhFrU;&cwAk z#HYgEAu|t##^Q<Y31GOjGce~~MArI3UxjYwX{{MstYf}v%B?%7u253bTg;TkN=f7b zQ!`A%gvn$h#&CHaI&ZaHHB%Cgl7~pWLxDgTcz)5FaQtEd%2rJPAcsFn7TPWK2D{XA zQbR=q)@r&3R8v<)Dwx^JWVlt{9Y%4(06n-~v){m^uQ4n|g)A+oq4#)~nK{5JQ<VC6 z`j>0+f_m#^CbzfH*<-DGEn3))Id@_<s7(4#yEz1m#(3m8Cmk+a;VcvWE$Q#+3ONHM z2hd%pZ}f+qKXlUi!rV09aMq_wYm0m>4AZkZdZ``&c2xwN4h}{J)05EC<S7dIvTBpa zYqfuvT<L`%f*7?oAHd~!6x2F8k4{`<jykrfr;V4;XCYfYbL9B)2s!@%S6sr(DkNFA zKF#Z-H8d!Y$MrrO{bN<bzPf9)F;L%ve*QN2CL+}m?v=~nq=kVC5gS+n{{Z2Tuv5V+ z!{Gk_5j{9$kQBDA$M4Q2W%hF#yd=zUmeS><l1%}y?$yb$plUPHIU8(hdb@>elvGbk z9Zb_VrRx^h8CZl>_r~BidHH;G=yagVl1#&37|>f&`bJ+Z6DQ(6Mj4b;tf7OFPe!RM z1)A`V(`PlY)6tnJmg15*RWlI8o<Jb@InUpYoB6WJUEh6aqY?T*bIj>8vP~{QsV!Cv z*5yKW)WNh3sbQp$T3MjG23nMoA_i4J46V3hJf8&k#~A2-&`ZNB2&G~Na5skJ9#_&V z{eZ>e<#BXM;Zq)Z!Ar7g+7PM`reO6TMuDw0jhoY3)tNQ`7X~>Zz$1bWBOVFxIQyQn zQ`o*4iBgw^n#S~FU#}q1=;i!g?H>#zaSS3<0T10$9N5(^i_=jKRXYB<s#zwXrlqBl zV6oH$l*(nmH~~fgI3;+_Mn0WA!+RelKnPm1WPH8QkSlFozOSufui6idc*){n!buWL z&;q;J%?W0p)bn}+NITHh7p7Bis)j*S{{X^Omm#Bwu_1}~Y%tH$$JeTNZ#ecIU>G?I z41-n_Q5tXJDoktt00KJ`I|U(pOB@KFGKqSWgHrEMRD7wVT#nlIdsbfV7Q59=y5B)) zid2?Gt!<(~#sdTSbIHLV@#CJ4;C+xs1jOOEpJ>8m%5bqGEXlK`q*jt;g)b}9G|Z?a zt44==W${z}YX^?u9h6e1&SfL#rD&6flQBd5u@h98WofIXSXzLf0M<GuW~Zm6nAR+I zV{~LhB8VbJh@p0(ASCS=9GsT&dcZ%bFN!^=<C3uWX%cY~cvP~bp)*r3txL?W?oTBF zA!g`6IZ%QCi<k7Z@O!e|nwd8hKNE+<aan*OWXWnkNJ9hiq=2<A<<*p;(<Ueqts;T< zN`bA~+f&ducwkD|R4W48P_ZL{&M?66I^R1J#Z$z%W*dkox~3s2BsEk;ZAW|U9zOV$ z!4<<e?k|V4CS1iNCd`suG&Q$d!+nm1IMPEyb819%DB_x;qCLQo<x~Ye2e(#lm>9jH zi7)_~>GY+W^`u6+_fAPRtfHp%YD_Uw#XiYdn-VmPyT6I!I3Lp;J%%YMB!}r{xsI-6 z#S)>Rs~txz7<Gx`O<3(ZYfD?iPPS<)t`G{kg8>FOOp$=YZ!QPUan1<wo}s6eFNxvU zW>RyhsiIl^b&LN11;m*g2Z>B6XH296LhiS2Z<p>(8YW)fem3(kOQk&}&VriRQq2up zylGP{Wjxg289d|>ET@sgUwn0b;)+3)l3G};Atu%VLFsG8E%;R}5@kR3(=e-^g___J zk!n+%bg!}RZJ)PVR+g^8ZLfwHpsJ3X!y5!)wU$*^@XBz(iQUFWJsXC^s#*uhETR~- zG!8Z}XW>wy5~Pfx-gGsnuC7b7?-f_#Ql{nHABk#xA!@Y8sqZ3&3uP^><93RwR;F_w zs*1`;R-L3AfC_?sRc^F=KMUY<J2s_pDM*;Ivc#n&Knk)+?<LLet5zzA;yh+YXl4Ei zV&zE)s7W~#qeob^DNhpm(Jzf{FE_%zrtN-&pr_FFUCFG|^53-_KY&T;C|0VjWu~R3 zVpZKDhAoXL{{Se&pV!VfuEOw~P9<u?%UrZDpjkSd3%5wzWcKrk$&jC#q>`d*l{VeL z9%sfYt&6`M-W0Tg$ycT<dn9NUT6-)<_$1^mWZW^&fsZF1I$-=F;Im4M!lgS<to;VO z{Nt0pDe+X~`6US-_Kuppw5G8k+H3wXb-h&`4{!7zarNU)Q6sCcX!&YYn+>`&mM9r_ z3>^HxfH~=mPs3i%$(m59bl9y%_1@Zny`d7mCGqc;Cj82sKuBP1MmO^Uf-c9y=fXGb zEQ`Dyt=9M6z;`CAM_KRI+oi&u?OAHENn1}(I!w_dl}@aVb`Yel2^jj2Mhmk19|<^5 zktl@<g2sn%<PYRRF@3AzbI_?%Cc0Dz&}_i4XQ{3AG3$QUx~_=~)c0FV^G36>myOyY z3bx0P61(w`a9A8M1dJ0Zq5_=-ydpAEXcBE^8?<qPgW@wm*0g_y)Yds)#D+SwBhR)E zKg+AyiW;MxLwFd?B`i!+Ci=HMh<m;Cv`AL2>sV|50LWdbWZVW>Wy-PrMsj_+AHGXl z$=geaHCU?ziW{@~_zws>pw~1K<i&H2J-c(e>Bcxb_0uIpW;N;Uyr6SA3LuirxHiyq z@?)#g3uv{9kgXkd_9p}f!9I8&Q~v&xscv$QNNG5P5=u#b)0t=Tip}27+D+Mx$XC_r zb9}q^N4Y96H1?Y!rf8qWDraU8Qx89xi+k<qD#L`bls)_D2I29Gn*RWY4hE*r*Xq^s zXy?wk{iPol{n2WQT^nDgI~k{oZB(*Cmn)!N1{&j3o{VkBn4;r69y8QdFBylDJM^QP zIV0`l<}W-qvOG(O7Q;&`29yCI-$F~DNTdD6yLTtK^>a(y?P1&w<VfU~H{C-$z)At} z={tx(2LlTjA7Rkrjp5Sa3iEO1BHJtBUNs3S#Yzqy#VS1=M4zUiq5#)=drI5wl2py2 ztW#FI#|&rCrdbSuZ~$$bU?}H21sx?^WeHAY9`F66yZHHQ${d&Q?sW2HAqoYqRGKxY zQcu@rhV23IiQeAaL;I$#>bmPhMxQ;7`zuz(96$`LGCpRfKlg0O&V45F(HL$khMJ_M z3Lu*}q2E&8I>bk4{uXguB3F!^EGZQZ4QltG6k_)qn?%_|+)mH;np(T2nY7bcYIcXI zwA4tVYCwG9hZ04T#EZcmLL82ZNLFm4$-b1oNYB=RFTA4SgD^Lxjo6KiV`Tlh(zP!O z6rL3Xs)cSboZ}wr(<xEP!H0l6uejD3nKL(d0zmd3aPfp)t<rSTB+8S#ize-?<K@S& zKg|CCew{>sMK;<HjCXm$Yi)K9$PIr{4lUd56-6<4>8V+A8|PuhMt|GaN&%-YKPQLf z%}63)8Al*Je@M!*&83!!QK6v|L&I&8pG=&NKHfi{OomT-w6p!Ash*(X<D+^Q4yapG zt9}+3!U6_ZV~;*h9!7fMn2@SizrDw|(g{b+JLL_hDvNqQNFJ=Z+i9|6YJ6jmt-BcK zpX`15XxtF~P4D)FB_Z=TH}GoL&w9Ku)atq_SKP%n?HiEefzO|y>D0(7wW0Y_5)xB1 zvsJbF8fY|u8_u+jV66oyP<bajxzF~``#N@39j<)EH-V;NRHWvX?tfA3wzjb!UUhM$ zw${|7j~tOB6)U^tZvz9)Gt-!q56o=d`g>?|43jlNQXCRMdg@vFS4bYQLqVr5np09! zUMlP7r=A+XvO%&U$tLsj91;sB(>(tGF0+aM05cUSa~2Ykrv=DS7h+hON4T+E3j@Q@ zj2<0Ru@dDZ4(zij7Jtm7q>-z=X{nB!e$ov=7NOEsI;(uG7w;B^RL}Ss)6`JM$Cgq0 z^<MaQhWS{Mvrr%F_WD{ekBDF=44EWcwJFf-DW&elI$fmWr_f!z(Hb&=1XMPwMHReb zfLbbaQ=jIf{=H=rNyK7THA*0;lczIC*U>2wu;Nf*lmSju4S%hosP`tmeN(DwVU7KG zVT_H-k~b1JXXrQ`XkC`VlP@uG;pJ;RPoz+LN5rM2Vn$s$*wM7=8iE6^9kY4;37w5G z3-Yrk4BzJhK<Auz@%8F?7(AqaNF1(dXsoY{%K)f{ZnfszpjzSEy74N-9mZxv8&!gq zAQO{-I0ujZ&Yk=jX90lZJ)WL0@_6L622*7_-^f#)AyoDHe?nSdz3F>*c&mGyDP%sh zwz?19^mT>K$slH%f2OOPyQy=#<q?sS#qi~<gkk~}FOgHXL9avB5kIvO%3ltpreM=3 zAe%T*pda+=D?<^bqT}3)yWMYEs)EihRdpV`%9Fc>45K;q0XhDB41GEe5r@K|K@wD0 za?@}v{KU@!$F6LZFAxS`UY@%ebBT{n>p?b>heYa2rFEjJmgv*l1vZ_Ux;Yi;<Z@b} zWD5~8g^Y$=Dn9(S3iRFwgUk8D5R_(p)BqjfVAbn*gzY~XN|LN0nU;tAGZRM)px|D+ zb!ecS-_p-ZsjrDR6e0wq1~zTNXJ`iPr^zJ7K*;(Wp0AiJN|v7GOJ1H2r|V8}mrX)J z%1u0Pcean`PPOKt$Kicw(3p&l?!)E70s$EQW1fsy30_qy)(kd#(%hZ3gsBN3ieCBx ze^KLDxBmbS6as$R%R?|}S?*ON{NZ@Qf4lp1KN?a_9F&&pS1<4F2KI48aUfdBG_!hj z^oKtD{4d=#M{1|<HMZrZ=qas}i*+@y&$_<xBTAmHqfg|lO54c+zc<aXKw?j1Lc$iM z5E4rVBkp`4uGizGDr~Hw$a@kgrMB07sT#W@MNww$4uQ7TQ`J*LTdKqx9SnTOH>m6P zW!!U|^1~w;9as4F8L`tS)c5u9v%wrA5cpYgR%i(<%tN2OE*=UlRYLcAg(sSE9aVIV zGb0sXykKN=f=L{HoOKz8kkn3}e<1|oX7_Tj4atu!4=6@$I##ZCGuh}WCUA0Eqhq)y z8C4zt7yxnL9*h&pQ=Fs`YXbYZ1>(WM_|_v2At*}JkdV|g56O?Gpo2?3k<n}?j(a82 zo&Nw1s;>okaTI~U-nlp~R2AFfocY1%RLqj5bTNypj@4pVkfL93E;+SL8MJ{dZSddQ zyY*C8y(Mtf^tTCa2~k%|S0qgpG)$kw*i>#Mq!|T|3fo9H-POy5ab+z0fu*<gx&0$C z?UxM0pd4(ntkV@xTV1J2i$N_nX0#TcdRm)CowiX=Ot~$B-lzSt$0WC&dXrkoHFMM7 zjber*r2v(8MLpZjE!SzQwS}ThKc^+o{jaLgi%S$gjH{tpWrSe1BxHoNh4&GRWERUg z3VNcSmZ_>*lye>a&HTNieBhixa;3|YF$q+#k#SK<iwASK)`lP}FK?_9UNsJxt+Ufq z+Gy%0ri3~MDv2f|=N@pQKd)Drb(Dvz^4oaBIE0h<sff5qVYnJGwFTbMJ?S3MS*osf z?MbKfHLmxl?b0cvc;E&~Wgww+3AC00j|AhMbASvlX;eN!QsibWLA6VZH{G|5GvPeB zNaAM@wB`s&Zb$Nh2DB!xVq@oUZMv>GzX3j&zthzEYdH-`CRK5q<PV|o=Y!Q2Uk_5h zxan@cuj%xUMo8^W7Exp+RQ~{^zkmjt>iR=RV<>Jl)tZvWa<yD(gbfvRaaB{aELHQl zV`t&HT=0GW0IfH)m?@Zcn4%d@E)JKcghz3Vb`KSZO5~HA3ebyC5bWWpt2JPszg10c zvs@rHx`-l>(;;aAA)nM9e~<^D_#zC&cZ*$zgj9HvfB{Jk4#R)nNT{8}>^(Pdw0(M3 z%~rLL{{YV^zYM`y@Hvd`1Q5fFV~#P;R^AQA=U}GiNTCd0rN-x(q*u6~XJq2!cj}}$ z@fvvZ<lDtp(mlHEo|l%vbc)8bx7vBaK~pr231Sb--!N8eF<r!afsFIjryIm^c%rFl zSs_BScH}8-K=F^eCC7U^B5ac`PF14Q6i6hKaz7~{M6!qht!TWu8uv{?V1}A7%^w8D zMhGX4d=dWumU;^hB+<t=`qJ;{G}blTXAw->DZ|yh^**s>Z5R3K>1k$?SjxPrthwG9 zO9Dm@x$)7aU>(xz!};jnSb<t4ki_P#deWNn^Jw#zt0~%_SZW$YgYcB_-Kye6T>k(8 zR(4RU_5lL(z{lUMM+U$Bx>OYO4hO7x9}<aaoV^&RR<$OYH3hn99US-)qnd34-TP#; z^2u$3P*Teq1SAPwmAyfqCn7kw1N!hfr}n_4tuqv=kVe$gpR8?v2PP}<^Gg;g1Toa4 zxQUNmX-hg+#Y`fKmZ#KT)aAXhdHsH!XLy-pf)EJPL;Lu8(me~ov7svkSJC?N^zn`; zYMAG()mnmTYI!K?O?^|wj=9J(NJ8_@axvuRKg+Gdh6KyPAqTt|b)W)*JR`@aCT|j- zF_~GENWIljv)cMag!q-xF=;9LHKXZb2{k5;rS)QF<~d8s?Y(`ovA+Cq?bKgq)iFz& zqV82nY1jFShs4|*xnG3C{!}7TLvCHoY2LZm-n5Hx@H5++eyi;#b}bsV(6>81%JCfq zN_wpsnl-0ZDjf)6hLKnheB<w!uF~U2&z7ktD+jfkQ%+WkPr}|8ClP?eN}h)cAP`8< zJ;OEhV;SrJ0D>C(E}*+#yJLE?TXfn}%PlnpU#~rFC-BDPiP>3XbGAUP%)5O<$I1^( zj?=u4{lFzpO#yA%-?ul0@!tz38LOLySQ3R&3+YmkO1|#{-w3^@p4Z&rS$MTuZkj&h zK%yeZ0rjJG3K=o^w)%woc=6SHwET55WhPPy3rTj+g7z;>KIRAPmjyEMWpfe?vKYH> zKt|%g>r0sJMXTWd0Jj>e@R#d-P1eIM1hWcCkm^Xu{E`L>=f?x8N_gD#xmkDV{P@O) zj_gkkm~$Cm7ps2F^osNG1=)Idd$-%2C#N+H(i!KrPgPd(G)&L7Rf!H&2GU2U57(b# z(Rdyy{iWssP0RN#{WN%l#`Y%wnTbrfcjisCKepA<Cy4tM+ifAHqrP6P)VEstYG$Z< zirHFUO(O-H3g0N;A8w#=nAx*QmK0c!7zU>;KTR(dJQroKa>-GY9B-@pa6opYW6ut@ zx&HfIQFgd2Yg=6P73#{smogT8paFnDZMgA))wi=|WV~#Zq1nTL7%?{_lH+chQZ$~} zaAYrnVkM-KfhdDZGHwB-!;eUVw4z3@PggYaBydzfs>L>$ODmE&@r*Cn^Vgs_vKF}$ zWuR3_P~S!tVIB_b+{sAdIAsvY`H3c!H5YM8Y$*Cb{Dr9QV!9SohM@$dnL}XiZKJ`$ zWS<|W)1t80aDbU-M(MM;v-7M8#xWl{eA%f=fl`Simb<7DM_tRrvtWxgvriF-Q_U$? zRGhK-{$ivNkVbrfJ-<$b;?fNK9Nak7D$<sr=?`{iiz|lld_E~Ce5*;mjvNE4y&_0R zr;!pbQb9OXRt&=g4h9F0@Q!i*-n_Ain<#gshRbr@T%Nl)^pDdMhCo>=E)QF5e@!=s z%34VF)fnPMEC~l-4A}NRL;nCCIuUq<1r^ODNY>|C6JLE{j5Zob10oVS06x|6sE4|` zRJC2zspK_Ci5*X<5P)2wum?HLHxK^+SMSs5oih(H3qkJ&BY*i-+1Y{8mxpA?{7j`P zX<(pI*E+Zs=NF^2{rA%`)LW>l=%^s75zmOKlPVe1lj%!_3V0lm%95mWp1p&@ej{*B zHIJK(7?nuG;=WdNsE`V!ry_E!GF2?Qgeapd%A}fz{NU}c^z+(255pyJ*%)Nv@HjI) zQp!R}%Q-=6&nS$^S_ok>SeTU~x}XJOz~3%)+HQwWYHQujS**2<A^aV5QK};B8xh%a zusWCE6M?jHdenF?Yq(}QZZn6=X+}msW(APyDmjP?P)P|;0n27CQsJM<+)J>$VoaPK zGEh|2VhYvrgt~-;f|1-xmYl)M0CJ3=hl-!x>xP)U_X6jB(rqGIt0~}|qbx(voW`pv z1&Nsg?i^zrAIdy+=RQ4vRIFmsrJ-d--3oF1(a1WNb9nTB2QyWKN6(isSsF}9N`cgj zz&I2MAXI4fZ_@K3K#`epRF>Vq2OobwPN4#6l`rk;)`PJVWla3Q650)P@Z3W_r>Baq zxDd|^kKs{M$0DfUk_+;A{{TDvL))fh6Ol<NBvgHiLduC3@csR?NgBEeTYZyFZ4W1> zG%`5c18!2xK7e2new=k1gqBYfmbErtmahV(BvI*L?^ZE+kv$3Q#Q8z<E_yb0N(<?s zA&2i0uCUXWJtZ}|iYhyGO53Q?s<25Msw*ej2~j4(jw4Jc+mVqrPs}>LE^rG6@{PCO zTOEzMQZj5dQ6UE_l3ai}njW;)yNI(Lyzj4XsH_@tj<K(H>Zs{r61U)qgfKudr3(NK z59m4L$4fI1CNWm~7yHvd)q+cuC}bI*K}O`_*JmSh3;SGcTA$+~ZyGX7E~<{MiEhzU znPiHh-z*WwOHhs;FiaA|Q6ncmBgeZV!)Ta%M-G|5jH$;g%CT-`yj)k6BDsh-#N&d{ z9RS;U3bncuw$UJd*9|9OuJ}{4>S?W3D~<jwS8RqWoD?v;&k3ijLll)eeN{}Hg~(Fa zIOnXl4=re#l{;xhtHtF?GbGHGvqqpjK(%Z4YtYA?O(SoLIb=V@f?rV?WI)?o9B{|j zAHN=b`nJm@Kudm2&%<4L?-h9{IRTeMjfR1)4O>S9mUgUr3;mhuw4VpPxU^Esy<W2K zQ7V$*8QQu`kO;#N0**7|&lu{n#gGKj_xf_AAH$F`D2>n7_0-Vvv~*5}mIFGnBSutY z5c^Nm9xzTm{{2EtEPJEIC#AEy_ifkh4|72juMn(^ykS8Z&&o!AX2Wv4{m(rL6ypB* zL&;DCs9&6aK4|-eUrw&#tz3dK#Q6IIoPCae*VXNP(B<|1dA^ZFmZVDuAOO>rzLeAM z9tdsy+q_fdsq1ErTpaBzqz^tp^Ne)FrGUu=hk@`Mo*u-lGqVJMrE5YtxI433tVwo1 ziG6)+LHK=Fe5FDD6{&Iw&)?6So`^^xK_CkFd*nIq0vU<OR7+ls>uUQhA~^3y$H#Q! zv{dLVJFRYX5L88rg=IVJsg!e=V3Z80Ir*3#2fx(B&nP65L2~vU`oi%16;oy(l7g*6 z0z!+LmaVOKJgXF%$=#iIZHjfZz_!{3c5@UI(YR(Loy_l-b~)vV`r{mRQ{tRi6%tmR zF7LL~=}7cXV><(yP+t<l%~&>O4SN8gzRn`US#|GwQkIcpvfVEK0Es~&I(&;7@NuyU z^W*0GjCEa^k4YafYe59N_0rX)g(FVk{gsxr6=86=l*><cnVYCN%WTc0G+ed+0DJ+c znr$U|g%cTbPa(%7XC_0D?dRL2;c>`W2@94%yI;wuV9q_*(<}t77@!V{QP4d*^kKR3 zh>ut5-D`QTpttHeddTU<!eVXlsNKP4JBcHY<?Ymr+-ot;18TsZKz$;Mf$Z>H!l5E^ ziE!mrH=reb(_cMW1}!ufQ(5@4X_}>$Pgi?Xa>SGD59{aa)GYoxDqP<7Ab`LTl<DOf zoR5Y${A`w&!!s74RK$=x9Y);0SZR3mCYZWKezH|PmcE&qNJ<>K{YG}<Y4BIWVCSnJ z2;=yCLHTP@UpjuZrn(BoBisJ~gIr4tvx*pW$w(dG6n7c`Eqx!W#kB43d!*IYVkUU0 zFYsVh(}Gv!JbJ61N;W_Ilfm#aEk6!~%phBpoayxE7-9)XB|}gdjYgYSkSx$`SFdYk zA(EmzsKVgjueJ%!KTe*Oa)x^A%)s-Km5`*f^kL)Boj2AQDs_!`XAsH1JO^RBKKKX5 zdRhafrrr_Ho}`DCE#mtz4Zg+)=hpW&C8MoSc^C%({{T;axB9wBk_+Y)Fv>A1up#sg z+`9DM5gkQWQzQEF)VeNNh<x$?0B58wUv<&%tUE2o_NHH3{@yg&8LMtk>_()K!Wn>9 zW&~^?amSqh0Jo<ORWZNo=kLx3lva`)hj#+SgSZ`SRvH&!2&%4CQ^yo@NfSpNW61M? z_xJi|uE_yF7Ge7N!^ldXGC(R67cJ*n>3C^bT6GI2DZGWoax=;2-<*D(Dp_ma-jJ!F zl8}RcFf}~BuvezLdr7Nq7Ztret~MH5aIcx{v@cO4lNQ7=JM3kQ6TOLSXK%~N>N^&& zP-L(m?`FA)t_6!$7I4N&BrOHQwGWNRrTG{^r?mC9eRXcIS?Dg6<k!>Cm8_9XPF_uq zlw9NkpC3V=Jyr4YB+EjVDS${Mur0g3oogCy6SVwWi7r{<Qxv@As-n%vBv+G`@{9BF zo2PDirrqgL#AkbDWfe-(#5t6DjEfp7=PmN!51>AH9aOs>g#6N){{V3(`H8OG&6zC9 zS{P(dCu6-xv*>obbVliYddqD*(7&W6g(PJoleu$)@9*OsWs;;2vZakpZTv>RjGl!{ z3J1D%>rq}`dq*#IUvulU=Xte$qN~)}4!1^APfKW)%O&<G>!+L3W|Bx~{{YIDk85T? z41B01ybaHRa5PQf(!rw0%=Z^84X;lK^RH?+^yyqiVoIC}0MBM~YHQa+7SBNUM@nnE zTur9+RbpIBMVgjZDN`OX%SKzA41CFv@A^B6;j54VE}iure(oS#FBL@a=>XQ=Ro_+* z1(mh+QB==N_sA+7oq)|6kf4u0mxVd^&N0+<xo04l38B%qiBzc}vZOYQ`|D9b^yvbX z*LS3vGd%PaEgLHl5L8wf&p7=LxAy8U8!V9SYWf0S)6w9x&XNj{5)?(MY101ya{)J6 z${Op1MRbWRD3!idLS0X{Ax8twGy3%xF9$UV0VEdHo6+J+1;nJK3JL@syYwDUb4XCP z$e^KyDhHGp1w`zuHiAMvdE*1moN?5K4+K**XJ;qS^7}=1ig6T7&6b6=Ao6oS8u~>e z?+w9kTDGPc;FYUbQ_Rog03ka;`uY0!!1n6(f|ru%ac?to@5U*|5Hp0R3h7&Iv{lM^ z9VjC7WC$6HuN*SuDd#?U>RT#ORF;1#8`OK{3Ye;z0+X%jp{ClHx<3z;?sO$2a%^O3 z`qxAxl0aqT{{TGy03*~tXmNQkq^9mP8&^+VAnwcHWzOPBLYcCYP+Mx&l-%=)FH!ej z;)c&~(S5V+Z*q0FYI|a}5lM5W(X~R|b-TRN{{S7{akSJ+v*nFulhQ&IU<~yG!)IZN z49V`LXzs&bVkLV(gEJ7hNJt}*^t(4Z9pKkf-07|r-M6;du6_Hs+atKv%u~3M(8*s; z>k%wsYBT9FJ8qGjfXyIb&z_yd;>6%c0*Pu^!#pBa%9fx*5_###+sYt|on=hd8oG)h z=zbV9knIEJ$tVZk1(%OKH6b8n8}o??vsgJ{q5b{8C|@t)=_Y8Jk!PnMh|uxjMjIo~ z`i?qv5{6KsS_eicK@5;7u%};d(cqTLQFT!nl7z_`D;$jE{<-7n)Ltxql25&2)x)L< zvcOpOY(H-uB6{t)p7VW%+SLA&wJiB8Mh@7^mi>NTf4JzL2QVuX9>wkKK>eXW{1UQ? zq=Gyy``+i9L#<_|8cJKDm6B|u6&rF#pMT}njfl5(srBjm##16tNNm(PH?3fDhhJ7x z#{^K-)+7jvkMl<ZA#=dv-yhGc)Q}WPuW!?*z2k66KmZnI8fpiL+KxX%Z3A+S<6C&I zsi;(_o_*P3335Om)Si00aH+vk0a<fjPdLrHM4$cY$VdaG;C=jJMAp}t^zN6kR$A*` zfn~3jIi5BtpW$u*pFYj~I*#oewPi_G=G_O@Cc76u<;<9(q5w!fwWY`o7;i{`FFnn@ zLn|2d1;Sc@cVQ|EzJA~wjxp6<R&cK2snq`dUNLx|#@JG)mp+G1R)gNgcOvs!sqfI- z{{V{dQAIo<hSWKgm9RVy3;ynUs`je{pOq}Tu`j)Yb+k?Pe~L|+HAt8U?!TF1=Fm^^ zO{*Y_wl|GdR7(xG($~rdmWd~i)foQ(5cJwZkFdv4otMFxxWvlJ#cn)Er%LTZ1@@mG z@~~>eJyJrhMI3E^tH9Qqzd>JQXkwWv3Z@mo`CNGfA8<j>^Xcqv9FNaVTKQZ;q+>G_ zs#&`c&dhoH+AAH}he%N0?lhG)*c!j_7;@~{jZgT3?!x3a9zLfa_`7g<<Vgz-?YFwy zPZ-N5Vbz1jEJ{eqNg$VC<>()-kbS0`OVKE|TPgnl4;+#qcQr4S#&{c;5$6Pq=L_^5 zNXy4g11kkHi$6DQy<%$(jVMbbl=%5w+x8wORVGO(7HVtFUAh&+fGeY5hwK?i^Nw;) zuss1Yj3p{>mgkUDdE0-yOQRn<<$@-tYo~_%o1aMHA9R*VUgUQQo=THZ65gwjHgM8r zXDo0r!N@oqewgc7;Zq62OPEr`jdT|CXv4Ui_r$3wI+IFj#@@_yXW$=DP+D|<cCEL2 z%`LSa^G5rcnnx^6E(2vwK=~AB1pD<(?Ur>d1pu>ByyI;6EseuUlQ>UUPk3Axw<}t; zeo<sQpHljorjk1x`eti~s-g|j%M7FD<C42bJPdxFR4}s8rDd9x6I%zoYn)Gye3?YZ zoh&u;7ID<?*TKYa<6Y6}DlL0>x7{t3)_csCNuD8AS|6F}TVcy%C{HE01Lh~e>t4Yk zT4dywpujQJm{s|{o)P05b|B8iEouk8qFj&p8-t*6MsGNq{w!}aS8nuP?$X(Bwky>X zl-7#>0LHjsQBhAyVtFA7a3MQJ(~=2RJZ``jvyu$CB|(U*4S~0;PWJx*7@TJmQ79=; z2?}!OFa&JOLmEEkSm|HE<d%Nd{5)vWsy}#JDlXL(cdD7@pr6u=Ov;fi<yH(mC}ALv zJO0@<4mm8&o~6tDp@6TSJNnaD*Siv_d^?7qEC9zqIKLFBWU9yfnTyuoQ<o^3H6L*M ze^U)%q}Tdc<cz6$X(}F`UQSSKWmn{>_A0nL%8xNC!ps0Q0qfA|UV7|e=ZEb!4xw`# zbz@pty-wN)!?F5ajv5In=)Vgs5>@)NQby`hMfFelHU|U{PmT|_S;rGumx(gX-MVS% z0^rdtD=LykS9I!k{tuXrrFPp-Uw;90REBd3u-wG#QZO_3_x{}lHxdbT481>V2$od# z7O3~^)WufsRkk~C#h-8O^134b0Lf^jK<oL#Rn%_Ae?|M8b!5RICQ8fP`{;Lw^EgbN z9;^%WaNeQMIFbG?M7m$$$GX?*3V<qM({q4HC3j582a(VI-0|<#aZ<i!DIrAr<!GGY zrAHDHSO?bLH}lfb<^xjAR@RGzHmXH6#uijmK<=xOyMP1|0M2}ldeJ*8!qBXIq)sJ) z&0R<jTZ-~#gZ?FP{{WG|aSD>kDo9J3BxWrB0^W9rR?!v8)|F97L2+4~L$uWpl~rKb z$jHF=AMiT#7j<CfG|&U@$~+l{n%IOdyPesoTN7G4j{;!|O*gGc5(wmpGZdr%*+RgN z2aL9H;~g$}NKjI*;rALmA#$drZjcbG5XQq{vly22`Z`T)kzOB0W4OxM-6E(TDL?V{ z&N>qrGyec-Fx`@AO^g1ZL%b4Do5k?56d6jGfLs3IOY6(aSUrxS1c^N-i0uVRa=TBt z-{S{^pKPAIr}DEazFNinHfQA@scBlXDOJizJL^t#+2Qso$e`Zp%&+PEgs#w^m9c<v z^#1@>eDxPPS(zxfa`my~ZUN@h@`u@EwFr99KU&quesMNFov`YOXSZqE4PSTG@zBF0 zkw#%!SU`#;%!SCp9AoQ}I)LpT6vW{124*R8(S<An<|?4FSX^JuGikl5#&Fr6h{sKn zF9(>1IsRo{zyb+AQI(fW-!x9F(i7`TT~(gmE8{IRp-g2%D-c~t1db1%9^F{J7xs&W zVz~AiXq-M$7J{qLJ=lsR?@i0XJ7wBAJZrPq`5X;@Hxu%f03Vw%K>?fs<WpxNF6Vo@ zrQ5A(+p(r?w>mq;>Z(YMGy&C2b9e=rj@1B>l1N;0p0<zlgz#J^5XEs>(w8oDrKKrC z(=kg{V5BG|8HfoZF;alo2ahOrTNA{woLd2i;ka4ym4%v>%v8xsmJuu@reaB08P=iQ zN@apgz+n{bhQ)HQ)>LV`+ghn=e}yzMK{Ry{tZb~g4H+O{uRl&cy=Pwxxcr=Kxr&rd zR~Jc3ooGNiGlz{=<F-2o#HC?4mJcy((#cX(k`Lw-Rclb<nh23J^}fH=X%k~ou1dUL zIB&1z&O9Ct54Rm^Dj~95RsH=V%;}kwJ2?$X*T@g1mxp><1#aO@10uF*sU+Yt?;e=W z-J>VLJ~RHlnoxOe_I-mk{@D6Q+0^Wl&-M4L9cvm^x$C_*q@j5hXlkI6Wq7?Y3@EJ9 zer6{=oD&?Aj~M*Ju5hd=GmPM9q_;bg)@wxE>}r+5(S`)(O4Op^ix7}2wfEk}CAZVp z8ic88tCT921vNDg$1+76Wt`+HNESAJKsd%&=fTfXf@mrvu-S>PJJfY^0V@gu89M4i zYpCXRW+Krg>2A}|S5sRZjMB#{C=%?8<r_%~Q+MXaoM-goqKxKAC{+iSt3Q+lIR=VA zV&}=axB$B!;vaNRX6p3Ua5Vn1(zVt0SsXX7Ea@~;#;iu;BcWAg0E}UmDtW;@Pvdb3 zGJ+eY?MN9CL0h3hh0P2213)*YIO;EOdpAqpPRQzx)$2Lqf8UfX1a}&QF0^+w!&B5I z9bXa3s`$?+TfjWlCQHn!QH%c0J#>R5;(}M3YIthnrQYqzJdRP)ExPADzM*Mjxl&6s z5&%_*F}cn%fDj059mgZzt5ybTR)DmNhtm4~(E*Oa&6cAe*(9+JY#Q!qt6yy+)K~0} z*D&f%!Rl=>Q6w)_XTQlZ!wwn!8i-NHpKZ~}jkxEGbsxv&u?tJrqo3ETD}<_9DrRaL z5`5m~-VGgMf}W0=p;DXGk9!gWK*Vl5eqrG7KeyMaqE2cL=h=%A<p9U@`{>l`1hCXJ zGSZ{8Zxa>|wBVz7;2%E!0H!)&NdSV)4y9RxuqSB#aE~F5H+OPlI3xte>z|;{zxC?u zF$hw?%Ezy_iV4FkAxl!7Jq;`VS0EjOHu0QuB(l3h>dJAseLR!xjDgSd>ys8h%4xs9 zVjNJ-H#kwXSXV|Y-VH<61~p!-Z9QkNt6IRdYNj}_3n6}f-(&UXqa>jPl%T+K0FOF9 zC=U*q%U{YX)tn@eOMt?gUhfs#Pe*UBmZIf;q`g<MEXg%Kc9tZP-&~xN_v)?MY*Ny( zCk(VC>J~k0+BAB9;KyY!3KKE>XAeC|Kak3>-N-q)2cuA44q&O$u}K+U`C+v1%Oo-| zZZqU5!T$hv9a2j1pfM7NI~0Ro5M$9ZaQ&T_fQbvn%0Kq3)K>XbZ3FTVV>EYuG%lIb zw%cuOezB~)QlliYEI1eh?EK4<@{D_9rK<yonB+;8P%C@ebv^vySbi<RWM)b@o+?_k zPD634e4$g#?`U$Z_>R%_G0d{Xs^vw<IdDJy-vbBEdhDD&AqzPqO91}>)GOqCX$j(d zcYtC6MBXWjQL`Cj4K*@Hqjao7TJyQOZ&C^tpt4<yDz+q&Ml8r!@B)F3ap(U4Gc2hq zBm02~I?*J*TGP@pgbWO|Em%BGB%rlB$$M0=%1)H!5u`f08g_`&+iQtL+QHe5bHUDA zla7-wg#t5`CF<-22a|Mf7$|m!4=Q4%iBr^)-<o8~wOjqcZ`vk))b_Vn_th%~lc3X- zu|YD!ESDORuM-u{cA`5K^Usb59Y|sLWWc|enXJK>0M^!``ty3VBA3R#Az&oUB4z+o zvn*dm4po_hnnQPVyF;q`IeI#TUbK~6$_bGknue_9fm|?P*enl&@`5-#W2+YomV_ha z&0j*ooP%lS=NNBjIKSfOoI+&@NM_2BM-_53AX$UxVnxwcJvpdoh2pY0`sfTX$rU_R z5yb4?pji~NG7PBRF}M<O?Vgi4GiA6-3hB?=>k#tr=sne!6t5@T`SQ}SCY!E<w80#9 z>t3C&tEf&rTH?lzO_1T-`f~5VT7j@|M2VB~@;Xs9YW&0z^yvQp#*ruZl%axHxjVmC zKFbQ!z{a(@*e{n_HPY70WfUzGQ3#@qk)BP6t_XZ(nnicuDvjU)=c47h6PZo`-TW*1 zh=|3d3Q<B@ynwr2{{UOV7yO^P$4M(q6)iO@#!$4gB4J257$+nZ1dMsX#(nuS5h$FC z4z0I(e!emEsT0x!#FyrD9w34?<*XBTCs^M*m#VHh-&<-sCCgG*6_U25odNZvSk)E1 zIEK&;)B#ilP!s?H?HrXXBxOi7Yqv%ZvtA#WDMn^?7bJ7>+3#EV#94D{I(Af}xJfj7 z4q9kR9l-O!CyyM1_4eqQK`MG!?@{IV7l!67DQRd^g?M{y-hw{RQAuAdH60?yEYc#% zrw8(VKpsXj<EAoZaCO>;tMANS2rh|v4Ns-*v-{dCG+mJH<+=(BEe5%$q@buDh-fWQ z1xYdkM(HDv*x1R8_~*wxanqHV)%Ex12>eP)fi(3SH7-uIFiWDo9x5#NA#}HD3Rtxy z2i2!)+ku8bE_w8dkOQ7bB>Bhd(2D#zlagQ+xTSshZ&;KPafw&VlI|)SLHz1%(c<mX z-So}cuH&Nn<)XA^tgE=t#U(vORdsb1^?{#lO(k^|L{d{!A5&|jiz1wyE<hbrr*Ktp z+21<|oah$Z3+bnYMZ{Y%ou*eAmITh^<bGyv78kE40^?Yh?=nojgckIwWB7FnvPjao zRbMRZ0cODEjtdi$$vtF*r!tD{<-AY6k-e4w0PWDTb{;ebn_j&Dioa;n+TPh+d!TyT zo##x|vr8>K%34aAUs0bU(xqWgssLiSCq8~r)}XF7g-b$Yx=87zo~8HxW6#Us=48bw zOv)$2b8Su8{b2J?Xu2C^<MA}rb(BgG@o$M?ikc=PClFJ`5);7WjE<XLGIYe8=9*LA zg*;3Zg~7^|GddDU9X_lW?EW=_`kQ^#Dp;b+3d<0bGqMF?f%7usAOY@u`mW<~m>{AG zvyq}gi5#3$s5JsCcw}N!NhOoHVaQO~#?cq*3Rr1gN2`XOXU8TYT%JkC0|4`oKK=(s z{v~Uc3lxrju|9W*nX*C_N|p(!?%eJ_R_DqZEHrT3Y8_|w=;1+@*n@MfJOXp|9FCWU zQZ;`D74p-Z50sL|jMZn?f70Dyn&Skp(cPA1RFCjgB1qtkg1%so=Kzv(j~PGg>Deh- zu}DH34BLwa<UH@RF^x+}QV!K`wQsH3(Mh#M*w1s4x6>03-BezrMDrj45a9hVe_Zu< zz)H-ODK2l%T196SFqDAZDAcL0#w&G6F~v<DRb+&0Vq4BxjxqX-eNP<qEM=wCk)!C| zf2-aYkV=vi?8BXomAmami}&!*M0!SooS>3HHCFyd%ku&``wV&NwZ~;7$z#92ZqYT_ z+28x(6+ly3FgCPU>V~6&rhzU{-vy*dzli(@kJk|ApG?W}?)q;S=RP{8nUrT>{=ayz zaYFNZqTrVC<xiocL)ynvQrPu|p1TyOrMJ`3*1bG(y1SNmW>gG73%eVCC>)>DsVPYl zP*UQp_2~oQoddgbq1Ef9ziPx;V48&9bhVgEEoH)K;1yA*`C#23;h6D-3^E6nKEQPR z=|M>}Z9VlKL#z-bJi--lY|dN?UtMdZ;_9B)=`kfiOMLWG0^qLJRbijaw6W*!kLA;0 zE#7K*wLKrtqza{}EuN&iP<@>A(&6Dh{{VNEYG~^opVWh8I|Vzy1C>9Nj!!3_^<$uB z$V%Ct-@1L`a|w?jKqS!9qrLYd(7}gnbetCJ)%x=FFHQJ6bdbc-FlF_o9+(_`6V*Nk zw^JRRzz>_B{x$QYMRu==DB-?HrezHc1HJCWfNdd)`5n~NO$s_PNH!~!!pKNJFvpw^ z`1)IMs>vZln|=93GXxU3sZE`q&b>ThEtbbuZ*NS^L2#Fq2^@{T82vCY&jj_JNtIAp zk3D}zz2ioi#L$vTNz+fbHS*F9G)=kG%N<=a(Zq#aSV%!UobkaNf#=BT<-keGv@Cle zi4AD4@x_NqkX<FgVAkFI;&9eht4-s!8lD=Movsvu3zTI;0zyf###bB_l1z_$4vgax z9zn<$iuqI4%|QV08HhNv8Gr<+k?d_sxA9PF=<V@E>BK3i6au9gf*kX~WA`0q({W6q zRt<Ip{T;t})bO}3n2+scuP_^LyTg`<BGIUS8A$PgEk4J1=T{6j1_R|kRXs%Gv9jjM z%I5U$s8?H!qGtz#pC)0MMq&vC#^LX;tVuoMWxV#cR9ojLX=-SU5-tP$q>u2C&*}E) zJ`j;B87*57ZTiFgp;O?dpo%COY1Xvp+SVaH(&{=pb#*?Ith&OrvA~FswBN-so&hRA zKG@)U57VjsH-J`gI#_g<a@Icgil<}vs4^3WQkhAmO>1gu0HLOk*R5@|_4@BnJ-(~= zizlSgB!4-L%wX~dBa9w;XAO%IWF$=t@91J}a#KGLq>>2@ZT9{l+QVjMb5k&rd`uaO z56Vg8kH72Gju(g_N(l-T(|w!Y+9Q{Mo0x(-z76C2UJdIk@~5m(Dt#6^k@ny=dBEcy z;AHe-u`<taHnewO`)>m$46v6rYqq!82a1o}ABHU}c>U|H<L)hY;A>h-ok0}wGC~-a zAsk?o892|kx%1SVp4iTkufqvIkwD~vd6B-KyhUPr7b!?Msk47G6$(()(ft0gE_UL< zq_mETwo_?p_%Bwng=V=^Q>Uq2wniSKGGS$req;cge7Mgirg8Y0lUDPC6Qi1Y+gLY; z;pJfE<&fkJTk{Rc6nY1OyR}7RbXSV458i!Ar|GAONvD^$)iKuKjBPVXBZPI_M&x~- zNIv~Y;4yf4C@GkNrFBUa<kX6H8OF`UfTgGbwOf{~PcSLt5H6PNWp8tJMbA}gOZJY| z_c~g|sC!<nzEN5utz+{vki{!tfESPs&BSh2>U}(44j8J5Z4yte*3g<n0&we52`8X& zPd09MrRftFesq6uyM+z5-`s0wWHe2x;ZBiJe(1i?+_rq5<xwfbvt;C-%z=N0{w>A1 zFNk2|0A!M)3)qgDwS2il(&f)9n58vHau~liy&2q5$*VAeT_tt6>6;dwv03g{xb2qu z3VH|#l1M?0BnYQzCm)kIKbJjGaV2vmscr!zmiO;S)-d?RqYRvm00NtSqs)U2w_fFH zxugC)Y?lJ0HfteC_Tw9Qkyz*DAJg>1lI4_!Dh2)S<EDnNRK72X6CElbz1#T@^ogIh zI;>LJ?UO1)0*Hj$BR~`!a;l?&k736Htjml@Y0G+-H@2Gn_lsW)#AixYqItKi0R8J7 z+R*xb{+e=Xi0trw4!YR2aMZ^z`LdbF9zz0gkEiR@T*L&WrJH>@{;?@D5R!8eUE_%V z02CU=?_2SQPfZHd3hOnxlGQXvILXZIjC<y#dHek_)!%}lKZR1r*OQOG)*`VgNX4K4 z;0>Ps{{UBuXYnPawHB-RE$_v~dRls#CDQFsz)Z_7*rZA1Dd*-@g^#{*<EXwKk~oC5 z0>CJKmbEl{!Q6dsgqe}^F*M{KKpV@bk1O}RWn#2b$wz9AqI&0Nhw)7BBNZb!OfSmV zz{up0<YygkvM?w~`KcLIyH&o7OnKWK$8l5S{vC4<%Tau<m^gOo1)iWagb-*gHK;WO zlBz4!1x%JFMQP-(mQ*2klB|lja^$H1bH_NqJrHq!EqUxK@1EAZV!SwTQWQsZwq_vp z(9-pzULyTZ6)mFYa-%l7>vgUeU;;TRJz=s;fZm}1k+@}Zf;@Y5V3j>fP+$kUd!$0E z3y};9g5rXWqf*7{=9F<*Fw*AXT~}qa(nOaEfhNR;Ie&`JuyM%eDy&boJ^rlmDUy|g zniNBn5z>_PW*Sxw;D}l{yqT)97M0kMMOJ~>5zdSNiEgt`>N*M1o}R;TsS%c8IzZAH zG6pw!<v_q6etA7$s_ep+7^r9sg}imun|SuzuZ!3fE9I<|gRWA-t#IbIKS)V;TKhFb zkyO#$sk0I)`mo8lK~Q;YW6vMReYyyCHgcUQmq$9M^>g%#1fPpM8z5Bq;VN=pDuAhG zw}5@zv@Wu1OP#*un>F65it_9sh2p3v((pW%;DSNO#yI|wTw}2YWTJ7B^Ccn6rP;Y$ z7B{2|itWE<vC@?z7dBeO*-ifd;(|2XRGM;&RGK$Z>I+3RPP*=O40Q3zLefO2<|!~Y z0@+302cJA~p1ky~(O~e{g(rsKiKR=HBTxAj@~vYzmGMi0;4upvCj<(J9px#UXhq9w zR$|o9G*s!De%91e>k6xFpSb!}5#ng21sW3HD3zDzRsiH@k(0;=2czV1v&M<Q%TdBk zEm30CA?QQvW3?VElRhOQv=}8|(=_2{Gv*!OMxnt;534btXATbQ7Pzx(4Nq*VvS~{; zo|P%(kjQ}|aTrDeBWlJ}mR?R6V*|<SPxxoqtX~brluyK|cM1i_Y8P@zr!7oUJ8!{w zl#UF!*sNHcA(P!&m6$A$#06$GOX?o3_ldtr+iB`96zTXBlDC!29-jXIKjCx)k>r98 zJ#VxB03ia6?!)gMZ7C`tD{@I-4Oy=5VeO$U@~JI-(ULlNzYfvR(h`R`2MpZ!Il*2$ z{{UW@k{o7HcY7b?bJnrdpoEeKcT4HhQr6P@MB#GMR~qeIe$>=juS{8ExZ7j({{S0H zD~RbCU`bo?3m|Cm=?q~Q6`7EJcEi|p5{#)VM~`2vPOlRZ5SfxvwUIzw^yf#Qumqa3 zyjp!dYVCch$wmJFEFidFZnr0nl^W9w@<kXVLknRL%%Jd0hBg`Y!5LBr{K_Lglk&@; z(f8{Uv+&4Np)DjTm8UJa@;fzcEn9n2taRkI%dIWCriV^6rUNR+B<7?59mE1WmXn56 zU7@!EI(W?ps4Q(t)tHL?pe!~C38L75ky69!QF@r4J9|l|zkX`{N?@p@u~b|hhL+iL ziK*ifh)1a(sMwGUtgOi4f`<yMagL3fk4mJaB!jUVYreZQL}EC269Uk^DhV_c0BJ_6 z2X~3#HJ-7uni%Zx=;KzKWRj9fnYKiVe;_7AQb^i&WRddldWD;cSWqE_HT`JfIIJR4 z56i1;E90XzYWh|@cqFm^0Lv?-W2;cs$#9lNDI)@)$k`*$jFJBQ^`UUG6DcJJPp*b5 z97<7C<^0ym%Cz2~{{SxZj^TdF4&dGNo%g#|du1J>=}T8dLs2AkmBO26rz^*mZ%wx` z6MeV`o}+t6w23L1vE&EZ6~mm(EEo~7f4eZRrJ6hc0H*cbW$V$BOFVBA1Ctd|lW*8$ zE=SxSe?3yuI4FN}(E7!tNGW8fdJlE}UL?vyrIDIsqN}Em;dc;KfcEfD9(ecbreJp# z-`(Tv%vhUg?vec2K&Yx&<wtjKn{u;!zj2IynfB`NWuq{b^%^xp*~qxsfbxnI+*1V^ zK&aPO9K|YkI%yb7JxtSj5W-bJ3f$vw(}B;_eYzkeKbrS$n$`z1&TN1hGn!CUsivcG zO2FQ(&^o_Y>5AH&CqYwotD%);nv#)APVwz!EV1C1!90$9gU4S05|>b7&sMFyjb0=$ zc!VNwC|F3*pVwN8Ri67rezx6cEfK{9&ZAJ<nk*wkPQs0zK?P1Z&$m@BEyFQNnx!Qu zB~`CR+()K658FNqgTtm&+0&(jwGyF2b6l#~?W_<z{>aK9qt!Z!2FX0cjvMX5XW#Ye z0$&8htGui_Na{!4H2K}X;GsV%M--ldYgC|)hqydmu-26@zpVcNyd|1Pm3BO_ujS7? zV}qRe9Cc`y!Bh}Gut^tUMwY+O##N8)T1@p#Ml_Tu*p#H)+y>`j#9ka?sHRZsQ#DH{ zKb05eZg@DypZ0%VoktBJK$$=d33?wpzL5ou?SN7U7_y=+!6BWAE)ZX9Qm<Bq8k(ev zU9nVLzYY#pfB^^Vh5LJStVar(p^%_vXZ>2WhUNCx3ocSj%tXNK?S7w@{NKVqM(I&i zSq&vURY_zZ1(SK?gMsc(pXt}qxNP9d?uOB;@10;CC)%zWwS2V+lN6FF<T3vM@UHLr zMS$!yUC7az=HIETeTS~@%`Vwun$=ffp0bvwaKLS%HeJIYg20UXbYI9s#DK2o>0N%i zHR%+-E5xwV9Lhs4yyVsN0YH*Q@2PEK`QG#=#jkhasHe324QW2#Tjx!pN4Ha2(G-ke zsaSFE<a3UE=clmvIdYN|2`u^_H#G3moIzrys#@~}2Yot*ru|ySSc<7W13oAFXQpp@ zhUup)nu>rjOEsp)a9fRiIOlYUPZGA|01d%dk$^xvo{5^2FDjBO_1DL9<q`Oa;#v}v zp7DFw3$SCpw&f5}tei6~Jq=W{qlI-Vv5=3dlaPG*=v+1&umHE~ynL0*cNAtX&)dH^ z-xZ@#+?g7d&g#x{9{t;&kV)`49C#z!>C%k0PE&1l=sdTCOw1SDp{)kB<{8@XbwSx~ z^xxr&_il^6OpZxOBcO74@;5NS=lh<TmYHQluAp>3p#~n9l@#)|*!@MU05#eq3s1rC zeD0W*;X_qrfE|VFYhk8Hv*4VO#yS50emXe(pzF)--jKxHNn5H}1ay4?sMOpaSdi#G z4z%qO(@Utkv)h?#B*1z*b!?Yv#rMIAnIt*;aDLopp%9e(ys8h$?A}-3)*O>N<}zfJ z4IPDFN7MsrH-ta0XtZ{csfrsemDiWrxVDC>uc!Uh7|wC>N>!8`5;qP&2OSMEX&{yq z<;%_E<t-=)0NiQ`Cby&C!MZ`^4yd{5lz}K~bful)1z}k?T%H(qf$8JrkI%7hf2K!H zBv2)3C&T!EFSI(aNhJy|%YQ%8Ba6<ek*g)BO3TG$j>jt;u~EcN9wDYtx1KS-!RTMZ z56xJc!s90tiAq%lhW$w<heM!<&a+uh_`kq@>$lL`-0himln0PRk~t-x+c`KR!1wA} zRNjBCv~vFWL?$qlOzx_wvz}}!G$PFBoOCPUt68^p`+0t<xYzEv+G>){lJ4~$wO%a8 zv<^$Ef{cCe54r0E?QaYiJXUF8g1gKvH@LCTbFCxNeUaj`vD`R(#FqytHn6>##66nY z#@hQ`U4v4MBY(J4(_K9iRR^mJB2>Y+gDe>e9aWCjIVDaq04hwE4+)tdO_ys>iW{+E zwR~w<&GE~~vnFHE)6U}b+NPo$9x7T^M|76GKpZ>mPkwf%llE+mr6o&pUY}Yu<Kf;J z75R?!1hBo>#=E6SBuLDVQ{hLa9Dgp~@SF^fWB&jLqERd}fc{^xg3zK1nu2wyt(lE9 zybRpx<EBIDiGapP3!TULk2&_}8Ipk|uhuxCKg~3z&E>6h((sPMFXJmqMNc*pb8U-q zh`#%A<KL*+2rEedJA3`0nG@0oAewq;#r^7F7g^VO7Cl*AOBkxDir-kkmhx67AY;MC zKet5TD~do-Vo2w27>vixA1y!wW2S)Ft>W>{?Mrt_-sJ@6@U0z=ngF4*>N2qTv*e!# z>-{>>B+3K(fNsZAUwg$ta#X2#8Qg)=mnO}&`v`~=Y?C3}l0>T;60M&HJaOlreu|io zNhEJiTF1zmLrhIVLvz`v)f&0$9S-<cFq;Li$OMrzmD$S>7biLX{{ZpFP&{ljrWSr* zNI!uG;uH--e>w}rl)gz0WY}kBCvVE6oOA3rBgQ_RQ8FpO0=xeGJR;kVT2tSDqo1y) z?(q(5-qLG}lr;UwrKFytNXt+At_q45TFQuOyuCWoMhub2%DaQQ6~PBQ0Bo2b@=~U| z(_37h)2AUFHDX8wMT<MV2-U7%lL(&v3A-snMXa??ar>?C)!dHY>AgK?{pVoP&1bo- zMyk7-gcR_NwC@!A7uA)PZ{UOJG@F+z)uRuBN)#0}S&8RLdilIp=W%5!TgVw+k7M+I zZt-gGeYx*GvhKHWwQUc>Pkg&M*l5t#QAc{vb)SkXQ2LRqu;QKtXTzdC;Hu2I;ZzTe zz{w=4TfW*`(@kUS2Njt>yYm&L3%4&9sE8)0!&3=Z-*TAF@~eeWj|@oRf7QoTIlzL- zKhG^&!Y){Oqz*%U#k^s@3$?b#r)%nH+{aG!NR1+|%67+)SJ?h=c=zgmv+|rRWYzWh zL?<1b6r`nW8Tr(aYg*7X6@4|yQEn9uu&RR22G3CPc|O4TBmV$CjpLItNo0$d1BJ=Y zImxK?uTQKkrnytn)<GQw9#qJxJut4$JaXCGPutt7X+nu%<L}V#5|h@Oc`&CtYtzO) zQcGSdE)djHtTf^=w3H;jJ{V*9=k@c{-vCfEj#fAKZ90f!iOm57J$!a^1#5ebeLty1 zMMRrk-y*W9ATS|E$T+|!2dfVenf-;OJ9_;hGXSLwN*L4;VR3WQma$!~RkX2}k|-); zP|SC;WCNeez`*cNS;;A0x8?TttX)ur6qM!+KK%!U;8N*&7W!(dt)e;_%Z)mUh>_0G zKHgd~!<F0vDEp9l?73xALR8=yS=*iWj?2a3C6ApTxlKTF9lG!OL_>S{puOs=I_Vp3 zjiI#5Su8PrE?U-~(%kEUu<Tsq0y%H>bz?_jWyzWNiK{0lG-kJn`ZGfuL*gvi^3{ix zD=AX~b3OGIZx{;it)iMbi+xR|28ON6QxF}CNQ4IYN0EXT#t)Is2So5A-6=M{gmSH` zvvZ2y7Fqd;nV7zwck252Lf?m$ji_}k6xA;kd~-)u6&o(<RH6LX&&;46f5bD;eYV3d z`*RW<H6Az65ont1Uls^V#3@Wk3hV3V7FxQh3qq#rloYj-D>P9}5pg>K?x~glWkx_a z-PKcu%fu|@l#JO~*n#DmokUx)n7If+nKeKYyZY3-doc;>e&lPr{+F${-6$Z_64XQ? zn8v#$18&mMuwr($csL9(jx*MeCxGG5u(Xm$-L>$)q*WE#Oia+r03WYLCGO(h6%kx- zUB~VeI&Cy$_si;7;eb*kYfu&ifl#8yw{BQrh`}cxUZh6_!WqVJmNOklsW*RqPY?&R zSd|Z#^AF2r%dobCPIrqf*;~C8KWg;tuBu9<WtOOgh#?p?5^W$c2ORnGda3c$PB?<3 zBx$Jy!0A$bv|Rf)kCvP)va)rh2)(ON@ghlcrmIF;n}th6WEn25NC0C41mtJw{?4Lh zOvI!f<G(cdmv66(NpUP52|+7Ln2H|hBi(d?N*d**uX;+FO*ML>E*!;KP9=Ew@}fWp zIQnPU^(QWJi5Zfw583GynV3|Q-dH1->F;9>8k*)kHHO;+R4AwjG_Wj7kFHP;Ab<Ap z(!$VAWQO{@2AQh>)}A0%m-?G>i9X8dTR}rBBN_b#Z138u^73+h@(&(!o}#9p6{*^< zPuKROX!t3KG7?y_K;=vNYwHcSX(*PU!%UA`Q^Ck$RwLsjfWnVFc|3qP;nV{8iSza$ z<t(aUNK@)gzt7p?Wzc_xTXn7`F_8OXa-$1^*x-8(Px9)8$0Q%bDrq#^s2AQZTm{o9 zGSox6d#{fp9)tTctK*wP(%5618EE136daK8?Zyw(bzD$TV%Por4Werwg)|}S(m18@ z5vL+gjh$JE9Ui^y_3?tgDOaR~5?k%*Fvr+^I=S%4t%PKk%1uW57y9WCLQs>BMAN9V zm#2ZJq;(Z-bd&aC^JBX#Z%0vQsiu}TYzKx)cSiH|1o_X7qj7+tK&Nr-y}V3gT4IVW z6bS14^p7ceo3$55sI=AS<kEsTksf895f`huSCGpa4criNIL><5a2!%tPkf!Z0Q-LN z=FE2w2%uFHty=tswdiPT8K!~mG?4^E(w5MRuu1AXjG&z0FiwA<=f_5Wt4kt-)01zn zq(^*}<_ue%0r$+*S+$6_TJ}>^TkSOxZL(eOlTR0?P?CCUPZ12#GZ#B{f>S4fm&cLR z&kMwrGKnEN>*40nAC5B;N)mZejjva5YhNgl`zP2ty#}wNN}WQ~dWtZVa9W{>U)One zpxr3K#si&&MsmzNbJLt&e7V?l$wFJM`Vji<pz(%yT(wHYB5ANJbbae){{ScDT_Vxd zSuYj}+pV(wW=d<7B+^sWQ&rOt^z+6_NgD&mbzngrLjl%JnU$1+2`!_Kud^F8xpLHi zP_`E9u?^?ZxyHIwq^)L(OK>DO+5kB4Mh1V`(sC^XAV1e_zVwcqIZ_T~1J#>X)%3qj zAdZhpP~0GS;<LkEk+4QkLlKaDk2v{r_Uq}``H4^&zd>D1?BbUZ9WqR?#oQLS*HP3H zZhFBrO_Ik^5U07@saiH8ahVkI2;_nZB>VY3N7Jh0oFt_yIuS~WBxzrJ8pK?_8kvb9 zDGCG)u3Wa&Z`VdfJ8#>{=*<+Ch$+yk?@<@X`SYI}bB}Cv?7&3dnK6ldKqjAGtaeWa zK$h79a-iC*Xw?JEP{zr7N88;iKs6<j7#8zMpGXIF3AkXByZq42_2lOVs}~94lc!Be zRc50ADYqxvz=nl!K-hVUbvHGVHXFGD&*0ESrkX0^bLmeaI6j?3YQO+TAgDl3pMDN_ z9tFWcAPa-b>-zdedc4F1r}Csx?asdU5fJM}nLF*M;G9**5~Uxe5>1Go^Jv=&k>Nn( zpE=1n&rZoga=A6~Kf`&5R!~@K)w$nOv&T-mE1>B6hh?Ryj`d$i)Uh-#98~Eu${9}9 zc^#B0f=_^3I3E2oCn#EizWpzMzSM)TD_&3m4hXXvXyL_auGFkvn+Id;l-5dCpw$=4 zexwn#G!>>eidvxjrJz?=GO~=a#N||+0>}WuCSj#1buRp^&Z4b*)WJyNGl40VNCQpB zJq$Ke{4u?kQOkK>ihWmZO4*=>FT~poEkEKgaEa>ADF{hWAyyz@g##m`nI#DhpgGy- z7**_RV|`i@iNymkNo$)PeEf;g;@@`DwU-SCrzz}LYsQ|}RZ6WSw*yYF$y+l!yGrVE zBX%yuy|es)dm#zSMjZZ>^o~xPO8#P8d#8}JOIGa-VwSEiQqqzv(?lgP$i+$Z7xIjd z58Mt1w@X@3OA^og-WtxbmrX}At^Fv`;e%UK!4GxywIvSdX{qnE&_^4c%^oUWjNk*o z`g?V4;aI%#5=b?x@`#=-Bm2&35*zP#e<$Z1^Zku|tAkU}_nT7Q5*cKqTXnjSuwc>2 zBTXZ@WFfd%qZuCL{ZCQ-r|+d8*mLmkHPFEPD4(24OR)C)?rR<Ev3CBV%@k14MNKjU zz-FnDm~oCv<e$)==k3)zOCpIB_sT3wLVq%y`EB+4L6XzosDa(4rK*W|1{$c?pX9}V z*VAh%Ab#B8OG+H1GkJUSkLK+@o7D6TBi!_Ex3{i0FRfy$hddMH$NkS*0*SJ2cQ*58 zCu+ujEX-O-m`k3HX-a{9y7`u8ucxD#mbOUfTX-z>3Q_*P91L(r9{&JtzB52lRI%Zw zzc?*;Ii#r;Q#-dmrutfrAg0Z$Z3|lsT^6LY(o}kiq{Ci_=^@W5RkNNwuzFgiqyS|P z%k7x+aLh)q1^_8j8B_*@bJu4kn{yN~(QPB}t==B!($Zb!ysY%KH1M)5!f5K4CcrSt z>ml98KH&N2gv>Na6da$SrT&C9;bv+jf{8`yY}Ev8Uiy)32G*ZJe$3r9T+meO>pqXX zL-~ww=~_@(c+b!2DI?E3pMH{{0tirYDCd5>&q1Vf@n<3Lhe^|IZfx!i>$}C6w|>p9 zgJ@}E7cYlh4{(@ZyR{`mWzRhS01ibT+oDKdLY>IwC^>!LY15`DL%zYSseWf;r%2Ip z_!Iasut7Cx_-XCBM;Ra&qn=z3t{zk49(n1susKCd?taH#I29A81qA|VX+iX(2h>xW zn2PH(N5UqIkjG^Ba_yv(E;fE2OGPs-KkYnc>*qZoU<Fip`yvZW)s<umDjs$ZK}r|U zK8<4tle_wI#fbEuXKi*APzw51%4$4)31FOf#z6MzE0mXVmK)K&o{%c@q^K1>;Hj@_ zSH!bYq{CJ3;)>@<1gL9t{guM)MX9^QigJC|dJof&<<j#ct2;jTC(@qp2F#R7zF01= z3%zJu0$f*e_M`&S_v=vJM1eJBrr}gy_-kadO<Kxx;1x&b&pW@DUmQXSakstfJm9HP z=A@MLsBQJPK=eM5zDMs4s)1UIOn1)v83zWUoZDb;eE$Fu;sAX(<J+dns-i_HzrPnh zcoAzrK>>Ewg2s+Th_!|GtRg=Sn)^^xC4I}bIv$FzAhh?3+O)0jhZOP1$H3&bKi{Ou zlt5xU;gn)c!r0SC8oStR9jg*Go8i+}T1<J>eYnyQfE0^0h31ug@#@atllLQx^w-N# z9oi1I@jWar3!jQkXIUl2z<O+VI=Q?RQ+7j6Qz>mp-A>_Nn1TvLR}}<Aamxr(f4+0S z>Eovc7~J)yy*B-!!91lVx)1>*hTh}!c!c%Eo3(lt<yumYPF>nhN@_D%LM{~%FZhc| zRMISfi~s;$bCLl7mY|%aId#^)Z|2+HkU5)(T83>-jLpkXZ1r}l#5;fZlDOIpL|T4G z?RK5cutPkmi5v~L_$D|6F|a#aZrT{D;J3;TK?S8irH?KA{Et|C$|X;qM!v7qj|LPE ztW}*!-m3%8P`3-69KNKR%rOGqkS<uPV3V|EkB{)+X9FE~GSY(e^ELANyb&%~Qp>Dp z@5o>Ei0e~#S?g&-*|dz&#uR30Vf0nU*L$`<Q_~n?Gc)wPJqLtHEg4c6iZ-oF+O2qn zuR7CG&XS~ZQbFL9fUASgg20?NgZ+AO((FYyFx>3QNdDyw!)Qxh&F@+R(p|5YI<^(} zDyfx~$c(Wd5sZv`pY6{~nW#w21N-L$Ckn+**V^BEbiJdbUkg3+?dM?Zmq@#VXxCS( z#Y06?MMY?{3Zz&JTS1lmMD)&bcm>&b%7c-f_Mfx!_>$78DoRi_Dq`;K?DP?J;e31W zGK{jN9M5F9wz_TnMU1|DWoTVjVYf$cwfBEf-{>wMj<lFGJe195r~_)v6+-O=nHysx zGovTS;n$9<{6Dg@aMKQ1h)PIe>J=RgLp=-rYQZ?3Ix_cCqNKU}u1Bc#0o*;HZu9XO z-JbJb^nHStvwCB;I!c<dp1S9AmW~K(uAw7w=%S!g1Wv3sC(ZLHAdCg-#fQLPaMIVB zahWiSIbi1e%`PnDHDFmd%v{OAla^X}x#;z^zNP6ByHZ=_rc`qq$gFp5-Nzn&{0wvL z(2B)O31ThmUL-zQQi&#}oSc52yb0Gw6p%|#Ef3?PlAJ`6PO^Umvw-MB02~~7+%d)v zL(Rm4Qp7<EmP$ZTubcfojNIT--(#MJN0JMzNU7c9Jdbg>5BH9O!{S0tZ{yzQ$~ZG1 zerogd)xBUZy0&J6wf)Sl6T~W`v(%FyKQUsh!1@9(rzgohNN~x>UM5mSpKUifz+6pB zOtrUpk4p2{Ic-|e%Ux4bB=wP0lLAJ_BMf6WAwVB*2dxf7phjBn6-iSFEQZiFoqf4H z;jFx7YMG)AD2uAb&Y&3pCk_7q?40B2<EAGhGYb8=L-<t{qcZ5Ng$VT9^^SmiFIB-6 zw#Ngqs%@>`xcNMec=~@nI<N6$q@{p%sP{-Sg-s~NX9B{k`VE`koLXDWJsk7ARFzb% zkggcWqhol-2akR_sSr|1h-TYuqRTR9nBX?n`g^Y^H_-Zzx;h8dyY_=$(f0dV%}ikz zHMhY~^ToI~rs8B}WXgsLbIOjs!f`T*xsWN<z4MOZuo>lmZA~CTyW&swgxl+lUDx6- zT~rrkQ#~C-7O5m(aES~%PDlp|Gr;<FVn-Iktxl}TSAqHg5K}m0)XU7AssokzwQam8 zI@{tCx)ak++>YgU!(3bsGDCdUcL`xa4nQ=~&Zy(u0;}}L)mW@n9btsZ`ugd-6NACb z3Hg~EbZ-%3&_P;QUOAihfQquQ2J;k-0!G8eB3vms#xfX=gi;+0s<&UekEBVg=1>8^ zJKBR?fPx5gt7|)S^b^;m6UREb?1Zs7QZazwdk<`NT;jd4UkV5krG+JK?nt(}+!nlA z_$Ojy@noz_no^`T#8$6Xub_eJ&uwGU_0ikS;%cNZ2}>08*;x1klgI1z9XrFjUo(ly zGZuiHrkc~o?GgNYvU2!j!6s75ii6k<Ty(8`A<m0OudNeSM^oztSImkDbO)X>&T@ah zMImZ(5mEHl-my-_&QelVSd)8-*RPh)`%k1y+KwtZF>H=WW-wqgAl~`GVie==$G`LG zCyR_4jODt5bRPWS&L6_&@d?SoK1C>oCf#V~&_dmL*^NotoiRJzp4)PUxFj(C7N>tZ z!}6?(LV%~nd}F61{6yjWOdLFvsWS+E@C{C2-02n$DcBru3kk(fT2(9^33oiT@QRtJ zB#z1enbMSPEDM$KjFZRH{iCIPx0p&&<n8GZlIE={DN32Xjbrp2;vv+!g3uslVzuby zePs*b2qg2!!NCNNW7LKZgid_KWo>Wth9_|;YePBE)A)*eXP_}%>T2z@kV8E?{wW;q zRaQpMa4-j)jGS}!>qaE}tg0?7ed`oiD44m<lFB9j0F8VfnS>gu^{i^AGhVcf_>W8l zk)kXcgU;cQK?m#f<Y%B{DnLt0SRD&n>FE*^rOzZPaI;)*YTI2Mpo6r!rdnNjVvHup z>ZklH>iI0n$XlK`!3uJJ&FEe|CS1%Sl7i|Yn?W2TDpn;ZRgDO3I{uM!H3pWmYdShg zO+y7eR8UAJgtNLdEsT7urC4#tR4f+}nTJ+n5)G)w(9raY<}U#%bAZiq3%@L*XXg>M z$KmfvO-CA2RbQfoc?d33fXCS5c?bQYso6cEp2R7M=}w=f(OsRxgs>7>m1fsw-&d>y z)O<PVBvcDQeUzr=U@N8mr#{%h$Nqg7p3_b)l1Gq)uZN~%tqLr8f#=UaHK~b~k4lkk ziCJYc43bI!S+acbkN7=A&&JIvaZZLTcy-EQESD`>{Wt5@KDCl)W1cAO$iRTi?H))V zl1_e~UY2olR9Qye{n8!9rch3L`T2WzWJ^s%GP$UN2;45?lgEtkKi$_RPDucgOZ(*z z3OS3NE8u@#y+kdB*(89?DyV=Hk^mc2c`Qed1E2Qv-^>o){;}B-*s?lVynHK2CABqN z)Mg4<<b^#@O6_dOyH0jwe1ka2@<)INJvE3a0F`g$r@nDDh6`F!gp&GNr^Wnk`+_0n zK^3|Q4DS^^H>fkZMT_{P<H^Pe@y>c`QBs$bB$a9aCBW&h)+gixD161N1d=O2JPFXB z9}^QVY{ezVM?rj!^KP0nnni`$Wl-t7fI(tDT&rQTjB}iIMD0qICmE4RDIf<Nnb`Ah zVA3x94>Zg=W+dh$uxH+*pM`e~9X{;Wa0){d$xy1Y!i>zPDCcOy0zC20-#r72!j&ih z3+?Q@UE4gm-`ygpch>DqU-3~(c5hGAhfh$&A<ja%G7*#GB^d`E-g>Zb%Hha!f3>R> zMI|Z5zDo=4<l9kbu)WLfjZK@gdZMi=HZ(T5{UGcBXE78}gWwi*9Wyl{Y6)P(j=y1k zaU+PDlu4O6RbXEJV$b0mUai!Ip31bew3ESeVirlEbXeXXFaTA+P<T9pocZfT!{p3T z19PQ%2*P;5&Wb6>H?2CCE;>=#<_h~~r7xYyrn}MEZ@MNONeBXt%UyO_YWuTeJ89x? z%sD?df<`hAP6VYyjrtg@s|-@=e5`r+7cR!N^=JvVJzWL<8>XSNH4|DY%x03YL#tH{ z%3*S<SJj!YxHw-;NCe=XtWss7RJ@61)0bVIwCKVhv58d05DSBPQ_8R8=UBHkjX6u$ z8e1;3)zd?E?uk^cv0EiP30!V#rC|Kq7Dr`;Jjlw@o*8z*aSk&~h@ywvUq=rXeiOld zaV1J*hjLq>1DjsiZFq&{ykD<Z8-?1g<27~CdD^C?qE;?umE4z+v;HE?PwIb8s(BJg zVb{jK#d^j2<%EKZ9UDVn+<>{)cZ`31Ra8{e8rGs!EFTfVtGTg{%B#n><>*3LAb?6o zT6nxkBM6l()+jZ&_o0HzJ>HTSCvk0~knC3j(qMDAV<a;1&qC2lVjq}qCfsuMhy~!u znlrE!VNtGw!o|5mwX)?ERc#_yS>w9Xvk>a?v`(O(2*B~<+#j!=kKx#2rJVEtJNRo# z`_Mz2Kz<%ZX-Z|OOuz!f)SgUfNHw@Vk*BLkV6y5PIvlA%aE_&Yut8{-@8qA<bl(%f zAC;V!4l2z0=zf*nBc)<yPVTKPs*qWZ*3|W-S=@+2tCL2}r*9HjuJ^es7BlI<^oU_4 zTnzev^Tto*7&!X$F9b7@h)GZ#T#ptl;vrm12bTpfXw>ujHo!zRrf&^RaA2%nH~D^H z!VSag@Ag0M>qu0^2O*le{e)*ze>0kzQ*SZoL<j4-`6_$Ibenj1q^6=^N`UG`GH1t@ z$m#q#+Eb`Cecw&K@XFM*qzy$Z2ah^(9PT5k{{RSgiKwktzyXn1z!Wjck^}i3{{Wc& zokwC-m1nN!pM&j9FA}^SQ-hS#!nJ=rUq_CMSZ=is0!GGfm9P;Tg@35}8~{E2zh0wd zOw5o-r#*f8S_mpG=@j1o0Drr9#IIneHyER(sxVZ>lNlLYFC`l*$=$mL2R~IEN=jl1 zdG&;)1tjmvzWPM#Yo2i%D@P+1z+4=TKE(6%{(U?MAP0sFg(LzC``(aYN|FUxC@EHZ zimqXcA$Q6$QAs=~EC<YgD9Jt#PDusWLkeM2Pq(Mh{A0j<{^N1F>m5^is%mprHQuc% z*t)*jpIrKc6(?{mT!tA84t@Hy@Okpa9W7Bxv2R$4<C5npdWj~FMh2qHLGdR0?H-yx zVjX)+qI>&Dv{g$Y-fa_a48Jn9Eb0|^azNR}c=^8lM{)I~l0~}v_Ru#BLc-RdMT0l- z<sIpUxuA?>)k68h31M6j`r!WnUaKloswd^3f}Ga(^@o~!n{gWm8GV5pIs0*e*9c(N zhcb~-NA}^GKbs8(wAR+lO+l>nbrT=~L>G#fz!@V1EPxDux2x73atnl)uCM7q8RW@J zm)<Q#1ICP9>@{+FL7lU`J=}p(Do*V8DPs(Pqu*+r2G5K<gnN<wJoN0@i)RPdPt%+e zGYF6qEvZ651c!GoSI}CI23CIhcP1$kO08v8aUK}fQBqW7W5@AgV~$Vt=~E_>;a;s% z$hG;nhQMTuv>eGA78Ep2#-C`JbhmdXr=4WEX<pmiB=YT1rs$RA2kOyAjmRE+@zYX( zDrJG-KD6--52z^stp(ZW_37hYS2Qtw`$MSv6RTE|j=x1&tT#r%CXTkcl_X*3X^M5* z{lC9P3?^4CYFyjSksQhyoPkY6A8`IOV3FeSdvxDxyJJwPB`qecrQvr7HGSzZo-jY> ze{Vf~^1w(0mTFXnKc?O=F-gp<fGJMpz$NX=VQ4I^?1yeH5UW)l;MB!}pgyv^k>dmZ z0P=tPI^txfDOG&W>kC@faLv#f)6>(&04^U4{hFzi`uaZYa4ZWIsnnNQcbuO&sps3z zP7I(_&{LO@`tJ>ZwNWg_rH8GHpJoOm_-pujr9Ptee?nE9ZuKjolC82i$@Qdc@t%4> zk^v>Yt@r!K#}P0qxLlpu!nLD-#^iGa;Y1n(wpwcj!P|`mW+-y4O=PA*qs~ig$vqTg zh6D!i_RJ%qa+BREBzW5OCtZe`!b^_2u<6ugtiRdpa1GfFQyd@>^<1y}`e{XhepRR* zaq?0#1R?d<>fLH;MQae2rTD1qrI&kL`<14>Rpcxc4N&LqeLdf9m|VQ4x@bInwuin% z$&5lvX`_K{pXk)mkWQ)i{p{79L7=SEdWub?sHdaYhZ*N|j6J@ird(G*VV}aS$IK&g zAI19e)5alMzmDx$s}|uWZhJd<)HLua8S3gQ0zpbx9lo@mENFb-GmPNibhH2&AZ7Q_ zymW%qf?Uac^(QS3zAQCrZuD^wcBJ^R@14~uBbQV5he1Zi4IMnf;XG^f{s^0&{{Sd> z^YwXZAvuFn&HZonj!D3v1p-M?H>d~Ajc!PB)+?5#*OT39YG=OpGg|6O$y;$s3w$>C z+>xAak(u%L*gcLo=x>@zhGN57dHZNSRHWu447pv4lR`GPT7hnG4=sJFYHG`jI$G;; ziU>^A6&DKW>WpeIOE4aq4m@yk(*&l8sU10ZdKf1Vm8^u^om$l!z2ZLA+TQ;Frskpa zw0&lq{7gmZ_QMS-$O9GMlm&pwu;-o*dQJvpx#8s~6oaK*`fcU4pzLP}pCKzVkV2Ri zV?Z@yaB9btX{esIvfo8=xX9o0hcY5G#r7!Rkj!(&Na;l-m^r(7+r-0?=9HX<$*#@M zpR*e2{{Rn33=>GLBrTPb4UND8Pd@nhdTmQH3oH&ntzSqLasimq^t)?E@vV(2<>Avz zOBJe(D^f33=RRaCqk93KFh`HC^XfMc6u$5HgYU)*MEQYaOlRNzR23UjLv7kSA=wQr zO>ym}h`L?q>L_X|D2uQ&l)&{LYY?XduvIwvb&PS`Vv~znWw8L=pGw!q*o`LxgP6QJ za~7p*;!A@{kF8=zt+HBTD?x9Tmb7mLrrJuNK0rU>JpTa3IvWEvYM<tMicoqEQS{t8 zP@-D;ix<D%{UGLpMsA~s31A$5lZ_NU27EH`Jd@8j9a-e}C53VxUhM;n>HwkJQZ(@T zKj{x~X=`*aPb`qgX2utEg@%3cjz3>+ip8vu$pElGPrqlS!<yLmLjej3&TrG{%iFv{ zx{iVW03B2wjz>W3eLhhK+zfc*>(m6yLX=5=Mm|QLSA`h?pOaUYtO&KTwxOhvSJR8e zz;<r?AGa9&K<Nr3C5U1*Z(-pCsUx~yMy<K9C#JeU4|=RMcK*zF2Cf*|N1ER8I>vLA zVUOl8e0%=@A=IY>#AnALD!0EcHi7t@?6`8*6mDKs)u|kiy46KB9V5zaa$T}sXqA71 z=&%eNoaZFtkD>m3Y7+3dNu??LZO>?_=2IuWYW1f(pKM@$m8FVCigk5>ktAg21xPG0 z_VPd5&r6*oWph`*yZxZN8ds1qobtPJ9PLKlIuY=EqD@b3HyXuS8cKT4O-e4+ArH)a zXY=Eq8PB$Qt?@-9$^cu|@g;;xjv-6X@5<oC%hZ+DI?3gwq_-vMm>sJe3h}AW9FSCd z@DJCjRK*aYUA#|^?-HqXP*wM*l>}?ISuT-PL2;n2Hi}ZQ?S?pGNlDrR2O}x49D$BV z13c%U=3?cAD=J*>OfFP~sU<2cUm||@=weQ?&{=A$WR~bBudnpVq_Gwf;E-?!f;@0A z4^13L%r3F-pFs|pc~U|gH#*b6{+(g&;ifFjJuPh=WzvQ5%rop}W*qLys*q36pKs;Y zWnt1uAbPWUpVO2*_^GnSWf!O4(v*c4N>f=RwUp^bij0WHNVYcY9!XQlJoxHyGO~wa zdY68Gybf^%Awk*8>9<Qg_0}Fcc|~%n)G0|EbkeDdJ5kPARe<L|O~;<7zYwrMVbGzX znxoCRS~Smw{6LIS<&wck+O#6C`v_~+bqLz7=xV9wsDW^Z=&HkL1N<c9`<|rxC5Ov_ zNf*p{7_RpF6tl!s3GRS8G2h4Q3MuRC0tEHy?o`roxJypMU<7bkbCJOEdYGMuQC46c zzCQUy081$W1Xt_Q&`UtJYW-(nwp#1rn&~}UamNt_URTEKAR{N`Q|HJZuTh+F88C}k z3>(XriRTg>j+DRk2_YiN(Ct_7rm-UYP^6LCduL3{QYxwkuZ@~O3l(!7eYa!g&OBp0 zb)WVZ2u31o)^-4T)b)QR@r{e(7AZ3@@??t=OY{`_k|Unl<4;=k1vO<QWiYCOp&B{E zHV)hYm0&ZTJo|C&)}bzR#AQ(|Q*W-c=jdZC95kX@kt{k@Qrc5`hW2e0F7@#Ci-~mJ zuD{z6PUVVY_=Hcl_*)%Xu>Gk}0W%N}FkANfMRI=tC=Qj7Hu`DH-Jn|W+3w`SLrn!W z!%Ei1(aRLFtW5-k8N3m+2EaZ~f_~?z0or^zkK`cj%ya##qyRWX)B;qhhN!-*HSy-! zM7a&JXvG~1NkXd>N~i;q@?Zhu$B*WEt14u$>SI@W9}c|VNSaKgAwMl6pNH$Mszf{6 z^u;UBO<9(Tc5JQ{7>3RO<bI!Cjg+#nl_sA4@QIShAQ4(OkG=Ycm2Fm3mRT2b9n4un zE_u&BKbAjEgq)O-PiNiW{1VoZK)A0*`Zw<cHM(}0S(PP#uG}eMjPMT_!Sa9mI*ydT zl;Db*nnAOc46rj6@cL6z47Ik(Es>RhCG_*SM<E9p#|Qf7rb>zL8XlBAVW3dJ6*``* zd7s+UG5QNsl&2fzBWq+6klbM7_0B(^9UUfNN;U`Y<^(pPptOWlk2dt%Z#YSCv`0ti z%F9?uZ%u@IN!@}$!8ijS*PgnA^AJjJ<K^Rf?E=jzNPMn!=hKt{R@zN55>=XcDx_wK z5dmg*0GtIF$pgvo0Ldyke1Ohq-^6^GMKnjA-_EhAwEV=aO=UXA8QNl}MsJV-$!}(C zkar#i2G9mrx>8d>T>XCjQQ1VOhqL?MhoeU7du;KRcudh1!2rjVQ;d#1&l%79`T}hH z%4CN=UKPL7oKE2;DH)D{{ccU4oCHy6?E|X2xo@QSh8@O>vvgM3YoMgHTFpdPYnnO* zilO2q%ugFmz<j$(2+j*J>c#%Kh{5o+f25Fca|i^QC?bG_Jt<3G`a?xnd{+=kasL4P za2x{6RzKuf_sgK5HH}*9y4{SX?r6Stx|>!$k;92-YHiWdO-YflmV`@_3T^p7BMF{3 z#xk{bYlxvAiNt}KM^a1kQ&Gvg`9{;gJ|W?yOBi`8S9EuKx;uACzNconj+Xp5>w8YN z($)=0V1{dLzNVMfijHahSEi41Mk6wRh>e&e`Nu?KIC|veM8X_u16O*Vwc=L|ij#>8 znv|TRn~`sSNQ7@Sbr<i5{c`eD)3r2rDCi-NId4uVk}yZxDn@hU@;r59!Ai-4?w3CA z`o$l;{;@D5umbg|r3QuhF^LD_p1O+LqwJbikz|Ut=0Rb&ZH6eY;hXBFLW2XII*;K% zs%cW6DnT^&^y>w7(-$&M9TKGx-z4~dHmq~bYb8Tck`^YAHzVo~zu&7|tcka;FKrng z5T0g+`gnSZ!S`$RE~@SI{+{!vuQyFORIT&PZ<RrC4=T#)$s(K&m6VK*I_&AB0NxIt ze^`u|6c?Nl9P@r=htl40W^bM9)VjqZO-Ck{y48gc!&_CPERxky;ll$A=9ko6t;oR` zBaWCv<sn&EQ(!+$8+cMlp;1<93Z3=wZGfvpJ*u>|Zlb>3^;P##U-eD)wok-W*Ib-S zJyVYi&ES9s0AOQ}9SW>W%)~R2`|Eq}{6yvvd1Zvuu%!>MH7@UWFoNBuW1}TOEgMAR zZzK{1;giqP$G<|$8R`S;p>O6SjP-<sl^<Jj<;z_o2&}11BM_4q!v|nnZvOxt*v5Ph z_WSf4*@+~j7xroP`@u?^Fd*fxCT64f^V%J#sG*jcDpGDcN^F#tR>;XC&yW0vPRj)( zxd7Y8%gw0`8Oq2MIliOq;9T;8+a8}W%`C82huRk+NccZCMnF7&Pjl3yxznT*ms(n& zb%?xdrO%cUn*+5&09yL$YgXDHYc$=`?Ib^gnt~Y=HbW!fJ@Lr$M?Ce#%_c|7QgZ@9 ze+$D{X%pqn3rX&?R^wVV4?^3QfsH}ijb}@ywH5I{cU4-bD9rUNDvUu8ki3V$bCv^W zB%c`b(_A|a(~vpVp8o(_n^q*a#yKj&Edr%NgIZir+rghuVhFaoOz{XpPbl&R0BmQ- z$>YGt&sRhsq!l2r@#VLSWgw*G0Ms>fZf@oFfcuq6xpyy6S0|vVDpnLO;aSxqRT%`0 zrwijG@ze4q_cGL*?!a@oj?PMw!NDe#1<u1}KCR&$iugmkT<rQ1?6j0~*DXbPX~F|M zS&lFOI|F0S*PYqwx5Z^tO<)e*{-;*bC%6=x=<b}Puot$iu<8U|dw!(4G^%8>(?K9z ztcQ+9cnWX_8RMTLpppt$tM%ulTX>LC0$m^wM)ank?c=Dfku_+l%ISX@SygA6o#HMx zD*Vo%XBa0Nha>Oy_L`TKOdH?7-mw~U0@P!-JAUn~bE@n6Z~G-=SgJJdWV`39XoGEO zmV136KRy_2XOOFHA+S%_<MVYbF9f9msdaGreIdE&AzkB8Sh)7>?uI!}@m2d)zAAnp zDy!`F9a(eiPi*RG+96@nRkBmdR|D=}`SognH0(ge!pji(fRB<%GVlenOtGUkjaa`U zq&9`5(F$N*w0d%K1$jGjc;(f;<5frL!$YaAky6PkyQ$h#G3S254hcE&<Mirh@pBNI zsY{Kt(Y-ukDrOYUnDYq=(6btC;a8|J?W^`LH*4=-1iCj;X<au@VAri(Q)kw7(#XbZ z_0o~PO0sfMQJ|}bVkiMYAmrrrT*s+JWY`@x^N!(0M@p(u<LK1)SnJI-F;-PIASoFE z2x0xX>M$r6Jz+^80ruq^-Jz$!5*YwEJJjG0_#J&QS#KQ)B`)#&!6B`X!bEk?9Oy|Z z2yNl9jQ;>EoP&<8Gf?bQJWaoE7^Wu}Lf%<qkQ^{2&iWcuRI%K~-`@3!94_-Tkuln? zPB{be{d4u{a>7{Tp&W^$+!06dg0V;wK$cVpRVZ1Ab{oFt#BCd4)E5xJo@bE(!N?nc z<ly~%#(3z*4&V^_Js;oeXiwnDNJPaeC_4si$<>WH2G$5Eb#>~DqeW0xYJ!_w0gtEh z{{41X8FLr$_pC5VsuD>}EX3NtU&!9v<efd<?P+C_I;v~6Oce+V2Ug)vu+B5jkL%L1 z0DuR+zRu9fW|b@wqw^S-qg$rUO#{`d7N<n~efQ@_2q~k!inx<$G1W&L-!3>HviaxR zjyfeIsWh&^x>J)k>jF<iwJeo5Q`Ekj^{s(BzL99De`*JL;-g1xC!ysG6f}<<pp1Fp zSbYZ_d~pD?CeQnH)*m=<4rP+by8s)Lus#(6kl9)LOFQRW(nU#S?d1SqR5H*==Oq11 zpJG2j<DrE18I*1G-Vm%32?Z%7HLbq3wuhaXBE4Jj)$s*<x4-`Y#ne`E#0=0;M+<m9 zALcpFxH<hgdSP`wk<{Vke6Uarb)zWPQ+=q;Vx($r`uAH@JkefzgRARHk-1vEwiEOK z9y}5Kz54oO#eR8)-C^{cs`*K#_SxF*HP-jMMisYOn)h>6SyfW(a!if5u;lqZefj(J z5mLh`RxZ`^-WFylN-0vhIA$U3MlWl_e3kB_Igzu*Hths{zuP}vyDidyUB0lQppwdG zEy!}I4grRVs~m43rsu;j;kfqtanor*QbA4jaiQN)>LD`npag{yO@An}w@`F~s;zH! zyy;yzq8fPMwAyE?mI`%1qDg09?L3SAIS5ri$B?KL91L_oni7!yKJOjx{Lm$uuxDxw zXdM3VJ5N6HQc2alL$e3SIrkp?{Xf;v^OVJaAP-(+q%KlRr@upOY7LD%hMKf>U$FlG z3Ys5O=zTkSxmSMiy6RbJYSuVtWU6aaUYU@iEMTm0dU3RdS$3`@ANE0kn~qW@goio- zYUx_vpo<i)8o!dFOC&LVh3w(_Kj{}cM*K4A+ZxYnrPH^I?9sh_EJE)*G`6>?hKVYf zXIRnqMpk76kmPKWAQc%G7l>F{PEY|Csd{gItq))kElK&Ah)818C9^i{^s|pD#~c1U zyMLwhUZU+~E}4do)of~N@b>8K^-z8d4P!iC#;9gBexf?b_?Y&@`--ZMOpc`cFM*dY z5AvkGq*k@n&HkG~9j@ZDu{f`viCuDl?3N%q1|O7)P`>2VqOn{ZBC*-29xs<{q!Wyz z89bBikO)0Y&jb-(yu?%_PojZxDO<VJ^yX;rId-<zB^50#Ges0;CXz$$OOJ8>hzI&~ zG`WTM>#x3W`Vyk2aw^&}r#^qQNu8Y175D44veio|s%a%y$;J>9{v-NwdWPdTlPoD@ zH`XVxQq0+*3qE_;_5I$B9pR*kg3n{1vs1+jLrY5>5U7kZqNI2PdBNk)^Y`n|ie7c9 zVy=18{<p6-jd>_aQIHYfI<<daGXS<f<5sMu6C|XJZjfQQ@D4nlI<nyckiZrp>^J*X zE4lNi6$Ts7k4x0}2%W67&__wSrj7_^&UT64k?uU?o=$lFqpK97Jt+laU%yBj5JHl~ zyL25XR@3!`mmNV&u{foIDI_XXf}AJ=>5;~B$J?g3f|d|cZ|7Z{2Jw1r2_=iD)N}Rz zFAz26Z^zV3W1cqvg&`eOpC{($&mZyQs1&TEx~=C7l`zbsLtlEfnh0o*5Xz;@yRn`= zU@`W`>F=JFwB`ie{42bCoI-%iDX87L`gp`e@gp-q*)3-DuI*DN;iDYw8(L+_+&zaG z>QjNBoLF$p<M#a`JB+x)lA>#;FSl6YZFHBq+mzJQW_tGZ$n?wljJ)G-IV^GyTI9LI zC^IOaHm!BNHm{^pNy$QKLf{HE!^duK1avS-FZlBmYDipiyna#aJbQimYZ)^rAp0Kt zOi4+ZS1RYfUk|KbZ-iTXcO6!g6*SRF0gkX3rCsa9VCMvL!29_7@lwVurKL>mUw=aq z*aQ93mHj;2^xTN(4a(Ib$svEkKos%lrgP`ce!ttQekEkgC4naTdfUp8IX4uycCRw` z{dabR>kT)d9VC-_j8w9uuByam{3FW_4~+Zu7`!<uD<F<+Sas*S!79dr3JE@+#UXXB zk_t+L_;QsJV3JSe@$@6^IUROVNUJx#yGOxpL08;^$_^>xdfI7bhE#d*eoPZI<CBCN zdB6kieSzwFS`qUBS$}?p&>X4iKbP!v@EbrxuXK3{^+%<WTk#MDl}>ZCMyHN3`Mm!C zZlxD32`ojtMTWbyD>`bI@}R%Ef&qgL(fm&KN47o9i%oYbikh0=UsL}8C#!WJ))a96 z0LiL?P>Cw-Gg60YZ<&9Ll|Y<0+Perc7{0<IiQ#3FGa)6x=|K8{eM9L1aL(SVh2mBO zy!k^AD0ih;ni?NE!H;q%>8h@@{nDV-{j;=on#V?`S+w<%)BYOVWJ0xe+2@5?s%m%_ z=7pn+@lsU<jQoisPw+p6h``}dg`F_wa!E^D#q2EgF$vmU-=~RVl|DqXk&=+~B#KlE zUq@@&1p9Y-q3&*j(;E8GL2;nAUFijNJvDS9Ipad^tG^iput30rw1yZYa%x;gN^sbP zNvi@w^1Jdog7vf~@yarZT3Vj9)O}9yd!+6<wwJnd!=Y>zx{IElw40Sx#_J)bv)ist zn`o_dwF`!ZYPi;^k@JXxM)K{A%L1^yk5?7L&6hdS8M^-fl^yijCHMzxFx)pCD-nx- zmPrJPupzTo{wCt*L!?9f?(VLjxZLeJW4$_?L2A8KLj^U*_f?u(L^jO}O<gPOrS)2K zRb5vjDzilr$dL`CO(!@lKMkbeBq-orD+vxsa-b{F9jUmvi^pyLBXF)EK3)@rpqWW6 z^9eoTz&J?*yGMFB+9BGi3Ph;&Vxe8?aM7&NMy=!(8yRpp_QC%EWg&R%t70kXUH$1u zMi+z3P$pW4JC7Sw0y)+Ps-f96RFFdSi;xuqyQuMnCGvRj$!|S5GIA!>4P83*zqg2t zRvA)eu@$3g_VTX&ajH3LkIU*)x-b6B#=M`e*ZF<AZd`x?CF;$%^^Sby5(v@hZ>{#E zYc{B^4kn5PiHGI^a5)_K8S|WdvCmup1vpV2M$qt<rVAbJbRU1Kb%zQ|1hFi1v5Is~ zWCLL!WP!&R2Z8+kx-$-pq5XdG9A+|tOKU-Gwk&qvN<m#Uh(%6~Jy9|%5Lr+aKI0>f zeE$GWlsmu=TED-iICfgn4QNI5sWttNSV?%d)rM0Q-m+y8s0Y$N=7EnGJ_kW5-n8-C z+xbJ%aR8^7YM(vFLHB{}w<r{!!c45{KxTye=Okrwk8nZ9zJ2<1lFSAD>GyZ*4Sdv~ zlAUQ`Ph05KZDIb^Sx*fd?O9t%3^B*K$#o4F{{TA&pCjCTy7=LgKQ5=Aukj715EPIP zbP83z#<Y6CUZdBPg$*vAq)DnIaHwW;8?nj5NO@#a?4vR;#zJ&-o*ywRW>eRzTK-pU zL^t!oh2LJ9^=~Gzf(R^P5luB(o897~Bxeb<n2tVJ(N~R%M&N^wa$KDBISMN7QRo|e zf1UP&B`HXknqi=`Uex|{jIMTTtvbPRsGy(lvjuornH^*q9#Kgk@}rP5_v^EytsU(= z81=Y5K2XsBtyxJV2L3sHBU0MAnmH0neL-@}vB$MjoZ(l>01p}JK5jKhB}cE&b-gL1 zSR}}zN=c_ceCr#iXk(7LmPA@|Kq1-GyI`Dc3**}-+~>*00LKzlFXfhC`k&qsaKkoa zfW#hNV`r^Mt!WDH)HJtoB^ueSQBuhs!m-9yGU`qwWy^1DpMC}a$4^V*GZGB6mLsU9 z_pgre*?c^Z7OLNG0k{U%+i0(_{8U{&ANBp)M#G}4m$)tRD5l#|)cHxG5_)nfDNHLl z8C()G#(C<`hU~Q5ejPZ3%?WxCpzOu3m>Tn--Xu70#4JAvY!)U$m6o8TX-(X=RV3Sy z8^vkv-+eVz$6UkQ9T#-ET2k{JJ<6(r6)v{=mu6{VecvEtZUX=}F2XUMCb%zU<xIkv z800$8Q~?E?8ifMx0R&LHR7FsHO~+vIYsB!?43h|StteUxJqS?s40Qr5zi<18ulv8J zDRoZ2n!e*z4D}Q=b&|#vC#H^HkRd;rhDbl?k8{;WiExQn97?eAlt`f&i=K6*JR(yc z+YCn=0~LvsiE2$hww#HfsbWi=4UZ;*=&Y{<^F(8gRV0_fV;LFbeuVVj%u>!(S-zh1 zh*_sJ%9m?<qcil=Sjbw^8Qr}kjJQ0bd;4?a+xw2XrzMnFUqBh3SMdR(vu7wEZLjRs z_gHnIU6KJ)6o58);mIFtf_#zvdR)~x?p*unQCMsVt1qUzwSSm7p;cs6m(o`Q<qR>j z<lyJP_xAVbY+Tkaujvn90V>Le8AgsTsblqitqJMrC5Bv}^fF5Vyo~2O{=AR2M?rk` zlBKouYWmVAlBE|*fLse!oS)s}RFr<X+~vv=SAWXHp8yhlym|U@*9wY~0`zZB3i<sa zLR_r9<ffzRTkho2m*pQ|p*wJQZf*+&RUc2k9C_*KLm>;b+WSLObL9Z8t<ha59=m}} zV6xn^D`AdCc8uU{=bxrL`}xnmMkzq0Sw;H&;v`Ey=3VOiwAh-sZnZaqYjh2`F`h_c z45d|v8RX>g$s^yW*|UKl^54^xOG=gU(n6>lhX7oQus8a{SHxzxqwRNRC$v_>6-lg) z4_XwSC7M#~t(cYga~c$VqmW<7BdIPKHAZL4UX?yQG^P32L{|~O=aQu*wyl}n$gSVg zTgN%{_jj}f!oH3l-uF1>dE-w?r6&?(kUWLtoQ#~X#ysaeUMI;3Im>l@FUsC=N=5?` zwPjEy?8EuJ-^e+L#{I5ux?i}u+SjObj1tVXPclms(QbGoZPA_QaAC3IJA)n@*n$BV z8S<w{6Xh$=`Tcgbq6x%sS?QXTwMYf2PgWr5tq5rBpTQ;HMr&@~Lqn}QbLih5rmD8r zQ!=>q?c0_d!&eU0MiN6ZvIfY?l?QR^3y4mXCaDtYSaN#UKabN`cLT!KlroeG$*9%Z zwbM&5j+SX{E2n9tr~S=cu590hiAp^s{=Ol+0Keqqnd5!9@>GwP2hKWc2m^o#-Q51I zU$-b+^aQaYx-~-w^wH@2!^ELUPu!Z6no54+YM7@q&l4q04Yr};akyb5^dpg5kaN3m zAYk$jPN0?~DbLewJbr^n9Y_S!9}m0vYIS-?5qrh^EW3q1t-9VjU#@JBM<RNaG_{dc z($rMT%QS?rba!lwfH~o~$?8K1#HL9=LWX3dICeF*wrlNpYaS7ZAp(Gxr>LgvC`q@3 zQ2l@KVc*R+3Q^x;utOAOluBdW{vt@)b`j6p`*YC>?F_V~h*0xxxBA0rz|2ujd5)l6 z*i>G|ucPy3h*~d(F5yR1^2r4wmK$*n#wrJ~{vapYf&9Jt@BX8b8nhR<(YcC4O28<q zx~D3r0D0_xiftaH{{UirJbje-Y}a~oTU1`D<+*D)ZWPc<W2`9@LW-IaM3KbtrsjyY zJwx*CBrhYNm&7H^cRKY4YJl9~47AQE*mKiL{k&}*sf%0aYSk@8sw_~*!}3X29B!XN zRm!mA9^DR^$kAc;a1YnVT5AO@B#g~op8o(?A*6}xRVrw233R4nN|;$4Q;%YxpX<`d zAQPF#E_yt6Y_L_7(W&n7{KD?qE4WbFDK!-(p$(3*Fe0dheMI?5`AG)>zysw!{klR* zk|<uDWPyE}{RC#^SqJx%Ru0TrjR_BHGiobS0qnZIog`048p$LWA_29$WEKaLz&Q2* z=b@6OBQ;a4Pte$GYsw}t@|0GkzxOs|)NFp0)2M`o@u?#$lhV@5A`*w?AzRKf&yGm` zy&LjSK*?e(O=&||YILPhYb*$><!ZPN2@kEun?kSoL2y*{-q#e0gJ5;wuLLPMKV##8 z_30VI=5`xBNf+=u_0YU04I)aw2|1W@t2hMJ$pOI7KOqj#>p4VliEXTk>fbn%45K(B zpM3kDJ$ETa<c2MG+M3hEbAbXBr7B?{H|Dh|X1kjcuGfNCHMn^w<XbJs!Vw|?9SGbw zIV9wG82<ooOEXfO&TG%6jiVSW@cDoWFaS^h<B|H4N?Hu*yO!b)LM=ObWKtQKnFtTY z27LbjFRw{aNkgk$uH5$V@rF~wr~YzTs%%?kBa<<5H?)3E<6NUAT74^Uk$@zFGUou1 z^yG|Z`t`tstQ5bcKab`(q!HfDxiocBz?TA=a`J>#`twxQMY5kw!B4etUP;)T{{W(( zWd8s^_4I>AO2)fa&uDghwM;$TnA(-K9^1P%j{z@p)s^OTc<87}R%I%M!nQuFd~=+1 zM9GLs#oB{oO+RrR6O>QNNX$WZ-^}cNJR|DR>U)Ig6-|9yF-m|prT`nhzQB8ax#_ZU zjK~)7fu%|LV9Q-e4B)$vTsxKk+QID_TF+kxt1PqBWrxi27GR_i!snlFf2ix_%u47* zMvfj2GZpg2RSctAD80)D0;fi!P+Y^6Ey3ar<;szQz&=kLV~=mAkEgdyqyWiDXQcx- zJ)Z9k%a$h+K<@$`uU6EsZ>_|AhVCiu^mVyk!nyS2$j0Ih<>$vdkAJ6JGR{(kRQnd? z;31VvQW+pJbv($ebsan)zA0$Lw9f;}1ttrFv^S;+$t3+hzfCPQDW+iCf_~76bBaY0 z04_EIcOJcC(I3P9we5zD?5AUCZg*`-PpWCG8dkOCwh3XUi`7}yq*lsyB+2+@RcLb| z23Sc>0a)x8WcZg5ocLKJiV#_<<hivQO3;_uEO^e+V6c)3aWjzJ;6Wg&$t-{ag*g1w z7qh}H;rw6e4$W&FX@1dl+G?&E+q{=3tE;~gG?h8GPfnPjJ5bD?v@sA|B#BI=j$aFR z!+ay0O7R%!sFDMyhe=fhSu{y5Xf^T?PMz^uNtG`PB1GBb1`M32a;p{8<;zl8-*#x@ z{{Y5ccxpTOS=;L7dfHKJw#7?J_~e2Q!&5A9`f#E~GO?kKM$4B!IbFq7aOV@@95V+l zP{2(y6n{I0BFqO$+1q$)3CB35DtzC?NC-ue#Hc7aB~L)-yQeh)5oKR(@AP_t)1&nT z2%xw@Lj_%AGB`sFaz-jL!~7)2EN2J7ZgJzPo-Zqv0Z(?`CUCP-L0WgUHx<~I8phie zoTJw|oosd!RdBl0OR_N)J37dt87c=MN7op}Is$MK6Z2-4-TC#P`oVZvCUnr5VeZ?r zKhiI^XL~UZZRY%C*(DeEbr3+Q@>Bgxgl7f306b*m=d9PX9AIJ*l38@UbbosIMgIVY zaJiUb95FR*Y0sa7YHJr*Ob|09aj_~?DcYnD*Es}xob%RmD>JhP>EY78Zx>05x>I`5 z>1vzM>{`^U2hv+ERd*PKl*s~)T0tuX0C?c>pQrpDtT;L7LW0frr;W6KIIo~B3{n6* zfNJ@2yXzArmd8U+OC4QCp{cmK;Ccmz$a&-HM?dQ7&mwhLwK;z#o14FkMPePA`+7b6 z9kdYDd5RilmSm}x)}^uOPDbF~eq~&+AKRgEvyh~<hVk%9QoOlNlzlbw^@!r4j+*0H zH=wk&HKQPilNldnT#`QLs2qfVjSoM5SB@<SN}h#6mFw1!(9zpyBzPsAY9<On5dpRn z?~}r*=iKMVQaF4PkM~M@zK7HFhRoEKLEP$npIED(6PLK9?JlOLimHIZ)ikCE;|%ht z5)xV5upT@BynB<LC-^oDCU9F9vD2duzmRy1$Id2JWn5EQ4t8%>j!ak<T6@(s!XZ)V z7xev2gb2xO`TF?&r1gFi6PGH(Shq_ZdF|38aR(w!QE^Lia1NE;mk{L~bu={s7$o%N z_1qQbI6MFc^Uq3{5j7z|KHjj(aVshrSW}Rz2L0?d5qLfhuhi69;*zSV;)ak4*!g{! zW`u#3^OK(*{@Cif#N~oqyV@pj%kL>Fdwp!&qW=JC&f;5jJ#MHp3r$N&ZMsQq-S~q4 z0Ej56Oo@O(#!H1CQmx~vjynW|022G!+_#BrJoL;3tnF5~)yrSey1~s{R1?JoTgOGc za`DQ$8bk-i+=4;QLGk_ig2zr;SUGJ&)5gLMl!ax^kRF>=&3WmgK>V`3T**$9?MV&? zjv2`J$Mg61&q93T`2lW~^QC&$n#0oY>PkYCe00~(#o*IQS>mLtt6DnRia%LK_jk@l z1_1N#pBU<E0XBdRbhnG%BPStBAcW99o`LqfDY)5;rHBhUZQ9HmfHUCp=k4|CWGDc{ z)8D52qvZy*DbL&eqnNdt0`dO<;Xhe^?q_78?q_W77utv_>Ex@gN)?85{5(}m*2xmE zm@<_HD9C9{ET7}gSF8tUu!+K^Nt=~v6?@$7Io{O~PsQ+zN^wSJR!Xc#=q;cmv~_z# zcMn7MvrVRx)7_BmuBoz8s>2N0!o@*vx6>3LD5kZ=1zkeMqynFbt&(qcWo-3d#`c%y zxJlQ4Vw|_EFA2f{QJ@s%e*vzhhnRR>aMf46duF;`HAbb9(lrzh9JecF3q=K_NQMQg zf}3$|utver3loI%&r!1HB~w0P*}Ql1)1kCKHcnEcCZ&T}$)|wxzOglScSq_j+;%FX zL3ab=bK$qWI(~w-S*<mc8m5Ah^(-=aV8d4}+T%?Mzx>{ui5giXUqS<u>5%#d{;Y6? zfC9yflh4xJ!6Rs^&ETn0)Jbq|%sP_gt4cN4(l|NsBil_WqkE%EVeEywxZ3{t_+Q?d zj^{3>v}%)6be65ANY<*ztw#}#FHAf}X{6ryW?Y04xIQk4lFZCXzy{h1-upo*!%jg@ z;pLJ;4IT6pEnZZMjZ0S?)Ks%g6T??DOEl1)Ss9cPjDE~W&yG6t)-FQC!e!UbeFxq* z{5KVpWe=E^A*oCH(e49j37~;09|<DDM>}0fKWqd2x#<hc11h`LobAdYreZ>!Kb1pz zfy{p~x_cc|a5Y;~ytKRaZwNw{+mH`9<Z?awB0`8Ik^uLqh8V3tEr`1{&w)By&KJ;H zYS|@~;gK=4k@I|~+dkh>{;vnF&Ym+2miJe@bqP?HnMnXrmbd)3f+`z+$uYKgT}F15 z#?pSLAMyP9aze9;s4?s3a$+MIrJ$7Qww-If`@uXmg=m}>-dVB~pg0&9=Z;U0<a#$S z32$`k0?SI2KuT@3ZC|~4nA3E$xfLRwk)lF44BN5C`NRz8^!t7Kj8-K#ORzp8Pvevo zEeb#?S3fbOHR@musbi-s)1jwy8;NGxn9r8?AL{YbqH$J^3wy089S0x^A)UR@_%-*1 zG}fA%uy$X>!9>VnDpUuT=mvR3&UyUD>b~Y95I|x)wdQngkfAJ;l&Ea`>8+b*6w(sj zH2t|!INl0Iok7ZqbzPyjJgz?6eR@d5E%Pi>k^X2o7+92$>q0}0Pw#EvjvGC)o&ePG zOHU|b(M+<ss-6K+oxl&&bKvx`IZ0;#gQHjMJZTPmpy5WZx6s_u{I%K>MQNvopvzBj zxI-&8SjJQHXE^`>!^hX_(`%XlD^MJZAFX}fJ~(i)2`s1UrKrW)pLigUG|y2zZCtgi zz!@G)Wz+x%*B(B<?&>alr&eU%-1XJ!Hi_&eC*^@9Y4Lg%@I49egt9E4Y>nxqQVOsQ zjOW;&rg`XWlK}<k(?RaBAtMthVb+t*?cj7gqPJasAnr1L{{78r({(UKG>}0ZL1w6( zRX|zFmI~SAgaeQ{Jn^CM?!-x25ix5~Rm(Lw4v|3O9kN;{DqOUH9f5WvH>rwQd$`=S zMb__Mc&MbmO<##8sM#zsq>8}&%D}GB09~ApILBHh4-GC#1es}Mqy`()M%S4y6g++= zYLq4^UGhnK9%jYu{6sf=o;yY5s3mH;%5POxDJ7_XNj8!4kO&0wK>q;GqvR<h67Ef{ z_2t$%B3jI`ISFt!Ym(07m+0Lip??arl*Y}iAhy-b9ln~n1T^eR0VBeUBvMWSl9GZH z4De5mvmV|l=gT7G7d<XMnt5I>9gik-wXJVUeIHlXK<`mTi&AaX6=XCsD@a!$7v;UU z1K-c<)hp(u3ItHF9Xi_k=@v90Wd>@9A+4?HNAqAFrG#vb(60i`l!jFqW4M4q;~w8B z@_(0396?r$4<P>lxP9TdDE|Nq!GUYt?0jQlSkOhBA7409Lh<19{eIuerlsOai>;f- zZwUHyg()m<e!Q)H_2kg+OF;=&jy$OY1Aaby6P*75Ec7(>Iz@f34Gu_`mGcH6-T89; z<LI}jKjM)<DhyoXgUMmXupjU`VQL{+h;FA|R*9Jjbmdh`TIX<ecG{7$dI;#)7bZkF zd~O5)d=P)u2iK*nsCPx!=zWKNP)w9E57y7`o|;G6C@BK~T}z+g`6FmN;N#z)Ziobj zAOY{r8kh+zljX6Y<R8y?CW<Ixi5wWcVB4946T6T&{{VL<-`l5itdbK@-3uRX@g9~A zPvs2u>i)jWDWcT6HqCdXk5F9f)X*$#ShUrZQNq=#j6Z^)oaNXR&jTBe80kMDQ%}qT zFipjL2a_9lM8vss6ox~G1nOD!>e~}rfFgl>PxpIHYVB93bp5*dL0?f_95nUy^)sSV z$2@a1&lFNNK-|I=-H@xjxghaw;QT&lPtH(cY6B8|8+*3YhH<3;{G_BaPzJV}*_?AV ztW+CLjjQ|(MaWM_EK3tku*O<xMlw_w(}nSk-<OX#>cKW(KqWO|HnyPtf`StuILrPu z@gsI{+SHmuE`yldYHm+=hUoJ=f_7<2sbE{fONKbX!N57{M;3{BSO6)ugKf9*h$)i@ znvxifOvaly)|H@-M_&ciP)YFd+gRx1TFNR&Dk$YiT>5KJl`|K@um>AtUQR{|;F5FI z2Z=3m=4DX@_xGo_FitXBfLTGPVtE1_6T91}5r6CwTxTRlZLFY@B_>^phBABtO1KB* zU_m?%4gn{w;U}rProYWEzLbcG(voVD)o(EOAoB!<J`p@vBCCOEsw6cILG=ryF(S#4 z&gB^6obXS+Pk;+6${5uB#kaghl!bTKZMl2g-$*^7ps=N}DC?uDhBQ`GCLD;e2spx! z0p0e;_36aTOAcNhgWmAYWw8n_O?JJ{g}frCd`(*Pb#9Hg!AnUS)u1u1=yzcFKPm8W zkPbLMr$O-~h9m;~wjQw|gH*z%2SVJrkJLLyA}v~H*VM7l7$B#^s`_e-Ta)u2Baa8$ zA733oiJ8sA8|!YO3?YqZp!zVY)rYelh`t&qtlC$%7AsOpmfQw+Q!d=T2IGO_BonlM z=hdGKwn9=Ic>5kvUz;GlWwAa3>-VXPF5;TL69Y;vH2W}8Qe9b3)Dl4-Y<u+TR8Qs( z55L|X3nZ~D`+E@HG_I5nJdLKb^*JZzDMbuqpY(!*o(H~sbd|`Ks*pT^`$AT8)b-Ry z^Ig(i(AK&=>ge?S#;Gl`j8xIcR$6JX&&?YRxjFOa@5fpa!XzMqYO2_madtZnUNN4? z!=`FZWP+9<vwh2IEbm7x1?}ECYN<V2zKOFsf&nlyLcoL0SDg9!beuX#E&f8}H9GU; z^ny(E5|=a~w4u@U2TI$NB^vX$wVOp{g6&KJ_GFWcXD1+I#xgoAVU?e{4~Lgh)OUoV z3o(%D3T|etQYuYcTt7XbZ|_dr%TCfN+~`Z7X59cI8;{VF?tFa@j=3sYfy}Cb$-hos zd%((=scT>eR#$w$iW?DH9znW7no54#)+>a%5QUj=BX1<<jt~8>)25K5p^J?e-`|By zH9H41N>Bq<H4NuKL8&$rU|Pl`oik?a^~$L~AFb%E3diS_spOMkz!=)8k`6rm`gEWq ziiQK-0WHnp`SO*Jf(sHwSXcmgk-Od6CG^vu4s=md%-zzWbXGf+Emc1#{dQoUN1t=o zRF`EXLdM=+{RA^6RzsMflc>!=4u$mbCHJ&9R9$`*tt_co+&almB@!DuV7Is>%HW)P z`xEceiCsx^&hPwf$~d`;oi95E4EJX>ZbY{;OAEsd^Iv>C(^0b6ZQ9oM`}YYAH9Rqu z1Lz!(4ti}x(0jxWAHVT-gC#a~45<Odg}jf5@p?onc<yePq?AQy(T>TnUM8BL5y;3a z0`ffn0Kn*ZlF3O@jVk^YVW+GjSOG#pYKCw{Z+F|&bAY;z=Uk_hH5H=UM;eXg#3i<Y zgPfC&bAj~fNEA~lt^-@3ht?2DT7e?Y^tYvLwGQwjU#YHl3TdaFcY*~Vh}JQ+qb-5s z>4E3#<EN5`S(X&!-`~DanR4ZPpmLOfpdjAB(cE*Ukl8KNOSVX3c|%4^hG23>gU)#8 z1B`ogqKu;})LNdOyfrrxl?4Ej66C4KaP4tW)-`Z^A#F3O+t<}XvY7@3LEZ}fVaKt- z!25K`{KX*DYQ@JR{KIHbl9aAuS&6ZCcNVtx@P$-2il0E1hDC6#69Xm+x6>pH4s)N= zj=q~M8@cCiC*Gd0OrtX*k`ixlK`*g=g$;UICK0T)#eEIFu5D>*($@HCqNaiBE^H)@ zD3q#|f+Aau$L87(Bc7~F?ROAVuft26QicJNZUA5aYIJ&+F$FiXTs>1pAWBN+I*24W za>9kfi`SzIz{aMy>6vQi?$=x1qtz8t!mN^0LbTDA@JW(FTau^zBR_7S*NIU{8J$-n z-TuNR@VGRoWF-ufYoDbb$9itcx{8)K=_jyh3M)erKaEwoN8y1}>PhmG$@cI%M|oEu z+&_W2&~$~U1KmoE=mBpK0CEY?+&+<Hv=?T((cWr`fB6}2TlLEGS^oe#y<M-=bYiko zoyb2GI?Bj0PU53;9GnfKrqwY)G)taA?`r<8-m%$Ilb8}&C5`IZlT+paXzj;meg<{l zXKXZ-dLK()Z}-U|SFgQWYT%M;mLP6KFv+?o!NwDiepWd=^-JS898M)=Q&Mt+N;vRi zUlA631|md|$R+E0kWTu6zS;;cy=Wfh)0hSS07vSG;o%xeyE$cL8TlGlh>|iGer#}g zIO@1KcmO&Wj$gI+-jOnAAb_<@sOD&NH+JjJ18#Noa3ayu=}0H43xcs*<N*hfxGMvK zGINeG;B^%@g`+WIUH99Q`e_pA#VjGzK%l)W8sn?iR)Ct?o4uy0Wv<Ydh~Z!aa|vXP zc+V;xGNAG9{QWrTV~1r(GaM6s?JHK%`5QpfN>s@tcq#1KY}NEOgR3^Vv{Tb9Ii%Jx zr~t^zNeqHK6OL7sk@Yy}sc1q{kY)mTgF&wS2DW$-T*6doPiBJSa@@;6#nZS}`70`D zW3u053meWWD`@3~6tA+ZLF94g-ACUU=(B+&m1YfJUbG(R0Z&j0$evX@{)d;0R*i4n z3W_mN1yzewR8jBV(C(g!g~ro^ff_RcF_7-r#&9}tVOf@yZKILn9XSHBuAp=B^47ee zhfeFehO0|$lFOv1X{{8?>M4R*<XAu%D4+)lc;E!V`h9sb43G|0k0$W`Bbb?qQOr{Q zP3mpP8d$>~xb0q`?uMSaSuK>%*eY6-$X4fF8no35&e4@j1GswvHk{{x3BY1yfl?`} zAJ!3wmSzDiSRjG#TW?C!Xrrz0<K2rT-j0qfJ1tX9P^}8l@7jlqkV@kmXF19GbB?St zF|vYt%lx~aFSR-w#5~L@%Z#MAT{QhU=@L-*RMzm#HO~J4D%+^zp9srMBNtE2xr?f- zXXaDSu><Yb7sX{|DGLR>4&FZN9r-ItohT-}h~=PzZ5^P^lKX3@(-POUR4PlN#CA_0 zb;E}G@OZ%l4<0%ael-&dJC?sD`)LPB!Kkb+F=M8-uUg%{)wJE1u-&XTJxy0!lEqJ1 zIWor_BXo`(jui2<W<q>pllt`wiJY0g?tW4A-VVb{M6sCzr5^OBFMGu6TWzH%S}Cij zNJdi8hTH-64fN#X_yelGJG(b31M6SV#NHpi6sNwU>&vV*eip746!BbZ-M|iuu!FaP z4t%yp)bu36l3hSoZ*+oF+(BAip7!5wrD9RgR8)~$u5nh;R94-X6HigR;W7vt(>U{< zN7o%o@YzTNEKuvF&w8Gou^zRo9EQBNty@Mq=?{rzt%`3_qLSN2t-;veF!sU6zw+a$ z13Fn0G#^{J)*70yfJ=2WW*=|bBRcCvbQ+#&nk!~%S=>UhCiQn9;N#!i<oopkW+)`7 zA;H`a8$+Wa(CQ3ct#;6R4dD`=p3!sH38?N=O>tooysE?i7d)N2bCTH~r{ASvRHTBj zVm^>8`A8&OKfibD5>3X#_{&;a<EDBg5SU=}*stOEz$4{T#s)a{=+sPP6Po_N#0M%; z8!FZ3OYSZ2c!YJIX#0P!Y;{xjE4R81dgpeAJDS^2Pr$+T8bEg_$lO_v9!NOoF^Qim zD3-G1=ud@-`az3GG${@B{km6Ztr}b5cSva+Lo`+kGtwmT#~6;^G_OX*kTQ`PgM+Z= z0OSGV0Q5?@-ZE(UOYZ|phxMr*9x&=Kl2D^G0tmlGr7w2?bcrhKq3yOJwBoMca~R$Y zB%Z2l_Qq5kk%Nq6WE0djA!|ai1#NFXr>8iZkO4}8a0MH0J(;UVg!ap2x@iXZUE)^o ziITtAaz{DO)2Qk5fTg|G97|0gljE`X(g>@qwkmThl(f}zZTVxCQG>LcgT`=p#sSAn zs&v9YN~vGl$5-%&)sk2lfNvkw{q+%@GUZCg0+2@H3EW%gc=~69_v^VUf79Qj4={Io zv>txPtUp_5lCYU6;D%o$ow#B_@N<Lh{PEQ94=MzL9lW~oh}r21s%cC5`E`ey=;VSV zX(`mMPs$SjocTU+?0Wia#Q@1I;{M;m#KLgdTnBgCp1N!05#85PMNVo2aaXM2_J#aA ze-Pssk8lBi_hIs{89gBwDkQ9e8*$dVTUc7=6s1`~aaUqG4q)sYpBM)TQF*BgQ<Dzx z$r`ay?ek>*W5MKr2e1dC%H^V{6&kbjvvhIe4-{r$mHp~`4>*$OkR(qO5j;>}9up@V zc+bDT{AZ*Yg(}Z;Z3dO~_gMJV$)W4-ZD4ZcN9)SWWSX70AZ#T`2iu=NUvKqvAjC4e zzsR&QaRCX+fnHp{U7;0C4OLiLb6T@Npq-~UQ|>nBgP(qsR9Kr4Z+mjIHjGJ@2~~Bg z`yF|)cy2bwfGl`mcNZa};j!Zf89g+);YHt-Jzm$|4VN@f&`?`XZ4MN4a0;-6wz{h^ z2sjy2{d^8O6q^-ve-BTzOZ?I$fTZg{4?90LtZGYjJg-kr6cNZA4cQyNmft^op1zYb zg=^*X<-OkU?9_)?xYzFfG&|lambCb0(wF9_tF(SH#$}h)*dtl#lQ_c&++}h}Cu0Hh z$5$L5YG$A@V$^PjL$^^ByiWm^r4;}|o`-KvuUc}59^>$#swiiINT_tqr-Rfg1tjK{ zLZkk~jDyBG40-BZ9k)?*snSmW0Ihm8;xzCumj#Q~lxqEY9<Y*^;ZC)xWS;7`Juz65 z0}?SxgpxCZhYH_PKgZ9w_v^DeR;3UUT=L~hx7r R(Q)bhok6nw#quTSIo%9Tg>P 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�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 literal 0 HcmV?d00001 diff --git a/web_console_v2/inspection/testing/test_data/image/images/000000008181.jpg b/web_console_v2/inspection/testing/test_data/image/images/000000008181.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b71a457b89dbddff102a8bc586f19d5b1581b292 GIT binary patch literal 214617 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Þ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®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 U4-R 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≠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 ZmM;<5fbq+-4S*de-b1K_Ia^_W|JgFY|GWSI literal 0 HcmV?d00001 diff --git a/web_console_v2/inspection/testing/test_data/image/images/000000018425.jpg b/web_console_v2/inspection/testing/test_data/image/images/000000018425.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2edae28c6fa0fa30bf7489cf1a95b5e6c74b52c5 GIT binary patch literal 209720 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%0DRpp{JE|$w}mt9o1 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 akEkQB+pT1}pyNbCqaxMBIFg>jmH*j*urx^k literal 0 HcmV?d00001 diff --git a/web_console_v2/inspection/testing/test_data/image/manifest.json b/web_console_v2/inspection/testing/test_data/image/manifest.json new file mode 100644 index 000000000..4962e4f4a --- /dev/null +++ b/web_console_v2/inspection/testing/test_data/image/manifest.json @@ -0,0 +1,40 @@ +{ + "images": [ + { + "name": "000000018425.jpg", + "file_name": "000000018425.jpg", + "width": 640, + "height": 480, + "file_size": 209720, + "created_at": "2021-08-30T16:52:15.501516", + "annotation": { + "caption": "Two giraffe grazing on tree leaves under a hazy sky.", + "label": "B" + } + }, + { + "name": "000000005756.jpg", + "file_name": "000000005756.jpg", + "width": 640, + "height": 361, + "file_size": 215758, + "created_at": "2021-08-30T16:52:15.501516", + "annotation": { + "caption": "A group of people holding umbrellas looking at graffiti.", + "label": "A" + } + }, + { + "name": "000000008181.jpg", + "file_name": "000000008181.jpg", + "width": 640, + "height": 480, + "file_size": 214617, + "created_at": "2021-08-30T16:52:15.501516", + "annotation": { + "caption": "A motorcycle is parked on a gravel lot", + "label": "C" + } + } + ] +} \ No newline at end of file diff --git a/web_console_v2/inspection/testing/test_data/tfrecords/small_tfrecords/default_small_data.tfrecords b/web_console_v2/inspection/testing/test_data/tfrecords/small_tfrecords/default_small_data.tfrecords new file mode 100644 index 0000000000000000000000000000000000000000..8ae5232ce7292164d016dc9e5396d56b542ed8c9 GIT binary patch literal 491 zcmWGyfB@Ck?Ay5lxP-a5QY#X33vyE9GgE|ErI@)G8M*km*oqR%kp#qms>)LHO5#g0 zb5n&lq}aGv_8z~;$tA$Wnv<B6nj^%{#mdE;oLG`=z3_JsK^vIRZD8Ue&W4oybm<?) zn}eY?@YecG<O;;*2WE5|m~q-52=fC67tmX2MfvG#bFz{Ndx8bs3KpDJU<8x+^7@U0 TZD2*Wft5%bfWg$8AZ-BvW8-iM literal 0 HcmV?d00001 diff --git a/web_console_v2/inspection/util.py b/web_console_v2/inspection/util.py new file mode 100644 index 000000000..4770f8b05 --- /dev/null +++ b/web_console_v2/inspection/util.py @@ -0,0 +1,130 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import enum +from typing import Optional +import json +import logging + +import fsspec +from pyspark.conf import SparkConf +from pyspark.sql import SparkSession +from pyspark.sql.dataframe import DataFrame +from pyspark.sql.types import StructType +from urllib.parse import urlparse + +EXAMPLE_ID = 'example_id' +DEFAULT_SCHEME_TYPE = 'file' + + +class FileSuffixError(Exception): + + def __init__(self, message): + super().__init__() + self.message = message + + def __repr__(self): + return f'{type(self).__name__}({self.message})' + + +def dataset_schema_path(dataset_path: str) -> str: + return os.path.join(dataset_path, 'schema.json') + + +def build_spark_conf(conf: Optional[SparkConf] = None) -> SparkConf: + """Builds spark config by following our practices.""" + if not conf: + conf = SparkConf() + + # Ref: https://spark.apache.org/docs/3.1.1/configuration.html + + # ---------- speculation related + # Re-launches tasks if they are running slowly in a stage + conf.set('spark.speculation', 'true') + # Checks tasks to speculate every 100ms + conf.set('spark.speculation.interval', 100) + # Fraction of tasks which must be complete before speculation is enabled for a particular stage. + conf.set('spark.speculation.quantile', 0.9) + # How many times slower a task is than the median to be considered for speculation. + conf.set('spark.speculation.multiplier', 2) + # ---------- end of speculation related + + # disable compression to snappy as model training not support reading snappy file extension + conf.set('spark.hadoop.mapred.output.compress', 'false') + + return conf + + +def getenv(name: str, default: str = None) -> str: + value = os.getenv(name) + if value is not None: + return value + if default is None: + raise ValueError(f'Environment variable {name} is not set') + return default + + +def load_tfrecords(spark: SparkSession, files: str, dataset_path: str) -> DataFrame: + logging.info(f'### loading df..., input files path: {files}') + # read schema + try: + with fsspec.open(dataset_schema_path(dataset_path)) as f: + schema = StructType.fromJson(json.load(f)) + return spark.read.format('tfrecords').schema(schema).load(files) + except Exception as e: # pylint: disable=broad-except + # intersection dataset generated by FLApp has no schema, ignore it + logging.info(f'### failed to load dataset schema, err: {e}') + return spark.read.format('tfrecords').load(files) + + +def is_file_matched(path: str) -> bool: + fs: fsspec.AbstractFileSystem = fsspec.get_mapper(path).fs + glob_path = path + # input path of spark might be dir, file or wildcard, while fsspec glob could only check file and wildcard + # so we manually add wildcard ** to dir + if fs.isdir(path): + glob_path = os.path.join(path, '**') + files = fs.glob(glob_path) + # ignore _SUCCESS file and xxx._SUCCESS file + data_files = [file for file in files if os.path.split(file)[1] != '_SUCCESS' and not file.endswith('._SUCCESS')] + return len(data_files) > 0 + + +class FileFormat(str, enum.Enum): + CSV = 'csv' + TFRECORDS = 'tfrecords' + UNKNOWN = 'unknown' + + +def load_by_file_format(spark: SparkSession, input_batch_path: str, file_format: FileFormat) -> DataFrame: + if file_format == FileFormat.CSV: + return spark.read.format('csv').option('header', 'true').option('inferSchema', 'true').load(input_batch_path) + if file_format == FileFormat.TFRECORDS: + return spark.read.format('tfrecords').load(input_batch_path) + err_msg = f'### no valid file format, format: {file_format}' + logging.error(err_msg) + raise ValueError(err_msg) + + +def normalize_file_path(url: Optional[str]) -> Optional[str]: + if url is None: + return url + url_parser = urlparse(url) + scheme = url_parser.scheme + # set default source_type if no source_type found + if scheme == '' and url.startswith('/'): + url = f'{DEFAULT_SCHEME_TYPE}://{url}' + return url diff --git a/web_console_v2/inspection/util_test.py b/web_console_v2/inspection/util_test.py new file mode 100644 index 000000000..7cebe7dac --- /dev/null +++ b/web_console_v2/inspection/util_test.py @@ -0,0 +1,216 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import unittest +import fsspec +import os +import json + +from util import FileFormat, dataset_schema_path, load_tfrecords, is_file_matched, load_by_file_format, \ + normalize_file_path +from pyspark.sql.types import BinaryType, IntegerType, StructField, StructType, StringType +from testing.spark_test_case import PySparkTestCase + + +class UtilTest(PySparkTestCase): + + def tearDown(self) -> None: + self._clear_up() + return super().tearDown() + + def _generate_tfrecords_with_schema(self, dataset_path: str, batch_path: str): + data = [ + (1, b'01001100111', 256, 256, 3, 'cat'), + (2, b'01001100111', 244, 246, 3, 'dog'), + (3, b'01001100111', 255, 312, 3, 'cat'), + (4, b'01001100111', 256, 255, 3, 'cat'), + (5, b'01001100111', 201, 241, 3, 'cat'), + (6, b'01001100111', 255, 221, 3, 'dog'), + (7, b'01001100111', 201, 276, 3, 'dog'), + (8, b'01001100111', 258, 261, 3, 'dog'), + (9, b'01001100111', 198, 194, 3, 'cat'), + (10, b'01001100111', 231, 221, 3, 'cat'), + ] + schema = StructType([ + StructField('raw_id', IntegerType(), False), + StructField('image', BinaryType(), False), + StructField('rows', IntegerType(), False), + StructField('cols', IntegerType(), False), + StructField('channel', IntegerType(), False), + StructField('label', StringType(), False), + ]) + df = self.spark.createDataFrame(data=data, schema=schema) + df.repartition(3).write.format('tfrecords').option('compression', 'none').save(batch_path, mode='overwrite') + with fsspec.open(dataset_schema_path(dataset_path), mode='w') as f: + json.dump(df.schema.jsonValue(), f) + + def _generate_tfrecords_no_schema(self, batch_path: str): + data = [ + (1, b'01001100111', 256, 256, 3, 'cat'), + (2, b'01001100111', 244, 246, 3, 'dog'), + (3, b'01001100111', 255, 312, 3, 'cat'), + (4, b'01001100111', 256, 255, 3, 'cat'), + (5, b'01001100111', 201, 241, 3, 'cat'), + (6, b'01001100111', 255, 221, 3, 'dog'), + (7, b'01001100111', 201, 276, 3, 'dog'), + (8, b'01001100111', 258, 261, 3, 'dog'), + (9, b'01001100111', 198, 194, 3, 'cat'), + (10, b'01001100111', 231, 221, 3, 'cat'), + ] + df = self.spark.createDataFrame(data=data) + df.repartition(3).write.format('tfrecords').option('compression', 'none').save(batch_path, mode='overwrite') + + def _clear_up(self): + fs = fsspec.filesystem('file') + if fs.isdir(self.tmp_dataset_path): + fs.rm(self.tmp_dataset_path, recursive=True) + + def test_load_tfrecords_with_schema(self): + dataset_path = os.path.join(self.tmp_dataset_path, 'input_dataset') + batch_path = os.path.join(dataset_path, 'batch/batch_test') + self._generate_tfrecords_with_schema(dataset_path, batch_path) + df = load_tfrecords(spark=self.spark, files=batch_path, dataset_path=dataset_path) + data = [ + (1, b'01001100111', 256, 256, 3, 'cat'), + (2, b'01001100111', 244, 246, 3, 'dog'), + (3, b'01001100111', 255, 312, 3, 'cat'), + (4, b'01001100111', 256, 255, 3, 'cat'), + (5, b'01001100111', 201, 241, 3, 'cat'), + (6, b'01001100111', 255, 221, 3, 'dog'), + (7, b'01001100111', 201, 276, 3, 'dog'), + (8, b'01001100111', 258, 261, 3, 'dog'), + (9, b'01001100111', 198, 194, 3, 'cat'), + (10, b'01001100111', 231, 221, 3, 'cat'), + ] + schema = StructType([ + StructField('raw_id', IntegerType(), False), + StructField('image', BinaryType(), False), + StructField('rows', IntegerType(), False), + StructField('cols', IntegerType(), False), + StructField('channel', IntegerType(), False), + StructField('label', StringType(), False), + ]) + expect_df = self.spark.createDataFrame(data=data, schema=schema) + self.assertCountEqual(df.select('*').collect(), expect_df.select('*').collect()) + + def test_load_tfrecords_no_schema(self): + dataset_path = os.path.join(self.tmp_dataset_path, 'input_dataset') + batch_path = os.path.join(dataset_path, 'batch/batch_test') + self._generate_tfrecords_no_schema(batch_path) + df = load_tfrecords(spark=self.spark, files=batch_path, dataset_path=dataset_path) + expect_data = [ + set([1, '01001100111', 256, 256, 3, 'cat']), + set([2, '01001100111', 244, 246, 3, 'dog']), + set([3, '01001100111', 255, 312, 3, 'cat']), + set([4, '01001100111', 256, 255, 3, 'cat']), + set([5, '01001100111', 201, 241, 3, 'cat']), + set([6, '01001100111', 255, 221, 3, 'dog']), + set([7, '01001100111', 201, 276, 3, 'dog']), + set([8, '01001100111', 258, 261, 3, 'dog']), + set([9, '01001100111', 198, 194, 3, 'cat']), + set([10, '01001100111', 231, 221, 3, 'cat']), + ] + data = [] + for row in df.select('*').collect(): + row_data = [] + for key, value in row.asDict().items(): + row_data.append(value) + data.append(set(row_data)) + self.assertCountEqual(data, expect_data) + + def test_is_file_matched(self): + dataset_path = os.path.join(self.tmp_dataset_path, 'input_dataset') + batch_path = os.path.join(dataset_path, 'batch/batch_test') + # test no data + os.makedirs(batch_path, exist_ok=True) + fs: fsspec.AbstractFileSystem = fsspec.get_mapper(dataset_path).fs + fs.touch(os.path.join(batch_path, '_SUCCESS')) + fs.touch(os.path.join(batch_path, 'part-0000._SUCCESS')) + fs.touch(os.path.join(batch_path, 'part-0001._SUCCESS')) + self.assertFalse(is_file_matched(batch_path)) + + self._generate_tfrecords_with_schema(dataset_path, batch_path) + self.assertTrue(is_file_matched(batch_path)) + batch_path_csv = os.path.join(batch_path, '*.csv') + self.assertFalse(is_file_matched(batch_path_csv)) + batch_path_all = os.path.join(batch_path, '**') + self.assertTrue(is_file_matched(batch_path_all)) + + def test_load_by_file_format_csv(self): + dataset_path = os.path.join(self.test_data, 'csv/small_csv') + df = load_by_file_format(spark=self.spark, input_batch_path=dataset_path, file_format=FileFormat.CSV) + expect_dtypes = [('example_id', 'int'), ('raw_id', 'int'), ('event_time', 'int'), ('x0', 'double'), + ('x1', 'double'), ('x2', 'double'), ('label', 'string')] + self.assertCountEqual(expect_dtypes, df.dtypes) + expect_data = [ + [1, 1, 20210621, -1.13672, 0.810161, 0.185828, 'cat'], + [2, 2, 20210621, -0.365981, 0.810161, 0.185828, 'dog'], + [3, 3, 20210621, -0.597202, 0.810161, 0.185828, 'frog'], + [4, 4, 20210621, -0.905498, 0.810161, 0.185828, 'cat'], + [5, 5, 20210621, -0.905498, -1.234323, 0.185828, 'dog'], + ] + data = [] + for row in df.select('*').collect(): + row_data = [] + for key, _ in expect_dtypes: + row_data.append(row.asDict().get(key)) + data.append(row_data) + self.assertCountEqual(data, expect_data) + + def test_load_by_file_format_tfrecords(self): + dataset_path = os.path.join(self.test_data, 'tfrecords/small_tfrecords') + df = load_by_file_format(spark=self.spark, input_batch_path=dataset_path, file_format=FileFormat.TFRECORDS) + expect_dtypes = [('example_id', 'bigint'), ('raw_id', 'bigint'), ('event_time', 'bigint'), ('label', 'string')] + self.assertCountEqual(expect_dtypes, df.dtypes) + expect_data = [ + [1, 1, 20210621, 'cat'], + [2, 2, 20210621, 'dog'], + [3, 3, 20210621, 'frog'], + [4, 4, 20210621, 'cat'], + [5, 5, 20210621, 'dog'], + ] + data = [] + for row in df.select('*').collect(): + row_data = [] + for key, _ in expect_dtypes: + row_data.append(row.asDict().get(key)) + data.append(row_data) + self.assertCountEqual(data, expect_data) + + def test_load_by_file_format_failed(self): + dataset_path = os.path.join(self.tmp_dataset_path, 'input_dataset') + batch_path = os.path.join(dataset_path, 'batch/batch_test') + with self.assertRaises(ValueError): + load_by_file_format(spark=self.spark, input_batch_path=batch_path, file_format='unknown') + + def test_normalize_file_path(self): + path = normalize_file_path('') + self.assertEqual(path, '') + + path = normalize_file_path('/') + self.assertEqual(path, 'file:///') + + path = normalize_file_path('/test/123') + self.assertEqual(path, 'file:///test/123') + + path = normalize_file_path('test/123') + self.assertEqual(path, 'test/123') + + path = normalize_file_path('hdfs:///test/123') + self.assertEqual(path, 'hdfs:///test/123') + + +if __name__ == '__main__': + unittest.main() diff --git a/web_console_v2/run_dev.sh b/web_console_v2/run_dev.sh new file mode 100644 index 000000000..82b26befc --- /dev/null +++ b/web_console_v2/run_dev.sh @@ -0,0 +1,41 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +#!/bin/bash + +set -e + +# Whether API or Client, always start nginx +diff_result=$(diff ../tools/dev_workspace/nginx.conf /etc/nginx/conf.d/nginx.conf || true) +if [[ $diff_result != '' ]] +then + echo "detect difference, restarting nginx" + cp -f ../tools/dev_workspace/nginx.conf /etc/nginx/conf.d/nginx.conf + service nginx restart +fi + +if [[ $ROLE == client ]] +then + # Starts Client server + cd ./client + npm install -g pnpm@6.4.0 && pnpm install && PORT=3000 pnpm run start +elif [[ $ROLE == api ]] +then + # Starts API server + cd ./api + bash run_dev.sh +else + echo "Invalid ROLE: $ROLE" +fi From fd5272dced66eae404816dc449a02ffcdf159539 Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Thu, 2 Feb 2023 17:57:54 +0800 Subject: [PATCH 03/15] [WebConsole] Sync to github manually --- data_processing/pom.xml | 11 -- tools/BUILD.bazel | 4 + tools/tcp_grpc_proxy/Dockerfile | 26 +++ tools/tcp_grpc_proxy/cmd/grpc2tcp/BUILD.bazel | 15 ++ tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go | 19 +++ tools/tcp_grpc_proxy/cmd/tcp2grpc/BUILD.bazel | 15 ++ tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go | 19 +++ tools/tcp_grpc_proxy/pkg/proto/BUILD.bazel | 28 ++++ tools/tcp_grpc_proxy/pkg/proto/proto.go | 1 + tools/tcp_grpc_proxy/pkg/proto/tunnel.proto | 12 ++ tools/tcp_grpc_proxy/pkg/proxy/BUILD.bazel | 32 ++++ tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp.go | 134 ++++++++++++++++ .../tcp_grpc_proxy/pkg/proxy/grpc2tcp_test.go | 84 ++++++++++ tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc.go | 149 ++++++++++++++++++ .../tcp_grpc_proxy/pkg/proxy/tcp2grpc_test.go | 85 ++++++++++ tools/tcp_grpc_proxy/start_proxy.sh | 60 +++++++ 16 files changed, 683 insertions(+), 11 deletions(-) create mode 100644 tools/BUILD.bazel create mode 100644 tools/tcp_grpc_proxy/Dockerfile create mode 100644 tools/tcp_grpc_proxy/cmd/grpc2tcp/BUILD.bazel create mode 100644 tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go create mode 100644 tools/tcp_grpc_proxy/cmd/tcp2grpc/BUILD.bazel create mode 100644 tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go create mode 100644 tools/tcp_grpc_proxy/pkg/proto/BUILD.bazel create mode 100644 tools/tcp_grpc_proxy/pkg/proto/proto.go create mode 100644 tools/tcp_grpc_proxy/pkg/proto/tunnel.proto create mode 100644 tools/tcp_grpc_proxy/pkg/proxy/BUILD.bazel create mode 100644 tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp.go create mode 100644 tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp_test.go create mode 100644 tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc.go create mode 100644 tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc_test.go create mode 100644 tools/tcp_grpc_proxy/start_proxy.sh diff --git a/data_processing/pom.xml b/data_processing/pom.xml index 37614882e..4308b76ac 100644 --- a/data_processing/pom.xml +++ b/data_processing/pom.xml @@ -19,17 +19,6 @@ <maven.compiler.release>8</maven.compiler.release> </properties> - <!-- <distributionManagement> - <repository> - <id>bytedance-releases</id> - <url>https://maven.byted.org/repository/releases</url> - </repository> - <snapshotRepository> - <id>bytedance-snapshots</id> - <url>https://maven.byted.org/repository/snapshots</url> - </snapshotRepository> - </distributionManagement> --> - <dependencies> <!-- Scala --> <dependency> diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel new file mode 100644 index 000000000..66c16f2d6 --- /dev/null +++ b/tools/BUILD.bazel @@ -0,0 +1,4 @@ +package_group( + name = "tools_package", + packages = ["//tools/..."], +) diff --git a/tools/tcp_grpc_proxy/Dockerfile b/tools/tcp_grpc_proxy/Dockerfile new file mode 100644 index 000000000..5e95dab47 --- /dev/null +++ b/tools/tcp_grpc_proxy/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.16 + +RUN apt-get update && \ + apt install -y curl git vim && \ + apt-get install -y make nginx g++ libgmp-dev libglib2.0-dev libssl-dev && \ + apt-get install -y protobuf-compiler && \ + apt-get clean + +WORKDIR /app +COPY . /app/tcp_grpc_proxy + +# Copies PSI lib +RUN git clone --recursive git://github.com/encryptogroup/PSI + +WORKDIR /app/PSI +RUN make + +WORKDIR /app/tcp_grpc_proxy +RUN make build + +# upgrade nginx +RUN echo "deb http://nginx.org/packages/mainline/debian/ stretch nginx deb-src http://nginx.org/packages/mainline/debian/ stretch nginx" > /etc/apt/sources.list.d/nginx.list +RUN wget -qO - https://nginx.org/keys/nginx_signing.key | apt-key add - +RUN apt update && \ + apt remove nginx-common -y && \ + apt install nginx diff --git a/tools/tcp_grpc_proxy/cmd/grpc2tcp/BUILD.bazel b/tools/tcp_grpc_proxy/cmd/grpc2tcp/BUILD.bazel new file mode 100644 index 000000000..06fc581c5 --- /dev/null +++ b/tools/tcp_grpc_proxy/cmd/grpc2tcp/BUILD.bazel @@ -0,0 +1,15 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") + +go_library( + name = "grpc2tcp_lib", + srcs = ["main.go"], + importpath = "fedlearner.net/tools/tcp_grpc_proxy/cmd/grpc2tcp", + visibility = ["//tools:tools_package"], + deps = ["//tools/tcp_grpc_proxy/pkg/proxy"], +) + +go_binary( + name = "grpc2tcp", + embed = [":grpc2tcp_lib"], + visibility = ["//tools:tools_package"], +) diff --git a/tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go b/tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go new file mode 100644 index 000000000..872924922 --- /dev/null +++ b/tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fedlearner.net/tools/tcp_grpc_proxy/pkg/proxy" + "flag" + "fmt" +) + +func main() { + var grpcServerPort int + var targetTCPAddress string + flag.IntVar(&grpcServerPort, "grpc_server_port", 7766, "gRPC server port") + flag.StringVar(&targetTCPAddress, "target_tcp_address", "127.0.0.1:17766", "The target TCP server") + flag.Parse() + grpcServerAddress := fmt.Sprintf("0.0.0.0:%d", grpcServerPort) + + grpc2tcpServer := proxy.NewGrpc2TcpServer(grpcServerAddress, targetTCPAddress) + grpc2tcpServer.Run() +} diff --git a/tools/tcp_grpc_proxy/cmd/tcp2grpc/BUILD.bazel b/tools/tcp_grpc_proxy/cmd/tcp2grpc/BUILD.bazel new file mode 100644 index 000000000..130eb9169 --- /dev/null +++ b/tools/tcp_grpc_proxy/cmd/tcp2grpc/BUILD.bazel @@ -0,0 +1,15 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") + +go_library( + name = "tcp2grpc_lib", + srcs = ["main.go"], + importpath = "fedlearner.net/tools/tcp_grpc_proxy/cmd/tcp2grpc", + visibility = ["//tools:tools_package"], + deps = ["//tools/tcp_grpc_proxy/pkg/proxy"], +) + +go_binary( + name = "tcp2grpc", + embed = [":tcp2grpc_lib"], + visibility = ["//tools:tools_package"], +) diff --git a/tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go b/tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go new file mode 100644 index 000000000..9b81e8f75 --- /dev/null +++ b/tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fedlearner.net/tools/tcp_grpc_proxy/pkg/proxy" + "flag" + "fmt" +) + +func main() { + var tcpServerPort int + var targetGrpcAddress string + flag.IntVar(&tcpServerPort, "tcp_server_port", 17767, "TCP server port") + flag.StringVar(&targetGrpcAddress, "target_grpc_address", "127.0.0.1:7766", "The target gRPC server") + flag.Parse() + tcpServerAddress := fmt.Sprintf("0.0.0.0:%d", tcpServerPort) + + tcp2grpcServer := proxy.NewTcp2GrpcServer(tcpServerAddress, targetGrpcAddress) + tcp2grpcServer.Run() +} diff --git a/tools/tcp_grpc_proxy/pkg/proto/BUILD.bazel b/tools/tcp_grpc_proxy/pkg/proto/BUILD.bazel new file mode 100644 index 000000000..727149066 --- /dev/null +++ b/tools/tcp_grpc_proxy/pkg/proto/BUILD.bazel @@ -0,0 +1,28 @@ +load("@rules_proto//proto:defs.bzl", "proto_library") +load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library") + +# gazelle:go_generate_proto true + +proto_library( + name = "proto_proto", + srcs = ["tunnel.proto"], + visibility = ["//tools:tools_package"], +) + +# keep +go_proto_library( + name = "proto_go_proto", + compilers = ["@io_bazel_rules_go//proto:go_grpc"], + importpath = "fedlearner.net/tools/tcp_grpc_proxy/pkg/proto", + proto = ":proto_proto", + visibility = ["//tools:tools_package"], +) + +go_library( + name = "proto", + srcs = ["proto.go"], + embed = [":proto_go_proto"], # keep + importpath = "fedlearner.net/tools/tcp_grpc_proxy/pkg/proto", + visibility = ["//tools:tools_package"], +) diff --git a/tools/tcp_grpc_proxy/pkg/proto/proto.go b/tools/tcp_grpc_proxy/pkg/proto/proto.go new file mode 100644 index 000000000..92256db4b --- /dev/null +++ b/tools/tcp_grpc_proxy/pkg/proto/proto.go @@ -0,0 +1 @@ +package proto diff --git a/tools/tcp_grpc_proxy/pkg/proto/tunnel.proto b/tools/tcp_grpc_proxy/pkg/proto/tunnel.proto new file mode 100644 index 000000000..ce5987254 --- /dev/null +++ b/tools/tcp_grpc_proxy/pkg/proto/tunnel.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package proto; +option go_package = "fedlearner.net/tools/tcp_grpc_proxy/pkg/proto"; + +service TunnelService { + rpc Tunnel (stream Chunk) returns (stream Chunk); +} + +message Chunk { + bytes data = 1; +} diff --git a/tools/tcp_grpc_proxy/pkg/proxy/BUILD.bazel b/tools/tcp_grpc_proxy/pkg/proxy/BUILD.bazel new file mode 100644 index 000000000..7af01f33e --- /dev/null +++ b/tools/tcp_grpc_proxy/pkg/proxy/BUILD.bazel @@ -0,0 +1,32 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "proxy", + srcs = [ + "grpc2tcp.go", + "tcp2grpc.go", + ], + importpath = "fedlearner.net/tools/tcp_grpc_proxy/pkg/proxy", + visibility = ["//tools:tools_package"], + deps = [ + "//tools/tcp_grpc_proxy/pkg/proto", + "@com_github_sirupsen_logrus//:logrus", + "@org_golang_google_grpc//:go_default_library", + ], +) + +go_test( + name = "proxy_test", + srcs = [ + "grpc2tcp_test.go", + "tcp2grpc_test.go", + ], + embed = [":proxy"], + visibility = ["//tools:tools_package"], + deps = [ + "//tools/tcp_grpc_proxy/pkg/proto", + "@com_github_sirupsen_logrus//:logrus", + "@com_github_stretchr_testify//assert", + "@org_golang_google_grpc//:go_default_library", + ], +) diff --git a/tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp.go b/tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp.go new file mode 100644 index 000000000..b9cb5c452 --- /dev/null +++ b/tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp.go @@ -0,0 +1,134 @@ +package proxy + +import ( + "fmt" + "io" + "net" + + "fedlearner.net/tools/tcp_grpc_proxy/pkg/proto" + "github.com/sirupsen/logrus" + "google.golang.org/grpc" +) + +// Grpc2TcpServer A server to proxy grpc traffic to TCP +type Grpc2TcpServer struct { + proto.UnimplementedTunnelServiceServer + grpcServerAddress string + targetTcpAddress string +} + +// Tunnel the implementation of gRPC Tunnel service +func (s *Grpc2TcpServer) Tunnel(stream proto.TunnelService_TunnelServer) error { + tcpConnection, err := net.Dial("tcp", s.targetTcpAddress) + if err != nil { + logrus.Errorf("[GRPC2TCP] Dail to tcp target %s error: %v", s.targetTcpAddress, err) + return err + } + contextLogger := logrus.WithFields(logrus.Fields{ + "prefix": "[GRPC2TCP]", + "tcp_client_addr": tcpConnection.LocalAddr().String(), + }) + contextLogger.Infoln("Connected to", s.targetTcpAddress) + // Makes sure the connection gets closed + defer tcpConnection.Close() + defer contextLogger.Infoln("Connection closed to", s.targetTcpAddress) + + errChan := make(chan error) + + // Gets data from gRPC client and proxy to remote TCP server + go func() { + tcpSentBytes := 0 + grpcReceivedBytes := 0 + defer func() { + contextLogger.Infof("gRPC received %d bytes, TCP sent %d byte", grpcReceivedBytes, tcpSentBytes) + }() + + for { + chunk, err := stream.Recv() + if err == io.EOF { + contextLogger.Infoln("gRpc client EOF") + return + } + if err != nil { + errChan <- fmt.Errorf("error while receiving gRPC data: %v", err) + return + } + data := chunk.Data + grpcReceivedBytes += len(data) + + contextLogger.Debugln("Sending %d bytes to tcp server", len(data)) + _, err = tcpConnection.Write(data) + if err != nil { + errChan <- fmt.Errorf("error while sending TCP data: %v", err) + return + } else { + tcpSentBytes += len(data) + } + } + }() + + // Gets data from remote TCP server and proxy to gRPC client + go func() { + tcpReceivedBytes := 0 + grpcSentBytes := 0 + defer func() { + contextLogger.Infof("Tcp received %d bytes, gRPC sent %d bytes", tcpReceivedBytes, grpcSentBytes) + } () + + buff := make([]byte, 64*1024) + for { + bytesRead, err := tcpConnection.Read(buff) + if err == io.EOF { + contextLogger.Infoln("Remote TCP connection closed") + errChan <- nil + return + } + if err != nil { + errChan <- fmt.Errorf("error while receiving TCP data: %v", err) + return + } + tcpReceivedBytes += bytesRead + + contextLogger.Debugf("Sending %d bytes to gRPC client\n", bytesRead) + err = stream.Send(&proto.Chunk{Data: buff[0:bytesRead]}) + if err != nil { + errChan <- fmt.Errorf("error while sending gRPC data: %v", err) + return + } else { + grpcSentBytes += bytesRead + } + } + }() + + // Blocking read + returnedError := <-errChan + if returnedError != nil { + contextLogger.Errorln(returnedError) + } + return returnedError +} + +// NewGrpc2TcpServer constructs a Grpc2TCP server +func NewGrpc2TcpServer(grpcServerAddress, targetTcpAddress string) *Grpc2TcpServer { + return &Grpc2TcpServer{ + grpcServerAddress: grpcServerAddress, + targetTcpAddress: targetTcpAddress, + } +} + +// Run starts the Grpc2TCP server +func (s *Grpc2TcpServer) Run() { + listener, err := net.Listen("tcp", s.grpcServerAddress) + if err != nil { + logrus.Fatalln("Failed to listen: ", err) + } + defer listener.Close() + + // Starts a gRPC server and register services + grpcServer := grpc.NewServer() + proto.RegisterTunnelServiceServer(grpcServer, s) + logrus.Infof("Starting gRPC server at: %s, target to %s", s.grpcServerAddress, s.targetTcpAddress) + if err := grpcServer.Serve(listener); err != nil { + logrus.Fatalln("Unable to start gRPC serve:", err) + } +} diff --git a/tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp_test.go b/tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp_test.go new file mode 100644 index 000000000..6166916ee --- /dev/null +++ b/tools/tcp_grpc_proxy/pkg/proxy/grpc2tcp_test.go @@ -0,0 +1,84 @@ +package proxy + +import ( + "context" + "fmt" + "io" + "net" + "testing" + "time" + + "fedlearner.net/tools/tcp_grpc_proxy/pkg/proto" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" +) + +func runFakeTcpServer(listener net.Listener) { + for { + conn, err := listener.Accept() + if err != nil { + logrus.Infoln("Intended TCP listener error:", err) + return + } + + go func(conn net.Conn) { + defer conn.Close() + for { + request := make([]byte, 64*1024) + bytesRead, err := conn.Read(request) + if err == io.EOF { + logrus.Infoln("[TCP server] Connection finished") + return + } + if err != nil { + logrus.Errorln("[TCP seerver] Error:", err) + return + } + response := fmt.Sprintf("[Proxy] %s", string(request[0:bytesRead])) + conn.Write([]byte(response)) + } + }(conn) + } +} + +func TestGrpc2Tcp(t *testing.T) { + grpcServerAddress := "localhost:13001" + targetTcpAddress := "localhost:13002" + + // Sets up a fake TCP server + listener, err := net.Listen("tcp", targetTcpAddress) + if err != nil { + assert.Fail(t, "Failed to listen") + } + go runFakeTcpServer(listener) + + // Starts the proxy + tcp2grpcServer := NewGrpc2TcpServer(grpcServerAddress, targetTcpAddress) + go tcp2grpcServer.Run() + time.Sleep(1 * time.Second) + + // Sends data by grpc connection and gets response in the same channel + responseChan := make(chan string) + for i := 0; i < 3; i++ { + go func(message string) { + grpcConn, _ := grpc.Dial(grpcServerAddress, grpc.WithInsecure()) + grpcClient := proto.NewTunnelServiceClient(grpcConn) + stream, _ := grpcClient.Tunnel(context.Background()) + + stream.Send(&proto.Chunk{Data: []byte(message)}) + stream.CloseSend() + chunk, _ := stream.Recv() + responseChan <- string(chunk.Data) + grpcConn.Close() + }(fmt.Sprintf("hello %d", i)) + } + + responses := make([]string, 0) + for i := 0; i < 3; i++ { + r := <-responseChan + responses = append(responses, r) + } + assert.ElementsMatch(t, responses, + []string{"[Proxy] hello 0", "[Proxy] hello 1", "[Proxy] hello 2"}) +} diff --git a/tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc.go b/tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc.go new file mode 100644 index 000000000..ae8baf8c3 --- /dev/null +++ b/tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc.go @@ -0,0 +1,149 @@ +package proxy + +import ( + "context" + "io" + "net" + "sync" + + "fedlearner.net/tools/tcp_grpc_proxy/pkg/proto" + "github.com/sirupsen/logrus" + "google.golang.org/grpc" +) + +// Tcp2GrpcServer to proxy TCP traffic to gRPC +type Tcp2GrpcServer struct { + tcpServerAddress string + targetGrpcAddress string +} + +// NewTcp2GrpcServer constructs a TCP2GrpcServer +func NewTcp2GrpcServer(tcpServerAddress, targetGrpcAddress string) *Tcp2GrpcServer { + return &Tcp2GrpcServer{ + tcpServerAddress: tcpServerAddress, + targetGrpcAddress: targetGrpcAddress, + } +} + +func handleTcpConnection(tcpConn net.Conn, targetGrpcAddress string) { + contextLogger := logrus.WithFields(logrus.Fields{ + "prefix": "[TCP2GRPC]", + "tcp_client_addr": tcpConn.RemoteAddr().String(), + }) + + contextLogger.Infoln("Handle tcp connection, target to:", targetGrpcAddress) + defer tcpConn.Close() + + grpcConn, err := grpc.Dial(targetGrpcAddress, grpc.WithInsecure()) + if err != nil { + contextLogger.Errorf("Failed to connect to grpc %s: %v\n", targetGrpcAddress, err) + return + } + defer grpcConn.Close() + + grpcClient := proto.NewTunnelServiceClient(grpcConn) + stream, err := grpcClient.Tunnel(context.Background()) + if err != nil { + contextLogger.Errorln("Error of tunnel service:", err) + return + } + + var wg sync.WaitGroup + + // Gets data from remote gRPC server and proxy to TCP client + wg.Add(1) + go func() { + defer wg.Done() + + tcpSentBytes := 0 + grpcReceivedBytes := 0 + defer func() { + contextLogger.Infof("gRPC received %d bytes, TCP sent %d byte", grpcReceivedBytes, tcpSentBytes) + }() + + for { + chunk, err := stream.Recv() + if err == io.EOF { + contextLogger.Infoln("gRpc server EOF") + tcpConn.Close() + return + } + if err != nil { + contextLogger.Errorf("Recv from grpc target %s terminated: %v", targetGrpcAddress, err) + tcpConn.Close() + return + } + grpcReceivedBytes += len(chunk.Data) + + contextLogger.Debugln("Sending %d bytes to TCP client", len(chunk.Data)) + _, err = tcpConn.Write(chunk.Data) + if err != nil { + contextLogger.Errorln("Failed to send data to TCP client:", err) + return + } else { + tcpSentBytes += len(chunk.Data) + } + } + }() + + // Gets data from TCP client and proxy to remote gRPC server + wg.Add(1) + go func() { + defer wg.Done() + + tcpReceivedBytes := 0 + grpcSentBytes := 0 + defer func() { + contextLogger.Infof("TCP received %d bytes, gRPC sent %d bytes", tcpReceivedBytes, grpcSentBytes) + }() + + tcpData := make([]byte, 64*1024) + for { + bytesRead, err := tcpConn.Read(tcpData) + + if err == io.EOF { + contextLogger.Infoln("Connection finished") + stream.CloseSend() + return + } + if err != nil { + contextLogger.Errorln("Read from tcp error:", err) + stream.CloseSend() + return + } + tcpReceivedBytes += bytesRead + + contextLogger.Debugln("Sending %d bytes to gRPC server", bytesRead) + err = stream.Send(&proto.Chunk{Data: tcpData[0:bytesRead]}) + if err != nil { + contextLogger.Errorln("Failed to send gRPC data:", err) + return + } else { + grpcSentBytes += bytesRead + } + } + }() + + wg.Wait() +} + +// Run Starts the server +func (s *Tcp2GrpcServer) Run() { + listener, err := net.Listen("tcp", s.tcpServerAddress) + if err != nil { + logrus.Fatalln("Listen TCP error: ", err) + } + defer listener.Close() + logrus.Infoln("Run TCPServer at ", s.tcpServerAddress) + + for { + conn, err := listener.Accept() + if err != nil { + logrus.Errorln("TCP listener error:", err) + continue + } + + logrus.Infoln("Got tcp connection") + go handleTcpConnection(conn, s.targetGrpcAddress) + } +} diff --git a/tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc_test.go b/tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc_test.go new file mode 100644 index 000000000..3eb98bd4c --- /dev/null +++ b/tools/tcp_grpc_proxy/pkg/proxy/tcp2grpc_test.go @@ -0,0 +1,85 @@ +package proxy + +import ( + "fmt" + "io" + "net" + "testing" + "time" + + "fedlearner.net/tools/tcp_grpc_proxy/pkg/proto" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" +) + +// mockGrpc2TcpServer is used to mock TunnelServer +type mockTunnelServer struct { + proto.UnimplementedTunnelServiceServer +} + +func (s *mockTunnelServer) Tunnel(stream proto.TunnelService_TunnelServer) error { + for { + chunk, err := stream.Recv() + if err == io.EOF { + logrus.Infoln("[gRPC server] Stream EOF") + return nil + } + if err != nil { + logrus.Errorln("[gRPC server] error:", err) + return err + } + response := fmt.Sprintf("[Proxy] %s", string(chunk.Data)) + if err = stream.Send(&proto.Chunk{Data: []byte(response)}); err != nil { + return err + } + } +} + +func runFakeGrpcServer(listener net.Listener) { + // Starts a gRPC server and register services + grpcServer := grpc.NewServer() + proto.RegisterTunnelServiceServer(grpcServer, &mockTunnelServer{}) + if err := grpcServer.Serve(listener); err != nil { + logrus.Fatalln("Unable to start gRPC serve:", err) + } +} + +func TestTcp2Grpc(t *testing.T) { + tcpServerAddress := "localhost:12001" + targetGrpcAddress := "localhost:12002" + + // Sets up a fake gRPC server + listener, err := net.Listen("tcp", targetGrpcAddress) + if err != nil { + assert.Fail(t, "Failed to listen") + } + go runFakeGrpcServer(listener) + + // Starts the proxy + tcp2grpcServer := NewTcp2GrpcServer(tcpServerAddress, targetGrpcAddress) + go tcp2grpcServer.Run() + time.Sleep(1 * time.Second) + + // Sends data by tcp connection and gets response in the same channel + responseChan := make(chan string) + for i := 0; i < 3; i++ { + go func(message string) { + tcpConnection, _ := net.Dial("tcp", tcpServerAddress) + tcpConnection.Write([]byte(message)) + response := make([]byte, 64*1024) + bytesRead, _ := tcpConnection.Read(response) + responseChan <- string(response[0:bytesRead]) + tcpConnection.Close() + }(fmt.Sprintf("hello %d", i)) + } + + responses := make([]string, 0) + for i := 0; i < 3; i++ { + r := <-responseChan + responses = append(responses, r) + } + + assert.ElementsMatch(t, responses, + []string{"[Proxy] hello 0", "[Proxy] hello 1", "[Proxy] hello 2"}) +} diff --git a/tools/tcp_grpc_proxy/start_proxy.sh b/tools/tcp_grpc_proxy/start_proxy.sh new file mode 100644 index 000000000..6fb20a114 --- /dev/null +++ b/tools/tcp_grpc_proxy/start_proxy.sh @@ -0,0 +1,60 @@ +#! /bin/bash +set -ex + +# The chain of the traffic: +# TCP client -> out TCP server -> out gRPC server -> Nginx -> +# network -> remote grpc server (Nginx) -> in gRPC server -> in TCP server +OUT_TCP_SERVER_PORT=17767 +OUT_GRPC_SERVER_PORT=17768 +IN_GRPC_SERVER_PORT=17769 +IN_TCP_SERVER_PORT=7766 + +REMOTE_GRPC_SERVER_HOST=1.1.1.1 +REMOTE_GRPC_SERVER_PORT=17771 + +echo " +upstream remote_grpc_server { + server ${REMOTE_GRPC_SERVER_HOST}:${REMOTE_GRPC_SERVER_PORT}; +} + +# Proxies to remote grpc server +server { + listen ${OUT_GRPC_SERVER_PORT} http2; + + # No limits + client_max_body_size 0; + grpc_read_timeout 3600s; + grpc_send_timeout 3600s; + client_body_timeout 3600s; + # grpc_socket_keepalive is recommended but not required + # grpc_socket_keepalive is supported after nginx 1.15.6 + grpc_socket_keepalive on; + location / { + # change grpc to grpcs if ssl is used + grpc_pass grpc://remote_grpc_server; + } +} + +# Listens grpc traffic, this port should be public +server { + listen ${REMOTE_GRPC_SERVER_PORT} http2; + + # No limits + client_max_body_size 0; + grpc_read_timeout 3600s; + grpc_send_timeout 3600s; + client_body_timeout 3600s; + grpc_socket_keepalive on; + location / { + grpc_pass grpc://localhost:${IN_GRPC_SERVER_PORT}; + } +} +" > nginx.conf +cp nginx.conf /etc/nginx/conf.d/nginx.conf +service nginx restart + +./tcp2grpc --tcp_server_port="$OUT_TCP_SERVER_PORT" \ + --target_grpc_address="localhost:$OUT_GRPC_SERVER_PORT" & + +./grpc2tcp --grpc_server_port="$IN_GRPC_SERVER_PORT" \ + --target_tcp_address="localhost:$IN_TCP_SERVER_PORT" & From b055ebda8319162ffd469afe75ce6a553daf9454 Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Mon, 6 Feb 2023 16:53:20 +0800 Subject: [PATCH 04/15] [WebConsole] Sync to github manually --- pp_lite/data_join/psi_rsa/psi_client.py | 3 +- pp_lite/proto/arguments.proto | 2 + pp_lite/test/trainer_test.py | 14 +- web_console_v2/api/BUILD.bazel | 7 +- web_console_v2/api/cmds/BUILD.bazel | 28 +-- .../fedlearner_webconsole/mmgr/controller.py | 35 +++- .../mmgr/controller_test.py | 81 ++++++++- .../mmgr/model_job_apis.py | 42 ++++- .../mmgr/model_job_apis_test.py | 171 +++++++++++++++--- .../mmgr/model_job_group_apis.py | 1 + .../fedlearner_webconsole/mmgr/scheduler.py | 2 + .../api/fedlearner_webconsole/mmgr/service.py | 9 +- .../mmgr/service_test.py | 22 +++ .../rpc/v2/job_service_client.py | 7 +- .../rpc/v2/job_service_client_test.py | 18 +- .../rpc/v2/job_service_server.py | 23 ++- .../rpc/v2/job_service_server_test.py | 42 ++++- .../sys_preset_templates/e2e-fed-left.json | 16 +- .../sys_preset_templates/e2e-fed-right.json | 16 +- .../sys_preset_templates/e2e-local.json | 6 +- .../e2e-sparse-estimator-test-right.json | 10 +- .../sys-preset-nn-horizontal-eval-model.json | 6 +- .../sys-preset-nn-horizontal-model.json | 6 +- .../sys-preset-nn-model.json | 4 +- .../sys-preset-psi-data-join-analyzer.json | 4 +- .../sys-preset-psi-data-join.json | 8 +- .../sys-preset-tree-model.json | 4 +- .../proto/rpc/v2/job_service.proto | 7 + .../src/components/TodoPopover/index.tsx | 8 +- .../client/src/typings/modelCenter.ts | 11 ++ .../ModelEvaluation/CreateForm/index.tsx | 27 ++- .../ModelEvaluation/ListTable/index.tsx | 45 ++++- .../ModelEvaluationCreate/index.tsx | 4 +- .../ModelEvaluationList/index.tsx | 23 +-- .../ModelCenter/ModelJobDetailDrawer.tsx | 7 +- .../client/src/views/ModelCenter/shared.tsx | 42 +++++ 36 files changed, 609 insertions(+), 152 deletions(-) diff --git a/pp_lite/data_join/psi_rsa/psi_client.py b/pp_lite/data_join/psi_rsa/psi_client.py index 2fd9071f1..3b1a70e87 100644 --- a/pp_lite/data_join/psi_rsa/psi_client.py +++ b/pp_lite/data_join/psi_rsa/psi_client.py @@ -53,7 +53,8 @@ def get_arguments(): arguments['worker_rank'] = int(getenv('INDEX', '0')) if getenv('NUM_WORKERS'): arguments['num_workers'] = int(getenv('NUM_WORKERS')) - if getenv('CLUSTER_SPEC'): + role = getenv('ROLE', '') + if getenv('CLUSTER_SPEC') and role != 'light_client': cluster_spec = json.loads(getenv('CLUSTER_SPEC')) # Only accept CLUSTER_SPEC in cluster environment, diff --git a/pp_lite/proto/arguments.proto b/pp_lite/proto/arguments.proto index 452784b38..bc8d34530 100644 --- a/pp_lite/proto/arguments.proto +++ b/pp_lite/proto/arguments.proto @@ -71,4 +71,6 @@ message TrainerArguments { float client_model_weight = 17; string data_path = 18; string export_path = 19; + // filter out tfrecord files we want to use + string file_wildcard = 20; } \ No newline at end of file diff --git a/pp_lite/test/trainer_test.py b/pp_lite/test/trainer_test.py index 84d48b350..ddb62de40 100755 --- a/pp_lite/test/trainer_test.py +++ b/pp_lite/test/trainer_test.py @@ -14,6 +14,7 @@ # import logging +import numpy as np import time import unittest import json @@ -31,7 +32,6 @@ class IntegratedTest(unittest.TestCase): def test_e2e(self): logging.basicConfig(level=logging.INFO) - cluster_spec_str = json.dumps({ 'master': ['localhost:50101'], 'ps': ['localhost:50102', 'localhost:50104'], @@ -50,6 +50,7 @@ def test_e2e(self): raise TypeError('Input cluster_spec type error') args = TrainerArguments(data_path='pp_lite/trainer/data/', + file_wildcard='**/*', export_path='pp_lite/trainer/model/', server_port=55550, tf_port=51001, @@ -119,16 +120,21 @@ def np_to_tfrecords(X, Y, file_path_prefix): # iterate over each sample, # and serialize it as ProtoBuf. + # temporarily enable eager execution so _bytes_feature can call Tensor.numpy() + tf.enable_eager_execution() for idx in range(X.shape[0]): x = X[idx] y = Y[idx] - - data = {'X': _float_feature(x), 'X_size': _int64_feature(x.shape[0]), 'Y': _int64_feature(y)} - + data = { + 'X': _bytes_feature(tf.serialize_tensor(x.astype(np.float32))), + 'X_size': _int64_feature(x.shape[0]), + 'Y': _int64_feature(y) + } features = tf.train.Features(feature=data) example = tf.train.Example(features=features) serialized = example.SerializeToString() writer.write(serialized) + tf.disable_eager_execution() if __name__ == '__main__': diff --git a/web_console_v2/api/BUILD.bazel b/web_console_v2/api/BUILD.bazel index 3a25ce20f..f20b92936 100644 --- a/web_console_v2/api/BUILD.bazel +++ b/web_console_v2/api/BUILD.bazel @@ -146,10 +146,5 @@ py_binary( "//web_console_v2/api/fedlearner_webconsole/utils:hooks_lib", "@common_flask//:pkg", "@common_gunicorn//:pkg", - ] + select( - { - "//:byted": ["//internal/python:hook_lib"], - "//conditions:default": [], - }, - ), + ], ) diff --git a/web_console_v2/api/cmds/BUILD.bazel b/web_console_v2/api/cmds/BUILD.bazel index 4cac58047..3fae43d8e 100644 --- a/web_console_v2/api/cmds/BUILD.bazel +++ b/web_console_v2/api/cmds/BUILD.bazel @@ -14,12 +14,7 @@ py_binary( # Although `//web_console_v2/api:command_lib` is not directly used in `flask_cli.py`, we have to `deps` it for discovering python dependencies at runtime. "//web_console_v2/api:command_lib", "@common_flask//:pkg", - ] + select( - { - "//:byted": ["//internal/python:hook_lib"], - "//conditions:default": [], - }, - ), + ], ) py_binary( @@ -36,12 +31,7 @@ py_binary( # Although `//web_console_v2/api:server_lib"` is not directly used in `gunicorn_cli.py`, we have to `deps` it for discovering python dependencies at runtime. "//web_console_v2/api:server_lib", "@common_gunicorn//:pkg", - ] + select( - { - "//:byted": ["//internal/python:hook_lib"], - "//conditions:default": [], - }, - ), + ], ) py_binary( @@ -55,12 +45,7 @@ py_binary( main = "supervisorctl_cli.py", deps = [ "@common_supervisor//:pkg", - ] + select( - { - "//:byted": ["//internal/python:hook_lib"], - "//conditions:default": [], - }, - ), + ], ) py_binary( @@ -74,12 +59,7 @@ py_binary( main = "supervisord_cli.py", deps = [ "@common_supervisor//:pkg", - ] + select( - { - "//:byted": ["//internal/python:hook_lib"], - "//conditions:default": [], - }, - ), + ], ) filegroup( diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/controller.py b/web_console_v2/api/fedlearner_webconsole/mmgr/controller.py index 4283f5966..40b2d7275 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/controller.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/controller.py @@ -267,19 +267,46 @@ def create_model_job_group_for_participants(self, model_job_group_id: int): class ModelJobController: - def __init__(self, session: Session): + def __init__(self, session: Session, project_id: int): self._session = session + self._client = [] + self._participants = ParticipantService(self._session).get_participants_by_project(project_id) + self._project_id = project_id + project = self._session.query(Project).get(project_id) + for p in self._participants: + self._client.append(JobServiceClient.from_project_and_participant(p.domain_name, project.name)) - def launch_model_job(self, project_id: int, group_id: int) -> ModelJob: - check_model_job_group(project_id, group_id, self._session) + def launch_model_job(self, group_id: int) -> ModelJob: + check_model_job_group(self._project_id, group_id, self._session) group = ModelJobGroupService(self._session).lock_and_update_version(group_id) self._session.commit() - succeeded, msg = LaunchModelJob().run(project_id=project_id, group_id=group_id, version=group.latest_version) + succeeded, msg = LaunchModelJob().run(project_id=self._project_id, + group_id=group_id, + version=group.latest_version) if not succeeded: raise InternalException(f'launching model job by 2PC with message: {msg}') model_job = self._session.query(ModelJob).filter_by(group_id=group_id, version=group.latest_version).first() return model_job + def inform_auth_status_to_participants(self, model_job: ModelJob): + for client, p in zip(self._client, self._participants): + try: + client.inform_model_job(model_job.uuid, model_job.auth_status) + except grpc.RpcError as e: + logging.warning(f'[model-job] failed to inform participants {p.id}\'s model job ' + f'{model_job.uuid} with grpc code {e.code()} and details {e.details()}') + + def update_participants_auth_status(self, model_job: ModelJob): + participants_info = model_job.get_participants_info() + for client, p in zip(self._client, self._participants): + try: + resp = client.get_model_job(model_job.uuid) + participants_info.participants_map[p.pure_domain_name()].auth_status = resp.auth_status + except grpc.RpcError as e: + logging.warning(f'[model-job] failed to get participant {p.id}\'s model job {model_job.uuid} ' + f'with grpc code {e.code()} and details {e.details()}') + model_job.set_participants_info(participants_info) + # TODO(gezhengqiang): provide start model job rpc def start_model_job(model_job_id: int): diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/controller_test.py b/web_console_v2/api/fedlearner_webconsole/mmgr/controller_test.py index 4e606e567..bd6e26183 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/controller_test.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/controller_test.py @@ -30,14 +30,14 @@ DatasetJobStage, DataBatch from fedlearner_webconsole.initial_db import _insert_or_update_templates from fedlearner_webconsole.mmgr.models import ModelJob, ModelJobGroup, ModelJobType, ModelJobRole, GroupCreateStatus, \ - GroupAutoUpdateStatus + GroupAutoUpdateStatus, AuthStatus as ModelJobAuthStatus from fedlearner_webconsole.mmgr.controller import start_model_job, stop_model_job, ModelJobGroupController, \ ModelJobController from fedlearner_webconsole.project.models import Project from fedlearner_webconsole.proto.algorithm_pb2 import AlgorithmPb -from fedlearner_webconsole.proto.project_pb2 import ParticipantsInfo +from fedlearner_webconsole.proto.project_pb2 import ParticipantsInfo, ParticipantInfo from fedlearner_webconsole.proto.setting_pb2 import SystemInfo -from fedlearner_webconsole.proto.mmgr_pb2 import ModelJobGroupPb, AlgorithmProjectList +from fedlearner_webconsole.proto.mmgr_pb2 import ModelJobGroupPb, AlgorithmProjectList, ModelJobPb from fedlearner_webconsole.proto.common_pb2 import Variable from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition, JobDefinition from fedlearner_webconsole.workflow_template.utils import set_value @@ -277,6 +277,10 @@ def setUp(self): with db.session_scope() as session: _insert_or_update_templates(session) project = Project(id=1, name='test-project') + participant1 = Participant(id=1, name='part1', domain_name='fl-demo1.com') + participant2 = Participant(id=2, name='part2', domain_name='fl-demo2.com') + pro_part1 = ProjectParticipant(id=1, project_id=1, participant_id=1) + pro_part2 = ProjectParticipant(id=2, project_id=1, participant_id=2) dataset_job = DatasetJob(id=1, name='datasetjob', uuid='uuid', @@ -299,8 +303,23 @@ def setUp(self): algorithm_id=2, role=ModelJobRole.COORDINATOR, dataset_id=3) + model_job = ModelJob(id=1, + name='model_job', + uuid='uuid', + project_id=1, + auth_status=ModelJobAuthStatus.AUTHORIZED) + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name), + 'demo1': ParticipantInfo(auth_status=AuthStatus.PENDING.name), + 'demo2': ParticipantInfo(auth_status=AuthStatus.PENDING.name) + }) + model_job.set_participants_info(participants_info) group.set_config(get_workflow_config(ModelJobType.TRAINING)) - session.add_all([dataset_job, dataset, project, group, algorithm]) + session.add_all([ + dataset_job, dataset, project, group, algorithm, participant1, participant2, pro_part1, pro_part2, + model_job + ]) session.commit() @patch('fedlearner_webconsole.two_pc.transaction_manager.TransactionManager._remote_do_two_pc') @@ -309,7 +328,7 @@ def test_launch_model_job(self, mock_remote_do_two_pc): group = session.query(ModelJobGroup).filter_by(uuid='uuid').first() mock_remote_do_two_pc.return_value = True, '' with db.session_scope() as session: - ModelJobController(session).launch_model_job(project_id=1, group_id=1) + ModelJobController(session=session, project_id=1).launch_model_job(group_id=1) group: ModelJobGroup = session.query(ModelJobGroup).filter_by(name='group').first() model_job = group.model_jobs[0] self.assertEqual(model_job.group_id, group.id) @@ -321,6 +340,58 @@ def test_launch_model_job(self, mock_remote_do_two_pc): self.assertTrue(model_job.dataset_id, group.dataset_id) self.assertTrue(model_job.workflow.get_config(), group.get_config()) + @patch('fedlearner_webconsole.rpc.v2.job_service_client.JobServiceClient.inform_model_job') + def test_inform_auth_status_to_participants(self, mock_inform_model_job: MagicMock): + mock_inform_model_job.return_value = Empty() + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + ModelJobController(session, 1).inform_auth_status_to_participants(model_job) + self.assertEqual(mock_inform_model_job.call_args_list, [(('uuid', ModelJobAuthStatus.AUTHORIZED),), + (('uuid', ModelJobAuthStatus.AUTHORIZED),)]) + # fail due to grpc abort + mock_inform_model_job.reset_mock() + mock_inform_model_job.side_effect = FakeRpcError(grpc.StatusCode.NOT_FOUND, 'model job uuid is not found') + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + ModelJobController(session, 1).inform_auth_status_to_participants(model_job) + self.assertEqual(mock_inform_model_job.call_args_list, [(('uuid', ModelJobAuthStatus.AUTHORIZED),), + (('uuid', ModelJobAuthStatus.AUTHORIZED),)]) + + @patch('fedlearner_webconsole.rpc.v2.job_service_client.JobServiceClient.get_model_job') + def test_get_participants_auth_status(self, mock_get_model_job: MagicMock): + mock_get_model_job.side_effect = [ + ModelJobPb(auth_status=AuthStatus.AUTHORIZED.name), + ModelJobPb(auth_status=AuthStatus.AUTHORIZED.name) + ] + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + ModelJobController(session, 1).update_participants_auth_status(model_job) + session.commit() + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name), + 'demo1': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name), + 'demo2': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name) + }) + self.assertEqual(model_job.get_participants_info(), participants_info) + # fail due to grpc abort + mock_get_model_job.side_effect = FakeRpcError(grpc.StatusCode.NOT_FOUND, 'model job uuid is not found') + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + ModelJobController(session, 1).update_participants_auth_status(model_job) + session.commit() + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name), + 'demo1': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name), + 'demo2': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name) + }) + self.assertEqual(model_job.get_participants_info(), participants_info) + if __name__ == '__main__': unittest.main() diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_apis.py b/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_apis.py index 64af9ae98..fa247fe39 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_apis.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_apis.py @@ -56,7 +56,9 @@ from fedlearner_webconsole.workflow_template.models import WorkflowTemplate from fedlearner_webconsole.proto.audit_pb2 import Event from fedlearner_webconsole.proto.mmgr_pb2 import PeerModelJobPb, ModelJobGlobalConfig +from fedlearner_webconsole.proto.project_pb2 import ParticipantsInfo, ParticipantInfo from fedlearner_webconsole.project.models import Project +from fedlearner_webconsole.participant.services import ParticipantService from fedlearner_webconsole.rpc.v2.system_service_client import SystemServiceClient from fedlearner_webconsole.flag.models import Flag @@ -195,6 +197,8 @@ def get(self, project_id: int, model_job_id: int): with db.session_scope() as session: model_job = get_model_job(project_id, model_job_id, session) ModelJobService(session).update_model_job_status(model_job) + ModelJobController(session, project_id).update_participants_auth_status(model_job) + session.commit() return make_flask_response(model_job.to_proto()) @input_validator @@ -254,6 +258,9 @@ def put(self, params: dict, project_id: int, model_job_id: int): ModelJobService(session).config_model_job(model_job, config=config, create_workflow=False) model_job.role = ModelJobRole.PARTICIPANT model_job.creator_username = get_current_user().username + # Compatible with old versions, use PUT for authorization + ModelJobService.update_model_job_auth_status(model_job=model_job, auth_status=AuthStatus.AUTHORIZED) + ModelJobController(session, project_id).inform_auth_status_to_participants(model_job) session.commit() scheduler.wakeup(model_job.workflow_id) @@ -266,10 +273,13 @@ def put(self, params: dict, project_id: int, model_job_id: int): 'metric_is_public': fields.Boolean(required=False, load_default=None), 'auth_status': - fields.String(required=False, load_default=None, validate=validate.OneOf([s.name for s in AuthStatus])) + fields.String(required=False, load_default=None, validate=validate.OneOf([s.name for s in AuthStatus])), + 'comment': + fields.String(required=False, load_default=None) }, location='json') - def patch(self, project_id: int, model_job_id: int, metric_is_public: Optional[bool], auth_status: Optional[str]): + def patch(self, project_id: int, model_job_id: int, metric_is_public: Optional[bool], auth_status: Optional[str], + comment: Optional[str]): """Patch the attribute of model job --- tags: @@ -297,6 +307,8 @@ def patch(self, project_id: int, model_job_id: int, metric_is_public: Optional[b type: boolean auth_status: type: string + comment: + type: string responses: 200: description: detail of the model job @@ -310,7 +322,11 @@ def patch(self, project_id: int, model_job_id: int, metric_is_public: Optional[b if metric_is_public is not None: model_job.metric_is_public = metric_is_public if auth_status is not None: - model_job.auth_status = AuthStatus[auth_status] + ModelJobService.update_model_job_auth_status(model_job=model_job, auth_status=AuthStatus[auth_status]) + ModelJobController(session, project_id).inform_auth_status_to_participants(model_job) + model_job.creator_username = get_current_user().username + if comment is not None: + model_job.comment = comment session.commit() return make_flask_response(model_job.to_proto()) @@ -622,6 +638,20 @@ def get(self, project_id: int, group_id: Optional[int], keyword: Optional[str], items: $ref: '#/definitions/fedlearner_webconsole.proto.ModelJobRef' """ + # update auth_status and participants_info of old data + with db.session_scope() as session: + model_jobs = session.query(ModelJob).filter_by(participants_info=None, project_id=project_id).all() + if model_jobs is not None: + participants = ParticipantService(session).get_participants_by_project(project_id) + participants_info = ParticipantsInfo(participants_map={ + p.pure_domain_name(): ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name) for p in participants + }) + pure_domain_name = SettingService.get_system_info().pure_domain_name + participants_info.participants_map[pure_domain_name].auth_status = AuthStatus.AUTHORIZED.name + for model_job in model_jobs: + model_job.auth_status = AuthStatus.AUTHORIZED + model_job.set_participants_info(participants_info) + session.commit() with db.session_scope() as session: query = session.query(ModelJob) if project_id: @@ -660,6 +690,7 @@ def get(self, project_id: int, group_id: Optional[int], keyword: Optional[str], if states is not None: model_jobs = [m for m in model_jobs if m.state in states] data = [m.to_ref() for m in model_jobs] + session.commit() return make_flask_response(data=data, page_meta=pagination.get_metadata()) @input_validator @@ -715,7 +746,7 @@ def post(self, params: dict, project_id: int): # model job type is TRAINING if model_job_type in [ModelJobType.TRAINING]: with db.session_scope() as session: - model_job = ModelJobController(session).launch_model_job(project_id=project_id, group_id=group_id) + model_job = ModelJobController(session, project_id).launch_model_job(group_id=group_id) session.commit() return make_flask_response(model_job.to_proto(), status=HTTPStatus.CREATED) # model job type is EVALUATION or PREDICTION @@ -766,6 +797,7 @@ def post(self, params: dict, project_id: int): data_batch_id=data_batch_id, comment=comment, version=version) + model_job.creator_username = get_current_user().username if group_id and data_batch_id is not None: group.auto_update_status = GroupAutoUpdateStatus.ACTIVE group.start_data_batch_id = data_batch_id @@ -912,7 +944,7 @@ def post(self, project_id: int, group_id: int): description: error exists when launching model job by 2PC """ with db.session_scope() as session: - model_job = ModelJobController(session).launch_model_job(project_id=project_id, group_id=group_id) + model_job = ModelJobController(session, project_id).launch_model_job(group_id=group_id) return make_flask_response(model_job.to_proto(), status=HTTPStatus.CREATED) diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_apis_test.py b/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_apis_test.py index 04cdcbe9f..ab7d7e3cc 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_apis_test.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_apis_test.py @@ -24,7 +24,6 @@ from http import HTTPStatus from datetime import datetime from unittest.mock import patch, Mock, MagicMock, call - from envs import Envs from testing.common import BaseTestCase from testing.fake_model_job_config import get_global_config, get_workflow_config @@ -46,8 +45,9 @@ from fedlearner_webconsole.proto.common_pb2 import Variable from fedlearner_webconsole.proto.workflow_definition_pb2 import WorkflowDefinition, JobDefinition from fedlearner_webconsole.proto.service_pb2 import GetModelJobResponse -from fedlearner_webconsole.proto.project_pb2 import ParticipantsInfo -from fedlearner_webconsole.proto.mmgr_pb2 import ModelJobGlobalConfig, ModelJobConfig +from fedlearner_webconsole.proto.setting_pb2 import SystemInfo +from fedlearner_webconsole.proto.project_pb2 import ParticipantsInfo, ParticipantInfo +from fedlearner_webconsole.proto.mmgr_pb2 import ModelJobGlobalConfig, ModelJobConfig, ModelJobPb from fedlearner_webconsole.workflow.models import WorkflowState, TransactionState from fedlearner_webconsole.dataset.models import Dataset, DatasetJob, DatasetJobKind, DatasetType, DatasetJobState, \ DatasetJobStage, DataBatch @@ -92,6 +92,7 @@ def setUp(self): pro_participant = ProjectParticipant(id=1, project_id=1, participant_id=1) group = ModelJobGroup(id=1, name='test-group', project_id=project.id, uuid='uuid', latest_version=2) session.add_all([project, group, dataset, dataset_job, data_batch, dataset_job_stage]) + participants_info = ParticipantsInfo() w1 = Workflow(name='w1', uuid='u1', state=WorkflowState.NEW, @@ -106,6 +107,7 @@ def setUp(self): role=ModelJobRole.COORDINATOR, auth_status=AuthStatus.PENDING, created_at=datetime(2022, 8, 4, 0, 0, 0)) + mj1.set_participants_info(participants_info) w2 = Workflow(name='w2', uuid='u2', state=WorkflowState.READY, target_state=None) w2.set_config(get_workflow_config(model_job_type=ModelJobType.EVALUATION)) mj2 = ModelJob(name='mj2', @@ -117,26 +119,29 @@ def setUp(self): role=ModelJobRole.PARTICIPANT, auth_status=AuthStatus.AUTHORIZED, created_at=datetime(2022, 8, 4, 0, 0, 1)) + mj2.set_participants_info(participants_info) w3 = Workflow(name='w3', uuid='u3', state=WorkflowState.RUNNING, target_state=None) w3.set_config(get_workflow_config(model_job_type=ModelJobType.PREDICTION)) mj3 = ModelJob(name='mj3', workflow_uuid=w3.uuid, - project_id=2, + project_id=1, algorithm_type=AlgorithmType.NN_HORIZONTAL, model_job_type=ModelJobType.PREDICTION, role=ModelJobRole.COORDINATOR, auth_status=AuthStatus.PENDING, created_at=datetime(2022, 8, 4, 0, 0, 2)) + mj3.set_participants_info(participants_info) w4 = Workflow(name='w4', uuid='u4', state=WorkflowState.RUNNING, target_state=None) w4.set_config(get_workflow_config(model_job_type=ModelJobType.PREDICTION)) mj4 = ModelJob(name='mj31', workflow_uuid=w4.uuid, - project_id=2, + project_id=1, algorithm_type=AlgorithmType.TREE_VERTICAL, model_job_type=ModelJobType.PREDICTION, role=ModelJobRole.PARTICIPANT, auth_status=AuthStatus.AUTHORIZED, created_at=datetime(2022, 8, 4, 0, 0, 3)) + mj4.set_participants_info(participants_info) w5 = Workflow(name='w5', uuid='u5', state=WorkflowState.COMPLETED, target_state=None) mj5 = ModelJob(id=123, project_id=1, @@ -145,16 +150,25 @@ def setUp(self): role=ModelJobRole.COORDINATOR, auth_status=AuthStatus.PENDING, created_at=datetime(2022, 8, 4, 0, 0, 4)) + mj5.set_participants_info(participants_info) + mj6 = ModelJob(id=124, + project_id=2, + name='mj6', + workflow_uuid=w5.uuid, + role=ModelJobRole.COORDINATOR, + auth_status=AuthStatus.PENDING, + created_at=datetime(2022, 8, 4, 0, 0, 4)) + mj5.set_participants_info(participants_info) model = Model(id=12, name='test', model_job_id=123, group_id=1, uuid='model-uuid', project_id=1) - session.add_all([w1, w2, w3, mj1, mj2, mj3, w4, mj4, w5, mj5, model, participant, pro_participant]) + session.add_all([w1, w2, w3, mj1, mj2, mj3, w4, mj4, w5, mj5, mj6, model, participant, pro_participant]) session.commit() def test_get_model_jobs_by_project_or_group(self): - resp = self.get_helper('/api/v2/projects/1/model_jobs') + resp = self.get_helper('/api/v2/projects/2/model_jobs') self.assertEqual(resp.status_code, HTTPStatus.OK) data = self.get_response_data(resp) model_job_names = sorted([d['name'] for d in data]) - self.assertEqual(model_job_names, ['mj1', 'mj2', 'mj5']) + self.assertEqual(model_job_names, ['mj6']) resp = self.get_helper('/api/v2/projects/1/model_jobs?group_id=2') data = self.get_response_data(resp) model_job_names = sorted([d['name'] for d in data]) @@ -172,24 +186,24 @@ def test_get_model_jobs_by_algorithm_types(self): '/api/v2/projects/1/model_jobs?algorithm_types=NN_VERTICAL&&algorithm_types=TREE_VERTICAL') data = self.get_response_data(resp) model_job_names = sorted([d['name'] for d in data]) - self.assertEqual(model_job_names, ['mj1', 'mj2']) - resp = self.get_helper('/api/v2/projects/2/model_jobs?algorithm_types=NN_HORIZONTAL') + self.assertEqual(model_job_names, ['mj1', 'mj2', 'mj31']) + resp = self.get_helper('/api/v2/projects/1/model_jobs?algorithm_types=NN_HORIZONTAL') data = self.get_response_data(resp) model_job_names = sorted([d['name'] for d in data]) self.assertEqual(model_job_names, ['mj3']) def test_get_model_jobs_by_states(self): - resp = self.get_helper('/api/v2/projects/0/model_jobs?states=PENDING_ACCEPT') + resp = self.get_helper('/api/v2/projects/1/model_jobs?states=PENDING_ACCEPT') data = self.get_response_data(resp) model_job_names = sorted([d['name'] for d in data]) self.assertEqual(model_job_names, ['mj1']) - resp = self.get_helper('/api/v2/projects/0/model_jobs?states=RUNNING&states=READY_TO_RUN') + resp = self.get_helper('/api/v2/projects/1/model_jobs?states=RUNNING&states=READY_TO_RUN') data = self.get_response_data(resp) model_job_names = sorted([d['name'] for d in data]) self.assertEqual(model_job_names, ['mj2', 'mj3', 'mj31']) def test_get_model_jobs_by_keyword(self): - resp = self.get_helper('/api/v2/projects/2/model_jobs?keyword=mj3') + resp = self.get_helper('/api/v2/projects/1/model_jobs?keyword=mj3') data = self.get_response_data(resp) model_job_names = sorted([d['name'] for d in data]) self.assertEqual(model_job_names, ['mj3', 'mj31']) @@ -198,45 +212,45 @@ def test_get_model_jobs_by_configured(self): resp = self.get_helper('/api/v2/projects/1/model_jobs?configured=false') data = self.get_response_data(resp) self.assertEqual(sorted([d['name'] for d in data]), ['mj1', 'mj5']) - resp = self.get_helper('/api/v2/projects/0/model_jobs?configured=true') + resp = self.get_helper('/api/v2/projects/1/model_jobs?configured=true') data = self.get_response_data(resp) self.assertEqual(sorted([d['name'] for d in data]), ['mj2', 'mj3', 'mj31']) def test_get_model_jobs_by_expression(self): filter_param = urllib.parse.quote('(algorithm_type:["NN_VERTICAL","TREE_VERTICAL"])') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?filter={filter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?filter={filter_param}') data = self.get_response_data(resp) self.assertEqual(sorted([d['name'] for d in data]), ['mj1', 'mj2', 'mj31']) filter_param = urllib.parse.quote('(algorithm_type:["NN_HORIZONTAL"])') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?filter={filter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?filter={filter_param}') data = self.get_response_data(resp) self.assertEqual(sorted(d['name'] for d in data), ['mj3']) filter_param = urllib.parse.quote('(role:["COORDINATOR"])') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?filter={filter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?filter={filter_param}') data = self.get_response_data(resp) self.assertEqual(sorted(d['name'] for d in data), ['mj1', 'mj3', 'mj5']) filter_param = urllib.parse.quote('(name~="1")') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?filter={filter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?filter={filter_param}') data = self.get_response_data(resp) self.assertEqual(sorted(d['name'] for d in data), ['mj1', 'mj31']) filter_param = urllib.parse.quote('(model_job_type:["TRAINING","EVALUATION"])') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?filter={filter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?filter={filter_param}') data = self.get_response_data(resp) self.assertEqual(sorted(d['name'] for d in data), ['mj1', 'mj2']) filter_param = urllib.parse.quote('(status:["RUNNING"])') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?filter={filter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?filter={filter_param}') data = self.get_response_data(resp) self.assertEqual(sorted(d['name'] for d in data), ['mj3', 'mj31']) filter_param = urllib.parse.quote('(configured=true)') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?filter={filter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?filter={filter_param}') data = self.get_response_data(resp) self.assertEqual(sorted(d['name'] for d in data), ['mj2', 'mj3', 'mj31']) filter_param = urllib.parse.quote('(auth_status:["AUTHORIZED"])') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?filter={filter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?filter={filter_param}') data = self.get_response_data(resp) self.assertEqual(sorted(d['name'] for d in data), ['mj2', 'mj31']) sorter_param = urllib.parse.quote('created_at asc') - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?order_by={sorter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?order_by={sorter_param}') data = self.get_response_data(resp) self.assertEqual([d['name'] for d in data], ['mj1', 'mj2', 'mj3', 'mj31', 'mj5']) self.assertEqual(data[0]['status'], ModelJobStatus.PENDING.name) @@ -244,10 +258,36 @@ def test_get_model_jobs_by_expression(self): self.assertEqual(data[2]['status'], ModelJobStatus.RUNNING.name) self.assertEqual(data[3]['status'], ModelJobStatus.RUNNING.name) self.assertEqual(data[4]['status'], ModelJobStatus.SUCCEEDED.name) - resp = self.get_helper(f'/api/v2/projects/0/model_jobs?page=2&page_size=2&order_by={sorter_param}') + resp = self.get_helper(f'/api/v2/projects/1/model_jobs?page=2&page_size=2&order_by={sorter_param}') data = self.get_response_data(resp) self.assertEqual(sorted(d['name'] for d in data), ['mj3', 'mj31']) + @patch('fedlearner_webconsole.project.services.SettingService.get_system_info') + def test_update_auth_status_of_old_data(self, mock_get_system_info): + mock_get_system_info.return_value = SystemInfo(pure_domain_name='test') + with db.session_scope() as session: + project = Project(id=3, name='project2') + participant = Participant(id=3, name='peer2', domain_name='fl-peer2.com') + pro_participant = ProjectParticipant(id=2, project_id=3, participant_id=3) + model_job6 = ModelJob(id=6, project_id=3, name='j6', participants_info=None) + model_job7 = ModelJob(id=7, project_id=3, name='j7', participants_info=None) + session.add_all([project, participant, pro_participant, model_job6, model_job7]) + session.commit() + resp = self.get_helper('/api/v2/projects/3/model_jobs') + self.assertEqual(resp.status_code, HTTPStatus.OK) + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name), + 'peer2': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name) + }) + with db.session_scope() as session: + model_job6 = session.query(ModelJob).get(6) + self.assertEqual(model_job6.auth_status, AuthStatus.AUTHORIZED) + self.assertEqual(model_job6.get_participants_info(), participants_info) + model_job7 = session.query(ModelJob).get(7) + self.assertEqual(model_job7.auth_status, AuthStatus.AUTHORIZED) + self.assertEqual(model_job7.get_participants_info(), participants_info) + @patch('fedlearner_webconsole.rpc.v2.system_service_client.SystemServiceClient.list_flags') @patch('fedlearner_webconsole.two_pc.model_job_creator.ModelJobCreator.prepare') @patch('fedlearner_webconsole.two_pc.transaction_manager.TransactionManager._remote_do_two_pc') @@ -349,6 +389,7 @@ def test_post_model_jobs_with_global_config(self, mock_create_model_job, mock_li self.assertEqual(model_job.get_global_config(), get_global_config()) self.assertEqual(model_job.comment, 'comment') self.assertEqual(model_job.status, ModelJobStatus.PENDING) + self.assertEqual(model_job.creator_username, 'ada') mock_list_flags.return_value = {'model_job_global_config_enabled': True} resp = self.post_helper('/api/v2/projects/1/model_jobs', data={ @@ -561,6 +602,8 @@ def setUp(self): Envs.SYSTEM_INFO = '{"domain_name": "fl-test.com"}' with db.session_scope() as session: project = Project(id=1, name='test-project') + participant = Participant(id=1, name='part', domain_name='fl-demo1.com') + pro_part = ProjectParticipant(id=1, project_id=1, participant_id=1) group = ModelJobGroup(id=1, name='test-group', project_id=project.id, uuid='uuid') workflow_uuid = 'uuid' workflow = Workflow(id=1, @@ -595,10 +638,18 @@ def setUp(self): job_id=2, job_name='uuid-train-job', created_at=datetime(2022, 5, 10, 0, 0, 0)) - session.add_all([project, group, workflow, model_job, dataset, dataset_job]) + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.PENDING.name), + 'demo1': ParticipantInfo(auth_status=AuthStatus.PENDING.name) + }) + model_job.set_participants_info(participants_info) + session.add_all([project, group, workflow, model_job, dataset, dataset_job, participant, pro_part]) session.commit() - def test_get_model_job(self): + @patch('fedlearner_webconsole.rpc.v2.job_service_client.JobServiceClient.get_model_job') + def test_get_model_job(self, mock_get_model_job): + mock_get_model_job.side_effect = [ModelJobPb(auth_status=AuthStatus.AUTHORIZED.name)] with db.session_scope() as session: workflow: Workflow = session.query(Workflow).filter_by(uuid='uuid').first() config = get_workflow_config(model_job_type=ModelJobType.TRAINING) @@ -654,13 +705,35 @@ def test_get_model_job(self): 'metric_is_public': False, 'auth_frontend_status': 'SELF_AUTH_PENDING', 'participants_info': { - 'participants_map': {} + 'participants_map': { + 'demo1': { + 'auth_status': 'AUTHORIZED', + 'name': '', + 'role': '', + 'state': '', + 'type': '' + }, + 'test': { + 'auth_status': 'PENDING', + 'name': '', + 'role': '', + 'state': '', + 'type': '' + } + } } }, ignore_fields=['config', 'output_models', 'updated_at', 'data_batch_id']) + @patch('fedlearner_webconsole.rpc.v2.job_service_client.JobServiceClient.inform_model_job') + @patch('fedlearner_webconsole.project.services.SettingService.get_system_info') @patch('fedlearner_webconsole.scheduler.scheduler.Scheduler.wakeup') - def test_put_model_job(self, mock_wake_up): + def test_put_model_job(self, mock_wake_up, mock_get_system_info, mock_inform_model_job): + mock_get_system_info.return_value = SystemInfo(pure_domain_name='test') + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + model_job.uuid = 'uuid' + session.commit() config = get_workflow_config(ModelJobType.TRAINING) data = {'algorithm_id': 1, 'config': to_dict(config)} resp = self.put_helper('/api/v2/projects/1/model_jobs/1', data=data) @@ -685,9 +758,29 @@ def test_put_model_job(self, mock_wake_up): ], yaml_template='{}') ])) + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name), + 'demo1': ParticipantInfo(auth_status=AuthStatus.PENDING.name) + }) + self.assertEqual(model_job.get_participants_info(), participants_info) + self.assertEqual(mock_inform_model_job.call_args_list, [(('uuid', AuthStatus.AUTHORIZED),)]) mock_wake_up.assert_called_with(model_job.workflow_id) - def test_patch_model_job(self): + @patch('fedlearner_webconsole.project.services.SettingService.get_system_info') + @patch('fedlearner_webconsole.rpc.v2.job_service_client.JobServiceClient.inform_model_job') + def test_patch_model_job(self, mock_inform_model_job, mock_get_system_info): + mock_get_system_info.return_value = SystemInfo(pure_domain_name='test') + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + model_job.uuid = 'uuid' + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.PENDING.name), + 'demo1': ParticipantInfo(auth_status=AuthStatus.PENDING.name) + }) + model_job.set_participants_info(participants_info) + session.commit() resp = self.patch_helper('/api/v2/projects/1/model_jobs/1', data={ 'metric_is_public': False, @@ -697,13 +790,24 @@ def test_patch_model_job(self): resp = self.patch_helper('/api/v2/projects/1/model_jobs/1', data={ 'metric_is_public': False, - 'auth_status': 'PENDING' + 'auth_status': 'PENDING', + 'comment': 'hahahaha' }) self.assertEqual(resp.status_code, HTTPStatus.OK) with db.session_scope() as session: model_job = session.query(ModelJob).get(1) self.assertFalse(model_job.metric_is_public) self.assertEqual(model_job.auth_status, AuthStatus.PENDING) + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.PENDING.name), + 'demo1': ParticipantInfo(auth_status=AuthStatus.PENDING.name) + }) + self.assertEqual(model_job.get_participants_info(), participants_info) + self.assertEqual(mock_inform_model_job.call_args_list, [(('uuid', AuthStatus.PENDING),)]) + self.assertEqual(model_job.creator_username, 'ada') + self.assertEqual(model_job.comment, 'hahahaha') + mock_inform_model_job.reset_mock() self.patch_helper('/api/v2/projects/1/model_jobs/1', data={ 'metric_is_public': True, @@ -713,6 +817,13 @@ def test_patch_model_job(self): model_job = session.query(ModelJob).get(1) self.assertTrue(model_job.metric_is_public) self.assertEqual(model_job.auth_status, AuthStatus.AUTHORIZED) + participants_info = ParticipantsInfo( + participants_map={ + 'test': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name), + 'demo1': ParticipantInfo(auth_status=AuthStatus.PENDING.name) + }) + self.assertEqual(model_job.get_participants_info(), participants_info) + self.assertEqual(mock_inform_model_job.call_args_list, [(('uuid', AuthStatus.AUTHORIZED),)]) @patch('fedlearner_webconsole.mmgr.model_job_configer.ModelJobConfiger.get_config') def test_put_model_job_with_global_config(self, mock_get_config): diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_group_apis.py b/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_group_apis.py index 6ff99a55b..83f2daa19 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_group_apis.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/model_job_group_apis.py @@ -223,6 +223,7 @@ def get( if len(group.model_jobs) != 0: ModelJobService(session).update_model_job_status(group.model_jobs[0]) data = [d.to_ref() for d in pagination.get_items()] + session.commit() return make_flask_response(data=data, page_meta=pagination.get_metadata()) @input_validator diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/scheduler.py b/web_console_v2/api/fedlearner_webconsole/mmgr/scheduler.py index 8797900e3..05f36cf7f 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/scheduler.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/scheduler.py @@ -40,6 +40,7 @@ def _check_model_job(model_job_id: int): with db.session_scope() as session: model_job = session.query(ModelJob).get(model_job_id) ModelJobService(session).update_model_job_status(model_job) + session.commit() logging.info(f'[ModelJobScheduler] model_job {model_job.name} updates status to {model_job.status}') @staticmethod @@ -50,6 +51,7 @@ def _config_model_job(model_job_id: int): global_config = model_job.get_global_config() if global_config is None: ModelJobService(session).update_model_job_status(model_job) + session.commit() return domain_name = SettingService(session).get_system_info().pure_domain_name model_job_config: ModelJobConfig = global_config.global_config.get(domain_name) diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/service.py b/web_console_v2/api/fedlearner_webconsole/mmgr/service.py index 59fc1ddff..55dcc175d 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/service.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/service.py @@ -295,7 +295,6 @@ def update_model_job_status(self, model_job: ModelJob): model_job.status = ModelJobStatus.SUCCEEDED if workflow.state in [WorkflowState.FAILED]: model_job.status = ModelJobStatus.FAILED - self._session.commit() def initialize_auth_status(self, model_job: ModelJob): pure_domain_name = SettingService(self._session).get_system_info().pure_domain_name @@ -320,6 +319,14 @@ def initialize_auth_status(self, model_job: ModelJob): model_job.auth_status = AuthStatus.AUTHORIZED model_job.set_participants_info(participants_info) + @staticmethod + def update_model_job_auth_status(model_job: ModelJob, auth_status: AuthStatus): + model_job.auth_status = auth_status + participants_info = model_job.get_participants_info() + pure_domain_name = SettingService.get_system_info().pure_domain_name + participants_info.participants_map[pure_domain_name].auth_status = auth_status.name + model_job.set_participants_info(participants_info) + def delete(self, job_id: int): model_job: ModelJob = self._session.query(ModelJob).get(job_id) model_job.deleted_at = now() diff --git a/web_console_v2/api/fedlearner_webconsole/mmgr/service_test.py b/web_console_v2/api/fedlearner_webconsole/mmgr/service_test.py index b65dea2db..0bcd6fe32 100644 --- a/web_console_v2/api/fedlearner_webconsole/mmgr/service_test.py +++ b/web_console_v2/api/fedlearner_webconsole/mmgr/service_test.py @@ -517,6 +517,28 @@ def test_initialize_auth_status(self, mock_system_info): 'demo2': ParticipantInfo(auth_status=AuthStatus.PENDING.name) })) + @patch('fedlearner_webconsole.project.services.SettingService.get_system_info') + def test_update_model_job_auth_status(self, mock_get_system_info): + mock_get_system_info.return_value = SystemInfo(pure_domain_name='test') + with db.session_scope() as session: + model_job = session.query(ModelJob).filter_by(name='test-model-job').first() + ModelJobService.update_model_job_auth_status(model_job, AuthStatus.AUTHORIZED) + session.commit() + with db.session_scope() as session: + model_job = session.query(ModelJob).filter_by(name='test-model-job').first() + self.assertEqual(model_job.auth_status, AuthStatus.AUTHORIZED) + self.assertEqual( + model_job.get_participants_info(), + ParticipantsInfo(participants_map={'test': ParticipantInfo(auth_status=AuthStatus.AUTHORIZED.name)})) + ModelJobService.update_model_job_auth_status(model_job, AuthStatus.PENDING) + session.commit() + with db.session_scope() as session: + model_job = session.query(ModelJob).filter_by(name='test-model-job').first() + self.assertEqual(model_job.auth_status, AuthStatus.PENDING) + self.assertEqual( + model_job.get_participants_info(), + ParticipantsInfo(participants_map={'test': ParticipantInfo(auth_status=AuthStatus.PENDING.name)})) + @patch('fedlearner_webconsole.rpc.v2.job_service_client.JobServiceClient.create_model_job') @patch('fedlearner_webconsole.setting.service.SettingService.get_system_info') def test_create_auto_update_model_job(self, mock_get_system_info, mock_create_model_job): diff --git a/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_client.py b/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_client.py index cacaeff61..64e414add 100644 --- a/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_client.py +++ b/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_client.py @@ -32,7 +32,7 @@ GetTrustedJobGroupResponse, CreateDatasetJobStageRequest, GetDatasetJobStageRequest, GetDatasetJobStageResponse, \ CreateModelJobGroupRequest, GetModelJobRequest, GetModelJobGroupRequest, InformModelJobGroupRequest, \ InformTrustedJobRequest, GetTrustedJobRequest, GetTrustedJobResponse, CreateTrustedExportJobRequest, \ - UpdateDatasetJobSchedulerStateRequest, UpdateModelJobGroupRequest + UpdateDatasetJobSchedulerStateRequest, UpdateModelJobGroupRequest, InformModelJobRequest def _need_retry_for_get(err: Exception) -> bool: @@ -106,6 +106,11 @@ def create_model_job(self, name: str, uuid: str, group_uuid: str, model_job_type version=version) return self._stub.CreateModelJob(request=request, timeout=Envs.GRPC_CLIENT_TIMEOUT) + @retry_fn(retry_times=3, need_retry=_default_need_retry) + def inform_model_job(self, uuid: str, auth_status: AuthStatus) -> empty_pb2.Empty: + msg = InformModelJobRequest(uuid=uuid, auth_status=auth_status.name) + return self._stub.InformModelJob(request=msg, timeout=Envs.GRPC_CLIENT_TIMEOUT) + @retry_fn(retry_times=3, need_retry=_need_retry_for_get) def get_model_job_group(self, uuid: str) -> ModelJobGroupPb: return self._stub.GetModelJobGroup(request=GetModelJobGroupRequest(uuid=uuid), timeout=Envs.GRPC_CLIENT_TIMEOUT) diff --git a/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_client_test.py b/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_client_test.py index aab286c02..d077dccfa 100644 --- a/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_client_test.py +++ b/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_client_test.py @@ -35,7 +35,7 @@ GetTrustedJobGroupResponse, CreateDatasetJobStageRequest, GetDatasetJobStageRequest, GetDatasetJobStageResponse, \ CreateModelJobGroupRequest, GetModelJobRequest, GetModelJobGroupRequest, InformModelJobGroupRequest, \ InformTrustedJobRequest, GetTrustedJobRequest, GetTrustedJobResponse, CreateTrustedExportJobRequest, \ - UpdateDatasetJobSchedulerStateRequest, UpdateModelJobGroupRequest + UpdateDatasetJobSchedulerStateRequest, UpdateModelJobGroupRequest, InformModelJobRequest _SERVICE_DESCRIPTOR: ServiceDescriptor = job_service_pb2.DESCRIPTOR.services_by_name['JobService'] @@ -165,6 +165,22 @@ def test_create_model_job(self): version=3)) self.assertEqual(call.result(), expected_response) + def test_inform_model_job(self): + call = self.client_execution_pool.submit(self._client.inform_model_job, + uuid='uuid', + auth_status=AuthStatus.AUTHORIZED) + invocation_metadata, request, rpc = self._fake_channel.take_unary_unary( + _SERVICE_DESCRIPTOR.methods_by_name['InformModelJob']) + expected_response = Empty() + rpc.terminate( + response=expected_response, + code=grpc.StatusCode.OK, + trailing_metadata=(), + details=None, + ) + self.assertEqual(request, InformModelJobRequest(uuid='uuid', auth_status=AuthStatus.AUTHORIZED.name)) + self.assertEqual(call.result(), expected_response) + def test_create_dataset_job_stage(self): event_time = datetime(2022, 1, 1) call = self.client_execution_pool.submit(self._client.create_dataset_job_stage, diff --git a/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_server.py b/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_server.py index 01259e6b4..096034549 100644 --- a/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_server.py +++ b/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_server.py @@ -26,7 +26,7 @@ GetTrustedJobGroupResponse, CreateDatasetJobStageRequest, GetDatasetJobStageRequest, GetDatasetJobStageResponse, \ CreateModelJobGroupRequest, GetModelJobGroupRequest, GetModelJobRequest, InformModelJobGroupRequest, \ InformTrustedJobRequest, GetTrustedJobRequest, GetTrustedJobResponse, CreateTrustedExportJobRequest, \ - UpdateDatasetJobSchedulerStateRequest, UpdateModelJobGroupRequest + UpdateDatasetJobSchedulerStateRequest, UpdateModelJobGroupRequest, InformModelJobRequest from fedlearner_webconsole.proto.mmgr_pb2 import ModelJobPb, ModelJobGroupPb from fedlearner_webconsole.participant.models import Participant from fedlearner_webconsole.tee.services import TrustedJobGroupService, TrustedJobService @@ -175,6 +175,27 @@ def CreateModelJob(self, request: CreateModelJobRequest, context: ServicerContex session.commit() return empty_pb2.Empty() + @emits_rpc_event(resource_type=Event.ResourceType.MODEL_JOB, + op_type=Event.OperationType.INFORM, + resource_name_fn=lambda request: request.uuid) + def InformModelJob(self, request: InformModelJobRequest, context: ServicerContext) -> empty_pb2.Empty: + with db.session_scope() as session: + project_id, client_id = get_grpc_context_info(session, context) + model_job: ModelJob = session.query(ModelJob).populate_existing().with_for_update().filter_by( + project_id=project_id, uuid=request.uuid).first() + if model_job is None: + context.abort(grpc.StatusCode.NOT_FOUND, f'model job {request.uuid} is not found') + try: + auth_status = AuthStatus[request.auth_status] + except KeyError: + context.abort(grpc.StatusCode.INVALID_ARGUMENT, f'auth_status {request.auth_status} is invalid') + pure_domain_name = session.query(Participant).get(client_id).pure_domain_name() + participants_info = model_job.get_participants_info() + participants_info.participants_map[pure_domain_name].auth_status = auth_status.name + model_job.set_participants_info(participants_info) + session.commit() + return empty_pb2.Empty() + @emits_rpc_event(resource_type=Event.ResourceType.DATASET_JOB_STAGE, op_type=Event.OperationType.CREATE, resource_name_fn=lambda request: request.dataset_job_uuid) diff --git a/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_server_test.py b/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_server_test.py index 6cf1ffff6..0984fb3da 100644 --- a/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_server_test.py +++ b/web_console_v2/api/fedlearner_webconsole/rpc/v2/job_service_server_test.py @@ -47,7 +47,7 @@ GetTrustedJobGroupResponse, CreateDatasetJobStageRequest, GetDatasetJobStageRequest, CreateModelJobGroupRequest, \ GetModelJobRequest, GetModelJobGroupRequest, InformModelJobGroupRequest, InformTrustedJobRequest, \ GetTrustedJobRequest, GetTrustedJobResponse, CreateTrustedExportJobRequest, UpdateDatasetJobSchedulerStateRequest, \ - UpdateModelJobGroupRequest + UpdateModelJobGroupRequest, InformModelJobRequest from fedlearner_webconsole.review.common import NO_CENTRAL_SERVER_UUID @@ -408,6 +408,44 @@ def test_create_model_job(self, mock_get_grpc_context_info: MagicMock, mock_crea global_config=global_config, version=4) + @patch('fedlearner_webconsole.rpc.v2.job_service_server.get_grpc_context_info') + def test_inform_model_job(self, mock_get_grpc_context_info: MagicMock): + mock_get_grpc_context_info.return_value = 1, 1 + with db.session_scope() as session: + project = Project(id=1, name='project') + participant = Participant(id=1, name='part1', domain_name='fl-demo1.com') + pro_part = ProjectParticipant(id=1, project_id=1, participant_id=1) + model_job = ModelJob(id=1, + name='model_job', + uuid='uuid', + project_id=1, + auth_status=ModelAuthStatus.AUTHORIZED) + participants_info = ParticipantsInfo() + participants_info.participants_map['demo1'].auth_status = AuthStatus.PENDING.name + model_job.set_participants_info(participants_info) + session.add_all([project, participant, pro_part, model_job]) + session.commit() + self._stub.InformModelJob(InformModelJobRequest(uuid='uuid', auth_status=AuthStatus.AUTHORIZED.name)) + # authorized + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + participants_info = model_job.get_participants_info() + self.assertEqual(participants_info.participants_map['demo1'].auth_status, AuthStatus.AUTHORIZED.name) + # pending + self._stub.InformModelJob(InformModelJobRequest(uuid='uuid', auth_status=AuthStatus.PENDING.name)) + with db.session_scope() as session: + model_job = session.query(ModelJob).get(1) + participants_info = model_job.get_participants_info() + self.assertEqual(participants_info.participants_map['demo1'].auth_status, AuthStatus.PENDING.name) + # fail due to model job not found + with self.assertRaises(grpc.RpcError) as cm: + self._stub.InformModelJob(InformModelJobRequest(uuid='uuid1', auth_status=AuthStatus.PENDING.name)) + self.assertEqual(cm.exception.code(), grpc.StatusCode.NOT_FOUND) + # fail due to auth_status invalid + with self.assertRaises(grpc.RpcError) as cm: + self._stub.InformModelJob(InformModelJobRequest(uuid='uuid', auth_status='aaaaa')) + self.assertEqual(cm.exception.code(), grpc.StatusCode.INVALID_ARGUMENT) + @patch('fedlearner_webconsole.dataset.models.DataBatch.is_available') @patch('fedlearner_webconsole.rpc.v2.job_service_server.get_grpc_context_info') def test_create_dataset_job_stage(self, mock_get_grpc_context_info: MagicMock, mock_is_available: MagicMock): @@ -703,7 +741,7 @@ def test_inform_model_job_group(self, mock_get_grpc_context_info: MagicMock): with db.session_scope() as session: group = session.query(ModelJobGroup).get(1) participants_info = group.get_participants_info() - self.assertEqual(participants_info.participants_map['demo2'].auth_status, AuthStatus.PENDING.name) + self.assertEqual(participants_info.participants_map['demo1'].auth_status, AuthStatus.PENDING.name) # fail due to group not found with self.assertRaises(grpc.RpcError) as cm: self._stub.InformModelJobGroup( diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-fed-left.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-fed-left.json index 25e9e18d6..0165805ab 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-fed-left.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-fed-left.json @@ -178,8 +178,8 @@ "access_mode": "PEER_WRITABLE", "name": "image", "tag": "", - "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", - "value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", + "value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "value_type": "STRING", "widget_schema": "{\"component\":\"Input\",\"required\":true}" }, @@ -300,7 +300,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -561,7 +561,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模板不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -921,7 +921,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -1227,7 +1227,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -1479,7 +1479,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -1740,7 +1740,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-fed-right.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-fed-right.json index 3834d31cd..42d0fa7fc 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-fed-right.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-fed-right.json @@ -178,8 +178,8 @@ "access_mode": "PEER_WRITABLE", "name": "image", "tag": "", - "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", - "value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", + "value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "value_type": "STRING", "widget_schema": "{\"component\":\"Input\",\"required\":true}" }, @@ -300,7 +300,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -561,7 +561,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模板不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -921,7 +921,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -1227,7 +1227,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -1479,7 +1479,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -1740,7 +1740,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-local.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-local.json index 3f1e5e1b8..fa5294234 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-local.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-local.json @@ -17,8 +17,8 @@ { "access_mode": "PEER_WRITABLE", "name": "image", - "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", - "value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", + "value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "value_type": "STRING", "widget_schema": "{\"component\":\"Input\",\"required\":true}" } @@ -94,7 +94,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-sparse-estimator-test-right.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-sparse-estimator-test-right.json index a84fb55ca..742d28904 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-sparse-estimator-test-right.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/e2e-sparse-estimator-test-right.json @@ -148,8 +148,8 @@ "access_mode": "PEER_WRITABLE", "name": "image", "tag": "", - "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", - "value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", + "value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "value_type": "STRING", "widget_schema": "{\"component\":\"Input\",\"required\":true}" }, @@ -270,7 +270,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模板不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -630,7 +630,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -936,7 +936,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模版不适用", "label": "容器镜像", "reference": "workflow.variables.image", diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-horizontal-eval-model.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-horizontal-eval-model.json index e1dd38eb6..de56968ba 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-horizontal-eval-model.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-horizontal-eval-model.json @@ -22,10 +22,10 @@ }, { "name": "image_version", - "value": "608e2c3", + "value": "50a6945", "access_mode": "PEER_WRITABLE", "widget_schema": "{\"component\":\"Input\",\"required\":true,\"tag\":\"INPUT_PARAM\"}", - "typed_value": "608e2c3", + "typed_value": "50a6945", "tag": "INPUT_PARAM", "value_type": "STRING" }, @@ -419,7 +419,7 @@ "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模板不适用", "reference_type": "SELF", "label": "容器镜像", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "default": "", "value_type": "STRING" }, diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-horizontal-model.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-horizontal-model.json index aa46633c8..fa318b43f 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-horizontal-model.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-horizontal-model.json @@ -22,10 +22,10 @@ }, { "name": "image_version", - "value": "608e2c3", + "value": "50a6945", "access_mode": "PEER_WRITABLE", "widget_schema": "{\"component\":\"Input\",\"required\":true,\"tag\":\"INPUT_PARAM\"}", - "typed_value": "608e2c3", + "typed_value": "50a6945", "tag": "INPUT_PARAM", "value_type": "STRING" }, @@ -173,7 +173,7 @@ "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模板不适用", "reference_type": "SELF", "label": "容器镜像", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "default": "", "value_type": "STRING" }, diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-model.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-model.json index ffce5c357..2d5b5a582 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-model.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-nn-model.json @@ -203,10 +203,10 @@ }, { "name": "image_version", - "value": "608e2c3", + "value": "50a6945", "access_mode": "PEER_WRITABLE", "widget_schema": "{\"component\":\"Input\",\"required\":true,\"tooltip\":\"镜像版本\",\"tag\":\"INPUT_PARAM\"}", - "typed_value": "608e2c3", + "typed_value": "50a6945", "tag": "INPUT_PARAM", "value_type": "STRING" }, diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-psi-data-join-analyzer.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-psi-data-join-analyzer.json index 62b21d298..e7fc038be 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-psi-data-join-analyzer.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-psi-data-join-analyzer.json @@ -57,8 +57,8 @@ "access_mode": "PEER_READABLE", "name": "fedlearner_image_version", "tag": "INPUT_PARAM", - "typed_value": "608e2c3", - "value": "608e2c3", + "typed_value": "50a6945", + "value": "50a6945", "value_type": "STRING", "widget_schema": "{\"component\":\"Input\",\"required\":true,\"tooltip\":\"镜像版本不建议修改,如若修改请使用新于此版本的镜像\",\"tag\":\"INPUT_PARAM\"}" }, diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-psi-data-join.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-psi-data-join.json index d3aa25bba..3d862f073 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-psi-data-join.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-psi-data-join.json @@ -177,8 +177,8 @@ "access_mode": "PEER_READABLE", "name": "image", "tag": "", - "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", - "value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "typed_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", + "value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "value_type": "STRING", "widget_schema": "{\"component\":\"Input\",\"required\":true,\"tooltip\":\"镜像版本不建议修改,如若修改请使用新于此版本的镜像\"}" }, @@ -281,7 +281,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模板不适用", "label": "容器镜像", "reference": "workflow.variables.image", @@ -578,7 +578,7 @@ }, "Slot_image": { "default": "", - "default_value": "artifact.bytedance.com/fedlearner/fedlearner:608e2c3", + "default_value": "artifact.bytedance.com/fedlearner/fedlearner:50a6945", "help": "建议不修改,指定Pod中运行的容器镜像地址,修改此项可能导致本基本模板不适用", "label": "容器镜像", "reference": "workflow.variables.image", diff --git a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-tree-model.json b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-tree-model.json index a8fb51974..9598943e7 100644 --- a/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-tree-model.json +++ b/web_console_v2/api/fedlearner_webconsole/sys_preset_templates/sys-preset-tree-model.json @@ -11,10 +11,10 @@ "variables": [ { "name": "image_version", - "value": "608e2c3", + "value": "50a6945", "access_mode": "PEER_WRITABLE", "widget_schema": "{\"component\":\"Input\",\"required\":true,\"tooltip\":\"镜像版本\",\"tag\":\"INPUT_PARAM\"}", - "typed_value": "608e2c3", + "typed_value": "50a6945", "tag": "INPUT_PARAM", "value_type": "STRING" }, diff --git a/web_console_v2/api/protocols/fedlearner_webconsole/proto/rpc/v2/job_service.proto b/web_console_v2/api/protocols/fedlearner_webconsole/proto/rpc/v2/job_service.proto index 5e25acf14..ec14919e2 100644 --- a/web_console_v2/api/protocols/fedlearner_webconsole/proto/rpc/v2/job_service.proto +++ b/web_console_v2/api/protocols/fedlearner_webconsole/proto/rpc/v2/job_service.proto @@ -80,6 +80,12 @@ message CreateModelJobRequest { int64 version = 7; } +message InformModelJobRequest { + string uuid = 1; + // ref: AuthStatus + string auth_status = 2; +} + message CreateDatasetJobStageRequest { string dataset_job_uuid = 1; string dataset_job_stage_uuid = 2; @@ -147,6 +153,7 @@ service JobService { rpc UpdateDatasetJobSchedulerState (UpdateDatasetJobSchedulerStateRequest) returns (google.protobuf.Empty) {} rpc CreateModelJobGroup (CreateModelJobGroupRequest) returns (google.protobuf.Empty) {} rpc GetModelJob (GetModelJobRequest) returns (ModelJobPb) {} + rpc InformModelJob (InformModelJobRequest) returns (google.protobuf.Empty) {} rpc GetModelJobGroup (GetModelJobGroupRequest) returns (ModelJobGroupPb) {} rpc InformModelJobGroup (InformModelJobGroupRequest) returns (google.protobuf.Empty) {} rpc UpdateModelJobGroup (UpdateModelJobGroupRequest) returns (google.protobuf.Empty) {} diff --git a/web_console_v2/client/src/components/TodoPopover/index.tsx b/web_console_v2/client/src/components/TodoPopover/index.tsx index 8095fc521..06dc3c317 100644 --- a/web_console_v2/client/src/components/TodoPopover/index.tsx +++ b/web_console_v2/client/src/components/TodoPopover/index.tsx @@ -21,12 +21,13 @@ import { Todo, Right } from 'components/IconPark'; import { Workflow } from 'typings/workflow'; import { ModelServing, ModelServingState } from 'typings/modelServing'; -import { ModelJob, ModelJobGroup, ModelJobState } from 'typings/modelCenter'; +import { ModelJob, ModelJobGroup } from 'typings/modelCenter'; import { Algorithm } from 'typings/algorithm'; import newModelCenterRoutes, { ModelEvaluationModuleType } from 'views/ModelCenter/routes'; import { getCoordinateName, PENDING_PROJECT_FILTER_MAPPER } from 'views/Projects/shard'; import { filterExpressionGenerator } from 'views/Datasets/shared'; +import { FILTER_MODEL_JOB_OPERATOR_MAPPER } from 'views/ModelCenter/shared'; import { useGetAppFlagValue, useGetCurrentProjectId, @@ -171,7 +172,10 @@ function EvaluationModelNew({ } return fetchModelJobList_new(projectId, { - states: [ModelJobState.PENDING_ACCEPT], + filter: filterExpressionGenerator( + { auth_status: ['PENDING'] }, + FILTER_MODEL_JOB_OPERATOR_MAPPER, + ), types: isPrediction ? 'PREDICTION' : 'EVALUATION', }); }, diff --git a/web_console_v2/client/src/typings/modelCenter.ts b/web_console_v2/client/src/typings/modelCenter.ts index a6fac4dca..46badea89 100644 --- a/web_console_v2/client/src/typings/modelCenter.ts +++ b/web_console_v2/client/src/typings/modelCenter.ts @@ -301,6 +301,7 @@ export type ModelJob = { global_config?: ModelJobGlobalConfig; auto_update?: boolean; data_batch_id?: ID; + auth_status?: string; }; export type ModelUpdatePayload = { @@ -438,6 +439,16 @@ export enum ModelGroupStatus { ALL_AUTHORIZED = 'ALL_AUTHORIZED', } +export enum ModelJobAuthStatus { + TICKET_PENDING = 'TICKET_PENDING', + TICKET_DECLINE = 'TICKET_DECLINE', + CREATE_PENDING = 'CREATE_PENDING', + CREATE_FAILED = 'CREATE_FAILED', + SELF_AUTH_PENDING = 'SELF_AUTH_PENDING', + PART_AUTH_PENDING = 'PART_AUTH_PENDING', + ALL_AUTHORIZED = 'ALL_AUTHORIZED', +} + export enum EnumModelJobType { TRAINING = 'TRAINING', EVALUATION = 'EVALUATION', diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.tsx index de072d09f..93484cb5d 100644 --- a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.tsx +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/CreateForm/index.tsx @@ -234,11 +234,11 @@ const CreateForm: React.FC<Props> = ({ job, createReq, patchReq, jobType }) => { ); const peerModelJobData = useQuery( - ['model-evaluation-peer-model-detail'], + ['model-evaluation-peer-model-detail', modelJobIsOldVersion], () => fetchPeerModelJobDetail_new(projectId!, params.id, participantId!).then((res) => res.data), { - enabled: Boolean(projectId && params.id && isEdit), + enabled: Boolean(projectId && params.id && isEdit && modelJobIsOldVersion), }, ); @@ -724,6 +724,10 @@ const CreateForm: React.FC<Props> = ({ job, createReq, patchReq, jobType }) => { } async function submitWrapper_new(value: any) { + if (!projectId) { + Message.info('请选择工作区!'); + return; + } const algorithmType = selectedModelJobDetail.data?.algorithm_type || job?.algorithm_type; const selectedModelJob = selectedModelJobDetail.data; const selectedDataset = selectedDatasetRef.current; @@ -778,6 +782,21 @@ const CreateForm: React.FC<Props> = ({ job, createReq, patchReq, jobType }) => { }; if (isEdit) { + try { + updateModelJob(projectId!, params.id, { + metric_is_public: value.metric_is_public, + comment: value.comment, + auth_status: 'AUTHORIZED', + }); + Message.success('授权成功!所有合作伙伴授权完成后任务开始运行'); + history.replace( + generatePath(routes.ModelEvaluationList, { + module: params.module, + }), + ); + } catch (err: any) { + Message.error(err.message); + } patchMutation.mutate( omit( { ...payload, metric_is_public: value.metric_is_public }, @@ -787,10 +806,6 @@ const CreateForm: React.FC<Props> = ({ job, createReq, patchReq, jobType }) => { return; } try { - if (!projectId) { - Message.info('请选择工作区!'); - return; - } const res = await createModelJob(projectId!, payload); value.metric_is_public && updateModelJob(projectId!, res.data.id, { metric_is_public: true }); Message.success('创建成功'); diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ListTable/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ListTable/index.tsx index 137654b9a..d1a3e39b8 100644 --- a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ListTable/index.tsx +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ListTable/index.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { PaginationProps, Table, TableProps, Space } from '@arco-design/web-react'; import { generatePath, Link } from 'react-router-dom'; import StateIndicator from 'components/StateIndicator'; -import { ModelJobState } from 'typings/modelCenter'; +import { ModelJobAuthStatus, ModelJobState } from 'typings/modelCenter'; import { ModelJob } from 'typings/modelCenter'; import { ColumnsGetterOptions, @@ -10,6 +10,9 @@ import { roleFilters, statusFilters, getModelJobStatus, + MODEL_JOB_STATUS_MAPPER, + resetAuthInfo, + AUTH_STATUS_TEXT_MAP, } from '../../shared'; import { formatTimestamp } from 'shared/date'; import MoreActions from 'components/MoreActions'; @@ -17,6 +20,8 @@ import routeMaps from '../../routes'; import { CONSTANTS } from 'shared/constants'; import AlgorithmType from 'components/AlgorithmType'; import { EnumAlgorithmProjectType } from 'typings/algorithm'; +import ProgressWithText from 'components/ProgressWithText'; +import { useGetCurrentProjectParticipantList, useGetCurrentPureDomainName } from 'hooks'; const staticPaginationProps: Partial<PaginationProps> = { pageSize: 10, @@ -57,6 +62,40 @@ export const getTableColumns = (options: ColumnsGetterOptions) => { return <AlgorithmType type={value as EnumAlgorithmProjectType} />; }, }, + { + title: '授权状态', + dataIndex: 'auth_frontend_status', + key: 'auth_frontend_status', + width: 120, + render: (value: ModelJobAuthStatus, record: any) => { + const progressConfig = MODEL_JOB_STATUS_MAPPER?.[value]; + const authInfo = resetAuthInfo( + record.participants_info.participants_map, + options.participantList ?? [], + options.myPureDomainName ?? '', + ); + return ( + <ProgressWithText + status={progressConfig?.status} + statusText={progressConfig?.name} + percent={progressConfig?.status} + toolTipContent={ + [ModelJobAuthStatus.PART_AUTH_PENDING, ModelJobAuthStatus.SELF_AUTH_PENDING].includes( + value, + ) ? ( + <> + {authInfo.map((item: any) => ( + <div key={item.name}>{`${item.name}: ${ + AUTH_STATUS_TEXT_MAP?.[item.authStatus] + }`}</div> + ))} + </> + ) : undefined + } + /> + ); + }, + }, { title: '运行状态', dataIndex: 'status', @@ -169,6 +208,8 @@ const EvaluationTable: React.FC<EvaluationTableProps> = (props) => { nameFieldText, filterDropdownValues = {}, } = props; + const participantList = useGetCurrentProjectParticipantList(); + const myPureDomainName = useGetCurrentPureDomainName(); const paginationProps = useMemo(() => { return { ...staticPaginationProps, @@ -188,6 +229,8 @@ const EvaluationTable: React.FC<EvaluationTableProps> = (props) => { onStopClick, nameFieldText, filterDropdownValues, + participantList, + myPureDomainName, })} pagination={paginationProps} {...props} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationCreate/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationCreate/index.tsx index bb027b321..17f1b6b35 100644 --- a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationCreate/index.tsx +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationCreate/index.tsx @@ -47,7 +47,9 @@ const Create: React.FC = () => { {params.module === ModelEvaluationModuleType.Evaluation ? '模型评估' : '离线预测'} </BackButton> } - centerTitle={params.module === ModelEvaluationModuleType.Evaluation ? '创建评估' : '创建预测'} + centerTitle={`${params.role === 'receiver' ? '授权' : '创建'}${ + params.module === ModelEvaluationModuleType.Evaluation ? '评估' : '预测' + }`} > <CreateForm job={job} diff --git a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationList/index.tsx b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationList/index.tsx index d99f5f4fa..55d91bbe6 100644 --- a/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationList/index.tsx +++ b/web_console_v2/client/src/views/ModelCenter/ModelEvaluation/ModelEvaluationList/index.tsx @@ -8,7 +8,6 @@ import GridRow from 'components/_base/GridRow'; import TodoPopover from 'components/TodoPopover'; import { useGetCurrentProjectId, useTablePaginationWithUrlState, useUrlState } from 'hooks'; import * as service from 'services/modelCenter'; -import { ModelJobState } from 'typings/modelCenter'; import { ModelJob, ModelJobQueryParams_new as ModelJobQueryParams } from 'typings/modelCenter'; import { TIME_INTERVAL } from 'shared/constants'; @@ -28,7 +27,12 @@ const { Search } = Input; const List: FC<TProps> = function () { const history = useHistory(); const params = useParams<ModelEvaluationListParams>(); - const [urlState, setUrlState] = useUrlState<ModelJobQueryParams>({}); + const [urlState, setUrlState] = useUrlState<ModelJobQueryParams>({ + filter: filterExpressionGenerator( + { auth_status: ['AUTHORIZED'] }, + FILTER_MODEL_JOB_OPERATOR_MAPPER, + ), + }); const { urlState: pageInfo, paginationProps } = useTablePaginationWithUrlState(); const projectId = useGetCurrentProjectId(); const isModelEvaluation = params.module === 'model-evaluation'; @@ -43,18 +47,6 @@ const List: FC<TProps> = function () { page: pageInfo.page, page_size: pageInfo.pageSize, types: params.module === 'offline-prediction' ? 'PREDICTION' : 'EVALUATION', - states: [ - ModelJobState.COMPLETED, - ModelJobState.FAILED, - ModelJobState.INVALID, - ModelJobState.PARTICIPANT_CONFIGURING, - ModelJobState.PREPARE_RUN, - ModelJobState.PREPARE_STOP, - ModelJobState.READY_TO_RUN, - ModelJobState.RUNNING, - ModelJobState.STOPPED, - ModelJobState.WARMUP_UNDERHOOD, - ], filter: urlState.filter || undefined, }); }, @@ -103,7 +95,7 @@ const List: FC<TProps> = function () { page: 1, keyword, filter: filterExpressionGenerator( - { ...filter, name: keyword }, + { ...filter, name: keyword, auth_status: ['AUTHORIZED'] }, FILTER_MODEL_JOB_OPERATOR_MAPPER, ), })); @@ -132,6 +124,7 @@ const List: FC<TProps> = function () { status: filters.status, role: filters.role, name: urlState.keyword, + auth_status: ['AUTHORIZED'], }, FILTER_MODEL_JOB_OPERATOR_MAPPER, ), diff --git a/web_console_v2/client/src/views/ModelCenter/ModelJobDetailDrawer.tsx b/web_console_v2/client/src/views/ModelCenter/ModelJobDetailDrawer.tsx index 1b0f9d488..bf7a22dcf 100644 --- a/web_console_v2/client/src/views/ModelCenter/ModelJobDetailDrawer.tsx +++ b/web_console_v2/client/src/views/ModelCenter/ModelJobDetailDrawer.tsx @@ -327,15 +327,18 @@ function ModelJobDetailDrawer({ ['fetchDatasetBatchDetail'], () => fetchDataBatchById(modelJobDetail?.dataset_id!, modelJobDetail?.data_batch_id!), { - enabled: Boolean(modelJobDetail?.dataset_id), + enabled: Boolean(modelJobDetail?.dataset_id && modelJobDetail?.data_batch_id), retry: 2, refetchOnWindowFocus: false, }, ); const datasetBatchDetail = useMemo(() => { + if (!modelJobDetail?.data_batch_id) { + return undefined; + } return datasetBatchDetailQuery.data?.data; - }, [datasetBatchDetailQuery.data?.data]); + }, [datasetBatchDetailQuery.data?.data, modelJobDetail?.data_batch_id]); const isOldModelJob = useMemo(() => { return !modelJobDetailQuery.data?.data.global_config; }, [modelJobDetailQuery.data?.data.global_config]); diff --git a/web_console_v2/client/src/views/ModelCenter/shared.tsx b/web_console_v2/client/src/views/ModelCenter/shared.tsx index eee9dba19..32aea8a9d 100644 --- a/web_console_v2/client/src/views/ModelCenter/shared.tsx +++ b/web_console_v2/client/src/views/ModelCenter/shared.tsx @@ -22,6 +22,7 @@ import { ModelJobVariable, ModelGroupStatus, ModelJobStatus, + ModelJobAuthStatus, } from 'typings/modelCenter'; import { ModelEvaluationModuleType } from './routes'; @@ -127,6 +128,8 @@ export type ColumnsGetterOptions = { isRestartLoading?: boolean; isHideAllActionList?: boolean; filterDropdownValues?: TableFiltersValue; + participantList?: Participant[]; + myPureDomainName?: string; }; export function getModelJobState( @@ -896,6 +899,7 @@ export const FILTER_MODEL_JOB_OPERATOR_MAPPER = { model_job_type: FilterOp.IN, status: FilterOp.IN, configured: FilterOp.EQUAL, + auth_status: FilterOp.IN, }; export const MODEL_GROUP_STATUS_MAPPER: Record<ModelGroupStatus, any> = { @@ -974,3 +978,41 @@ export const ALGORITHM_TYPE_LABEL_MAPPER: Record<string, string> = { NN_VERTICAL: '纵向联邦-NN模型', TREE_VERTICAL: '纵向联邦-树模型', }; + +export const MODEL_JOB_STATUS_MAPPER: Record<ModelJobAuthStatus, any> = { + TICKET_PENDING: { + status: 'default', + percent: 30, + name: '待审批', + }, + CREATE_PENDING: { + status: 'default', + percent: 40, + name: '创建中', + }, + CREATE_FAILED: { + status: 'warning', + percent: 100, + name: '创建失败', + }, + TICKET_DECLINE: { + status: 'warning', + percent: 30, + name: '审批拒绝', + }, + SELF_AUTH_PENDING: { + status: 'default', + percent: 50, + name: '待我方授权', + }, + PART_AUTH_PENDING: { + status: 'default', + percent: 70, + name: '待合作伙伴授权', + }, + ALL_AUTHORIZED: { + status: 'success', + percent: 100, + name: '授权通过', + }, +}; From 9e005cb2d4099334b83294942497e0f71460035f Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Mon, 6 Feb 2023 20:15:17 +0800 Subject: [PATCH 05/15] [WebConsole] Sync to github manually --- data_processing/.gitignore | 1 - data_processing/README.md | 14 - data_processing/pom.xml | 115 ---- data_processing/spark/csv_to_hive.py | 36 - data_processing/spark/hive_to_csv.py | 36 - .../com/bytedance/aml/enterprise/sm4/SM4.java | 320 --------- .../aml/enterprise/sm4/SM4Context.java | 31 - .../aml/enterprise/sm4/SM4Utils.java | 139 ---- .../aml/enterprise/sparkudf/SM4.java | 31 - .../bytedance/aml/enterprise/util/Util.java | 632 ------------------ .../com/bytedance/aml/enterprise/Main.scala | 27 - .../aml/enterprise/sparkudaf/Hist.scala | 25 - .../aml/enterprise/sparkudaf/HistUDAF.scala | 65 -- 13 files changed, 1472 deletions(-) delete mode 100644 data_processing/.gitignore delete mode 100644 data_processing/README.md delete mode 100644 data_processing/pom.xml delete mode 100644 data_processing/spark/csv_to_hive.py delete mode 100644 data_processing/spark/hive_to_csv.py delete mode 100644 data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4.java delete mode 100644 data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4Context.java delete mode 100644 data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4Utils.java delete mode 100644 data_processing/src/main/java/com/bytedance/aml/enterprise/sparkudf/SM4.java delete mode 100644 data_processing/src/main/java/com/bytedance/aml/enterprise/util/Util.java delete mode 100644 data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala delete mode 100644 data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/Hist.scala delete mode 100644 data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/HistUDAF.scala diff --git a/data_processing/.gitignore b/data_processing/.gitignore deleted file mode 100644 index 2f7896d1d..000000000 --- a/data_processing/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/data_processing/README.md b/data_processing/README.md deleted file mode 100644 index 7f8290fe6..000000000 --- a/data_processing/README.md +++ /dev/null @@ -1,14 +0,0 @@ -## Package -```shell -mvn clean scala:compile assembly:single -``` - -## Run -```shell -mvn scala:run -DmainClass=com.bytedance.aml.enterprise.Main -``` - -## Dependencies -* Spark 3.0.1 -* Java 8 -* Scala 2.12 diff --git a/data_processing/pom.xml b/data_processing/pom.xml deleted file mode 100644 index 4308b76ac..000000000 --- a/data_processing/pom.xml +++ /dev/null @@ -1,115 +0,0 @@ -<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - <modelVersion>4.0.0</modelVersion> - - <groupId>com.bytedance.aml.enterprise</groupId> - <artifactId>sm4spark</artifactId> - <version>0.0.1-SNAPSHOT</version> - - <properties> - <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> - <spark.version>3.0.3</spark.version> - - <java.version>1.8</java.version> - <scala.major.version>2.12</scala.major.version> - <scala.version>2.12.10</scala.version> - - <maven.compiler.target>1.8</maven.compiler.target> - <maven.compiler.source>1.8</maven.compiler.source> - <maven.compiler.release>8</maven.compiler.release> - </properties> - - <dependencies> - <!-- Scala --> - <dependency> - <groupId>org.scala-lang</groupId> - <artifactId>scala-library</artifactId> - <version>${scala.version}</version> - </dependency> - - <dependency> - <groupId>commons-codec</groupId> - <artifactId>commons-codec</artifactId> - <version>1.15</version> - </dependency> - - <dependency> - <groupId>org.apache.spark</groupId> - <artifactId>spark-sql_2.12</artifactId> - <version>${spark.version}</version> - <scope>provided</scope> - <exclusions> - <exclusion> - <groupId>com.google.guava</groupId> - <artifactId>guava</artifactId> - </exclusion> - </exclusions> - </dependency> - </dependencies> - - <build> - <pluginManagement> - <plugins> - <plugin> - <groupId>org.apache.maven.plugins</groupId> - <artifactId>maven-compiler-plugin</artifactId> - <configuration> - <source>${java.version}</source> - <target>${java.version}</target> - </configuration> - </plugin> - - <plugin> - <groupId>net.alchim31.maven</groupId> - <artifactId>scala-maven-plugin</artifactId> - <version>4.3.0</version> - <executions> - <execution> - <id>scala-compile-first</id> - <phase>process-resources</phase> - <goals> - <goal>add-source</goal> - <goal>compile</goal> - </goals> - </execution> - <execution> - <id>scala-test-compile</id> - <phase>process-test-resources</phase> - <goals> - <goal>testCompile</goal> - </goals> - </execution> - </executions> - <configuration> - <scalaVersion>${scala.version}</scalaVersion> - </configuration> - </plugin> - - <!-- scala assembly--> - <plugin> - <artifactId>maven-assembly-plugin</artifactId> - <configuration> - <finalName>${project.artifactId}-${project.version}-RELEASE</finalName> - <archive> - <manifest> - <mainClass>fully.qualified.MainClass</mainClass> - </manifest> - </archive> - <descriptorRefs> - <descriptorRef>jar-with-dependencies</descriptorRef> - </descriptorRefs> - </configuration> - <executions> - <execution> - <id>make-assembly</id> - <phase>package</phase> - <goals> - <goal>single</goal> - </goals> - </execution> - </executions> - </plugin> - </plugins> - </pluginManagement> - </build> -</project> \ No newline at end of file diff --git a/data_processing/spark/csv_to_hive.py b/data_processing/spark/csv_to_hive.py deleted file mode 100644 index ef50a7c83..000000000 --- a/data_processing/spark/csv_to_hive.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2023 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from pyspark.sql import SparkSession -from pyspark.sql.functions import col, lower - - -def run(): - spark = SparkSession \ - .builder \ - .enableHiveSupport() \ - .config('hive.exec.dynamic.partition', 'true') \ - .config('hive.exec.dynamic.partition.mode', 'nonstrict') \ - .getOrCreate() - - df = spark.read.option('header', 'false') \ - .csv('/home/byte_aml_tob/fedlearner_v2/njb/reduced.csv') - df = df.select(lower(col(df.columns[0])).alias('phone_sha256')) - df.write.mode('overwrite').insertInto('aml_tob.njb_intersection_sha256') - spark.stop() - - -if __name__ == '__main__': - run() diff --git a/data_processing/spark/hive_to_csv.py b/data_processing/spark/hive_to_csv.py deleted file mode 100644 index aa1a39b7c..000000000 --- a/data_processing/spark/hive_to_csv.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2023 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from pyspark.sql import SparkSession - - -def run(): - spark = SparkSession \ - .builder \ - .enableHiveSupport() \ - .getOrCreate() - - df = spark.sql('SELECT DISTINCT uid FROM aml_tob.njb_intersection_uid WHERE uid IS NOT NULL') - - # Partition automatically - df.write.format('csv').option('compression', - 'none').option('header', - 'false').save('/home/byte_aml_tob/fedlearner_v2/njb/reduced_uid', - mode='overwrite') - spark.stop() - - -if __name__ == '__main__': - run() diff --git a/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4.java b/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4.java deleted file mode 100644 index d3eb37a93..000000000 --- a/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4.java +++ /dev/null @@ -1,320 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise.sm4; - -import com.bytedance.aml.enterprise.util.Util; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; - -public class SM4 { - public static final int SM4_ENCRYPT = 1; - - public static final int SM4_DECRYPT = 0; - - private long getULongBe(byte[] b, int i) { - long n = (long) (b[i] & 0xff) << 24 | (long) ((b[i + 1] & 0xff) << 16) | (long) ((b[i + 2] & 0xff) << 8) | (long) (b[i + 3] & 0xff) & 0xffffffffL; - return n; - } - - private void putULongBe(long n, byte[] b, int i) { - b[i] = (byte) (int) (0xFF & n >> 24); - b[i + 1] = (byte) (int) (0xFF & n >> 16); - b[i + 2] = (byte) (int) (0xFF & n >> 8); - b[i + 3] = (byte) (int) (0xFF & n); - } - - private long shl(long x, int n) { - return (x & 0xFFFFFFFF) << n; - } - - private long rotl(long x, int n) { - return shl(x, n) | x >> (32 - n); - } - - private void swap(long[] sk, int i) { - long t = sk[i]; - sk[i] = sk[(31 - i)]; - sk[(31 - i)] = t; - } - - public static final byte[] SBOX_TABLE = {(byte) 0xd6, (byte) 0x90, (byte) 0xe9, (byte) 0xfe, - (byte) 0xcc, (byte) 0xe1, 0x3d, (byte) 0xb7, 0x16, (byte) 0xb6, - 0x14, (byte) 0xc2, 0x28, (byte) 0xfb, 0x2c, 0x05, 0x2b, 0x67, - (byte) 0x9a, 0x76, 0x2a, (byte) 0xbe, 0x04, (byte) 0xc3, - (byte) 0xaa, 0x44, 0x13, 0x26, 0x49, (byte) 0x86, 0x06, - (byte) 0x99, (byte) 0x9c, 0x42, 0x50, (byte) 0xf4, (byte) 0x91, - (byte) 0xef, (byte) 0x98, 0x7a, 0x33, 0x54, 0x0b, 0x43, - (byte) 0xed, (byte) 0xcf, (byte) 0xac, 0x62, (byte) 0xe4, - (byte) 0xb3, 0x1c, (byte) 0xa9, (byte) 0xc9, 0x08, (byte) 0xe8, - (byte) 0x95, (byte) 0x80, (byte) 0xdf, (byte) 0x94, (byte) 0xfa, - 0x75, (byte) 0x8f, 0x3f, (byte) 0xa6, 0x47, 0x07, (byte) 0xa7, - (byte) 0xfc, (byte) 0xf3, 0x73, 0x17, (byte) 0xba, (byte) 0x83, - 0x59, 0x3c, 0x19, (byte) 0xe6, (byte) 0x85, 0x4f, (byte) 0xa8, - 0x68, 0x6b, (byte) 0x81, (byte) 0xb2, 0x71, 0x64, (byte) 0xda, - (byte) 0x8b, (byte) 0xf8, (byte) 0xeb, 0x0f, 0x4b, 0x70, 0x56, - (byte) 0x9d, 0x35, 0x1e, 0x24, 0x0e, 0x5e, 0x63, 0x58, (byte) 0xd1, - (byte) 0xa2, 0x25, 0x22, 0x7c, 0x3b, 0x01, 0x21, 0x78, (byte) 0x87, - (byte) 0xd4, 0x00, 0x46, 0x57, (byte) 0x9f, (byte) 0xd3, 0x27, - 0x52, 0x4c, 0x36, 0x02, (byte) 0xe7, (byte) 0xa0, (byte) 0xc4, - (byte) 0xc8, (byte) 0x9e, (byte) 0xea, (byte) 0xbf, (byte) 0x8a, - (byte) 0xd2, 0x40, (byte) 0xc7, 0x38, (byte) 0xb5, (byte) 0xa3, - (byte) 0xf7, (byte) 0xf2, (byte) 0xce, (byte) 0xf9, 0x61, 0x15, - (byte) 0xa1, (byte) 0xe0, (byte) 0xae, 0x5d, (byte) 0xa4, - (byte) 0x9b, 0x34, 0x1a, 0x55, (byte) 0xad, (byte) 0x93, 0x32, - 0x30, (byte) 0xf5, (byte) 0x8c, (byte) 0xb1, (byte) 0xe3, 0x1d, - (byte) 0xf6, (byte) 0xe2, 0x2e, (byte) 0x82, 0x66, (byte) 0xca, - 0x60, (byte) 0xc0, 0x29, 0x23, (byte) 0xab, 0x0d, 0x53, 0x4e, 0x6f, - (byte) 0xd5, (byte) 0xdb, 0x37, 0x45, (byte) 0xde, (byte) 0xfd, - (byte) 0x8e, 0x2f, 0x03, (byte) 0xff, 0x6a, 0x72, 0x6d, 0x6c, 0x5b, - 0x51, (byte) 0x8d, 0x1b, (byte) 0xaf, (byte) 0x92, (byte) 0xbb, - (byte) 0xdd, (byte) 0xbc, 0x7f, 0x11, (byte) 0xd9, 0x5c, 0x41, - 0x1f, 0x10, 0x5a, (byte) 0xd8, 0x0a, (byte) 0xc1, 0x31, - (byte) 0x88, (byte) 0xa5, (byte) 0xcd, 0x7b, (byte) 0xbd, 0x2d, - 0x74, (byte) 0xd0, 0x12, (byte) 0xb8, (byte) 0xe5, (byte) 0xb4, - (byte) 0xb0, (byte) 0x89, 0x69, (byte) 0x97, 0x4a, 0x0c, - (byte) 0x96, 0x77, 0x7e, 0x65, (byte) 0xb9, (byte) 0xf1, 0x09, - (byte) 0xc5, 0x6e, (byte) 0xc6, (byte) 0x84, 0x18, (byte) 0xf0, - 0x7d, (byte) 0xec, 0x3a, (byte) 0xdc, 0x4d, 0x20, 0x79, - (byte) 0xee, 0x5f, 0x3e, (byte) 0xd7, (byte) 0xcb, 0x39, 0x48}; - - public static final int[] FK = {0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc}; - - public static final int[] CK = {0x00070e15, 0x1c232a31, 0x383f464d, 0x545b6269, - 0x70777e85, 0x8c939aa1, 0xa8afb6bd, 0xc4cbd2d9, - 0xe0e7eef5, 0xfc030a11, 0x181f262d, 0x343b4249, - 0x50575e65, 0x6c737a81, 0x888f969d, 0xa4abb2b9, - 0xc0c7ced5, 0xdce3eaf1, 0xf8ff060d, 0x141b2229, - 0x30373e45, 0x4c535a61, 0x686f767d, 0x848b9299, - 0xa0a7aeb5, 0xbcc3cad1, 0xd8dfe6ed, 0xf4fb0209, - 0x10171e25, 0x2c333a41, 0x484f565d, 0x646b7279}; - - private byte sm4Sbox(byte inch) { - int i = inch & 0xFF; - byte retVal = SBOX_TABLE[i]; - return retVal; - } - - private long sm4Lt(long ka) { - long bb = 0L; - long c = 0L; - byte[] a = new byte[4]; - byte[] b = new byte[4]; - putULongBe(ka, a, 0); - b[0] = sm4Sbox(a[0]); - b[1] = sm4Sbox(a[1]); - b[2] = sm4Sbox(a[2]); - b[3] = sm4Sbox(a[3]); - bb = getULongBe(b, 0); - c = bb ^ rotl(bb, 2) ^ rotl(bb, 10) ^ rotl(bb, 18) ^ rotl(bb, 24); - return c; - } - - private long sm4F(long x0, long x1, long x2, long x3, long rk) { - return x0 ^ sm4Lt(x1 ^ x2 ^ x3 ^ rk); - } - - private long sm4CalciRK(long ka) { - long bb = 0L; - long rk = 0L; - byte[] a = new byte[4]; - byte[] b = new byte[4]; - putULongBe(ka, a, 0); - b[0] = sm4Sbox(a[0]); - b[1] = sm4Sbox(a[1]); - b[2] = sm4Sbox(a[2]); - b[3] = sm4Sbox(a[3]); - bb = getULongBe(b, 0); - rk = bb ^ rotl(bb, 13) ^ rotl(bb, 23); - return rk; - } - - private void sm4SetKey(long[] SK, byte[] key) { - long[] MK = new long[4]; - long[] k = new long[36]; - int i = 0; - MK[0] = getULongBe(key, 0); - MK[1] = getULongBe(key, 4); - MK[2] = getULongBe(key, 8); - MK[3] = getULongBe(key, 12); - k[0] = MK[0] ^ (long) FK[0]; - k[1] = MK[1] ^ (long) FK[1]; - k[2] = MK[2] ^ (long) FK[2]; - k[3] = MK[3] ^ (long) FK[3]; - for (; i < 32; i++) { - k[(i + 4)] = (k[i] ^ sm4CalciRK(k[(i + 1)] ^ k[(i + 2)] ^ k[(i + 3)] ^ (long) CK[i])); - SK[i] = k[(i + 4)]; - } - } - - private void sm4OneRound(long[] sk, byte[] input, byte[] output) { - int i = 0; - long[] ulbuf = new long[36]; - ulbuf[0] = getULongBe(input, 0); - ulbuf[1] = getULongBe(input, 4); - ulbuf[2] = getULongBe(input, 8); - ulbuf[3] = getULongBe(input, 12); - while (i < 32) { - ulbuf[(i + 4)] = sm4F(ulbuf[i], ulbuf[(i + 1)], ulbuf[(i + 2)], ulbuf[(i + 3)], sk[i]); - i++; - } - putULongBe(ulbuf[35], output, 0); - putULongBe(ulbuf[34], output, 4); - putULongBe(ulbuf[33], output, 8); - putULongBe(ulbuf[32], output, 12); - } - //修改了填充模式,为模式 - private byte[] padding(byte[] input, int mode) { - if (input == null) { - return null; - } - - byte[] ret = (byte[]) null; - if (mode == SM4_ENCRYPT) { - //填充:hex必须是32的整数倍填充 ,填充的是80 00 00 00 - int p = 16 - input.length % 16; - String inputHex = Util.byteToHex(input)+ "80"; - StringBuffer stringBuffer =new StringBuffer(inputHex); - for (int i = 0; i <p-1 ; i++) { - stringBuffer.append("00"); - } - ret= Util.hexToByte(stringBuffer.toString()); - } else { - String inputHex =Util.byteToHex(input); - int i = inputHex.lastIndexOf("80"); - String substring = inputHex.substring(0, i); - ret= Util.hexToByte(substring); - } - return ret; - } - - public void sm4SetKeyEnc(SM4Context ctx, byte[] key) throws Exception { - if (ctx == null) { - throw new Exception("ctx is null!"); - } - - if (key == null || key.length != 16) { - throw new Exception("key error!"); - } - - ctx.mode = SM4_ENCRYPT; - sm4SetKey(ctx.sk, key); - } - - public void sm4SetKeyDec(SM4Context ctx, byte[] key) throws Exception { - if (ctx == null) { - throw new Exception("ctx is null!"); - } - - if (key == null || key.length != 16) { - throw new Exception("key error!"); - } - - int i = 0; - ctx.mode = SM4_DECRYPT; - sm4SetKey(ctx.sk, key); - for (i = 0; i < 16; i++) { - swap(ctx.sk, i); - } - } - - public byte[] sm4CryptECB(SM4Context ctx, byte[] input) throws Exception { - if (input == null) { - throw new Exception("input is null!"); - } - - if ((ctx.isPadding) && (ctx.mode == SM4_ENCRYPT)) { - input = padding(input, SM4_ENCRYPT); - } - - int length = input.length; - ByteArrayInputStream bins = new ByteArrayInputStream(input); - ByteArrayOutputStream bous = new ByteArrayOutputStream(); - for (; length > 0; length -= 16) { - byte[] in = new byte[16]; - byte[] out = new byte[16]; - bins.read(in); - sm4OneRound(ctx.sk, in, out); - bous.write(out); - } - - byte[] output = bous.toByteArray(); - if (ctx.isPadding && ctx.mode == SM4_DECRYPT) { - output = padding(output, SM4_DECRYPT); - } - bins.close(); - bous.close(); - return output; - } - - public byte[] sm4CryptCBC(SM4Context ctx, byte[] iv, byte[] input) throws Exception { - if (iv == null || iv.length != 16) { - throw new Exception("iv error!"); - } - - if (input == null) { - throw new Exception("input is null!"); - } - - if (ctx.isPadding && ctx.mode == SM4_ENCRYPT) { - input = padding(input, SM4_ENCRYPT); - } - - int i = 0; - int length = input.length; - ByteArrayInputStream bins = new ByteArrayInputStream(input); - ByteArrayOutputStream bous = new ByteArrayOutputStream(); - if (ctx.mode == SM4_ENCRYPT) { - for (; length > 0; length -= 16) { - byte[] in = new byte[16]; - byte[] out = new byte[16]; - byte[] out1 = new byte[16]; - - bins.read(in); - for (i = 0; i < 16; i++) { - out[i] = ((byte) (in[i] ^ iv[i])); - } - sm4OneRound(ctx.sk, out, out1); - System.arraycopy(out1, 0, iv, 0, 16); - bous.write(out1); - } - } else { - byte[] temp = new byte[16]; - for (; length > 0; length -= 16) { - byte[] in = new byte[16]; - byte[] out = new byte[16]; - byte[] out1 = new byte[16]; - - bins.read(in); - System.arraycopy(in, 0, temp, 0, 16); - sm4OneRound(ctx.sk, in, out); - for (i = 0; i < 16; i++) { - out1[i] = ((byte) (out[i] ^ iv[i])); - } - System.arraycopy(temp, 0, iv, 0, 16); - bous.write(out1); - } - } - - byte[] output = bous.toByteArray(); - if (ctx.isPadding && ctx.mode == SM4_DECRYPT) { - output = padding(output, SM4_DECRYPT); - } - bins.close(); - bous.close(); - return output; - } -} diff --git a/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4Context.java b/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4Context.java deleted file mode 100644 index 59cca4131..000000000 --- a/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4Context.java +++ /dev/null @@ -1,31 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise.sm4; - -public class SM4Context { - public int mode; - - public long[] sk; - - public boolean isPadding; - - public SM4Context() - { - this.mode = 1; - this.isPadding = true; - this.sk = new long[32]; - } -} diff --git a/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4Utils.java b/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4Utils.java deleted file mode 100644 index 28792c193..000000000 --- a/data_processing/src/main/java/com/bytedance/aml/enterprise/sm4/SM4Utils.java +++ /dev/null @@ -1,139 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise.sm4; - -import com.bytedance.aml.enterprise.util.Util; -import org.apache.commons.codec.binary.Base64; - -import java.nio.charset.StandardCharsets; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class SM4Utils { - - public String secretKey = ""; - public String iv = ""; - public boolean hexString = false; - - public String encryptDataECB(String plainText) { - try { - SM4Context ctx = new SM4Context(); - ctx.isPadding = true; - ctx.mode = SM4.SM4_ENCRYPT; - - byte[] keyBytes; - keyBytes = Util.hexStringToBytes(secretKey); - - SM4 sm4 = new SM4(); - sm4.sm4SetKeyEnc(ctx, keyBytes); - byte[] encrypted = sm4.sm4CryptECB(ctx, plainText.getBytes(StandardCharsets.UTF_8)); - return Util.byteToHex(encrypted); - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } - - public String decryptDataECB(String cipherText) { - try { - byte[] encrypted = Util.hexToByte(cipherText); - cipherText= Base64.encodeBase64String(encrypted); - if (cipherText != null && cipherText.trim().length() > 0) { - Pattern p = Pattern.compile("\\s*|\t|\r|\n"); - Matcher m = p.matcher(cipherText); - cipherText = m.replaceAll(""); - } - - SM4Context ctx = new SM4Context(); - ctx.isPadding = true; - ctx.mode = SM4.SM4_DECRYPT; - - byte[] keyBytes; - if (hexString) { - keyBytes = Util.hexStringToBytes(secretKey); - } else { - keyBytes = secretKey.getBytes(); - } - - SM4 sm4 = new SM4(); - sm4.sm4SetKeyDec(ctx, keyBytes); - byte[] decrypted = sm4.sm4CryptECB(ctx, Base64.decodeBase64(cipherText)); - return new String(decrypted, StandardCharsets.UTF_8); - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } - - public String encryptDataCBC(String plainText) { - try { - SM4Context ctx = new SM4Context(); - ctx.isPadding = true; - ctx.mode = SM4.SM4_ENCRYPT; - - byte[] keyBytes; - byte[] ivBytes; - if (hexString) { - keyBytes = Util.hexStringToBytes(secretKey); - ivBytes = Util.hexStringToBytes(iv); - } else { - keyBytes = secretKey.getBytes(); - ivBytes = iv.getBytes(); - } - - SM4 sm4 = new SM4(); - sm4.sm4SetKeyEnc(ctx, keyBytes); - byte[] encrypted = sm4.sm4CryptCBC(ctx, ivBytes, plainText.getBytes("UTF-8")); - return Util.byteToHex(encrypted); - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } - - public String decryptDataCBC(String cipherText) { - try { - byte[] encrypted = Util.hexToByte(cipherText); - cipherText= Base64.encodeBase64String(encrypted);; - if (cipherText != null && cipherText.trim().length() > 0) { - Pattern p = Pattern.compile("\\s*|\t|\r|\n"); - Matcher m = p.matcher(cipherText); - cipherText = m.replaceAll(""); - } - SM4Context ctx = new SM4Context(); - ctx.isPadding = true; - ctx.mode = SM4.SM4_DECRYPT; - - byte[] keyBytes; - byte[] ivBytes; - if (hexString) { - keyBytes = Util.hexStringToBytes(secretKey); - ivBytes = Util.hexStringToBytes(iv); - } else { - keyBytes = secretKey.getBytes(); - ivBytes = iv.getBytes(); - } - - SM4 sm4 = new SM4(); - sm4.sm4SetKeyDec(ctx, keyBytes); - byte[] decrypted = sm4.sm4CryptCBC(ctx, ivBytes, Base64.decodeBase64(cipherText)); - return new String(decrypted, StandardCharsets.UTF_8); - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } -} diff --git a/data_processing/src/main/java/com/bytedance/aml/enterprise/sparkudf/SM4.java b/data_processing/src/main/java/com/bytedance/aml/enterprise/sparkudf/SM4.java deleted file mode 100644 index e6af34bea..000000000 --- a/data_processing/src/main/java/com/bytedance/aml/enterprise/sparkudf/SM4.java +++ /dev/null @@ -1,31 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise.sparkudf; - -import org.apache.spark.sql.api.java.UDF1; -import com.bytedance.aml.enterprise.sm4.SM4Utils; - -public class SM4 implements UDF1<String, String> { - private static final String SECRET_STR = "64EC7C763AB7BF64E2D75FF83A319910"; - - @Override - public String call(String raw) throws Exception { - SM4Utils sm4 = new SM4Utils(); - sm4.secretKey = SECRET_STR; - sm4.hexString = true; - return sm4.encryptDataECB(raw); - } -} diff --git a/data_processing/src/main/java/com/bytedance/aml/enterprise/util/Util.java b/data_processing/src/main/java/com/bytedance/aml/enterprise/util/Util.java deleted file mode 100644 index 2a4b3da8a..000000000 --- a/data_processing/src/main/java/com/bytedance/aml/enterprise/util/Util.java +++ /dev/null @@ -1,632 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise.util; - -import java.math.BigInteger; - -public class Util { - /** - * 整形转换成网络传输的字节流(字节数组)型数据 - * - * @param num 一个整型数据 - * @return 4个字节的自己数组 - */ - public static byte[] intToBytes(int num) { - byte[] bytes = new byte[4]; - bytes[0] = (byte) (0xff & (num >> 0)); - bytes[1] = (byte) (0xff & (num >> 8)); - bytes[2] = (byte) (0xff & (num >> 16)); - bytes[3] = (byte) (0xff & (num >> 24)); - return bytes; - } - - /** - * 四个字节的字节数据转换成一个整形数据 - * - * @param bytes 4个字节的字节数组 - * @return 一个整型数据 - */ - public static int byteToInt(byte[] bytes) { - int num = 0; - int temp; - temp = (0x000000ff & (bytes[0])) << 0; - num = num | temp; - temp = (0x000000ff & (bytes[1])) << 8; - num = num | temp; - temp = (0x000000ff & (bytes[2])) << 16; - num = num | temp; - temp = (0x000000ff & (bytes[3])) << 24; - num = num | temp; - return num; - } - - /** - * 长整形转换成网络传输的字节流(字节数组)型数据 - * - * @param num 一个长整型数据 - * @return 4个字节的自己数组 - */ - public static byte[] longToBytes(long num) { - byte[] bytes = new byte[8]; - for (int i = 0; i < 8; i++) { - bytes[i] = (byte) (0xff & (num >> (i * 8))); - } - - return bytes; - } - - /** - * 大数字转换字节流(字节数组)型数据 - * - * @param n - * @return - */ - public static byte[] byteConvert32Bytes(BigInteger n) { - byte tmpd[] = (byte[]) null; - if (n == null) { - return null; - } - - if (n.toByteArray().length == 33) { - tmpd = new byte[32]; - System.arraycopy(n.toByteArray(), 1, tmpd, 0, 32); - } else if (n.toByteArray().length == 32) { - tmpd = n.toByteArray(); - } else { - tmpd = new byte[32]; - for (int i = 0; i < 32 - n.toByteArray().length; i++) { - tmpd[i] = 0; - } - System.arraycopy(n.toByteArray(), 0, tmpd, 32 - n.toByteArray().length, n.toByteArray().length); - } - return tmpd; - } - - /** - * 换字节流(字节数组)型数据转大数字 - * - * @param b - * @return - */ - public static BigInteger byteConvertInteger(byte[] b) { - if (b[0] < 0) { - byte[] temp = new byte[b.length + 1]; - temp[0] = 0; - System.arraycopy(b, 0, temp, 1, b.length); - return new BigInteger(temp); - } - return new BigInteger(b); - } - - /** - * 根据字节数组获得值(十六进制数字) - * - * @param bytes - * @return - */ - public static String getHexString(byte[] bytes) { - return getHexString(bytes, true); - } - - /** - * 根据字节数组获得值(十六进制数字) - * - * @param bytes - * @param upperCase - * @return - */ - public static String getHexString(byte[] bytes, boolean upperCase) { - String ret = ""; - for (int i = 0; i < bytes.length; i++) { - ret += Integer.toString((bytes[i] & 0xff) + 0x100, 16).substring(1); - } - return upperCase ? ret.toUpperCase() : ret; - } - - /** - * 打印十六进制字符串 - * - * @param bytes - */ - public static void printHexString(byte[] bytes) { - for (int i = 0; i < bytes.length; i++) { - String hex = Integer.toHexString(bytes[i] & 0xFF); - if (hex.length() == 1) { - hex = '0' + hex; - } - System.out.print("0x" + hex.toUpperCase() + ","); - } - System.out.println(""); - } - - /** - * Convert hex string to byte[] - * - * @param hexString the hex string - * @return byte[] - */ - public static byte[] hexStringToBytes(String hexString) { - if (hexString == null || hexString.equals("")) { - return null; - } - - hexString = hexString.toUpperCase(); - int length = hexString.length() / 2; - char[] hexChars = hexString.toCharArray(); - byte[] d = new byte[length]; - for (int i = 0; i < length; i++) { - int pos = i * 2; - d[i] = (byte) (charToByte(hexChars[pos]) << 4 | charToByte(hexChars[pos + 1])); - } - return d; - } - - /** - * Convert char to byte - * - * @param c char - * @return byte - */ - public static byte charToByte(char c) { - return (byte) "0123456789ABCDEF".indexOf(c); - } - - /** - * 用于建立十六进制字符的输出的小写字符数组 - */ - private static final char[] DIGITS_LOWER = {'0', '1', '2', '3', '4', '5', - '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; - - /** - * 用于建立十六进制字符的输出的大写字符数组 - */ - private static final char[] DIGITS_UPPER = {'0', '1', '2', '3', '4', '5', - '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; - - /** - * 将字节数组转换为十六进制字符数组 - * - * @param data byte[] - * @return 十六进制char[] - */ - public static char[] encodeHex(byte[] data) { - return encodeHex(data, true); - } - - /** - * 将字节数组转换为十六进制字符数组 - * - * @param data byte[] - * @param toLowerCase <code>true</code> 传换成小写格式 , <code>false</code> 传换成大写格式 - * @return 十六进制char[] - */ - public static char[] encodeHex(byte[] data, boolean toLowerCase) { - return encodeHex(data, toLowerCase ? DIGITS_LOWER : DIGITS_UPPER); - } - - /** - * 将字节数组转换为十六进制字符数组 - * - * @param data byte[] - * @param toDigits 用于控制输出的char[] - * @return 十六进制char[] - */ - protected static char[] encodeHex(byte[] data, char[] toDigits) { - int l = data.length; - char[] out = new char[l << 1]; - // two characters form the hex value. - for (int i = 0, j = 0; i < l; i++) { - out[j++] = toDigits[(0xF0 & data[i]) >>> 4]; - out[j++] = toDigits[0x0F & data[i]]; - } - return out; - } - - /** - * 将字节数组转换为十六进制字符串 - * - * @param data byte[] - * @return 十六进制String - */ - public static String encodeHexString(byte[] data) { - return encodeHexString(data, true); - } - - /** - * 将字节数组转换为十六进制字符串 - * - * @param data byte[] - * @param toLowerCase <code>true</code> 传换成小写格式 , <code>false</code> 传换成大写格式 - * @return 十六进制String - */ - public static String encodeHexString(byte[] data, boolean toLowerCase) { - return encodeHexString(data, toLowerCase ? DIGITS_LOWER : DIGITS_UPPER); - } - - /** - * 将字节数组转换为十六进制字符串 - * - * @param data byte[] - * @param toDigits 用于控制输出的char[] - * @return 十六进制String - */ - protected static String encodeHexString(byte[] data, char[] toDigits) { - return new String(encodeHex(data, toDigits)); - } - - /** - * 将十六进制字符数组转换为字节数组 - * - * @param data 十六进制char[] - * @return byte[] - * @throws RuntimeException 如果源十六进制字符数组是一个奇怪的长度,将抛出运行时异常 - */ - public static byte[] decodeHex(char[] data) { - int len = data.length; - - if ((len & 0x01) != 0) { - throw new RuntimeException("Odd number of characters."); - } - - byte[] out = new byte[len >> 1]; - - // two characters form the hex value. - for (int i = 0, j = 0; j < len; i++) { - int f = toDigit(data[j], j) << 4; - j++; - f = f | toDigit(data[j], j); - j++; - out[i] = (byte) (f & 0xFF); - } - - return out; - } - - /** - * 将十六进制字符转换成一个整数 - * - * @param ch 十六进制char - * @param index 十六进制字符在字符数组中的位置 - * @return 一个整数 - * @throws RuntimeException 当ch不是一个合法的十六进制字符时,抛出运行时异常 - */ - protected static int toDigit(char ch, int index) { - int digit = Character.digit(ch, 16); - if (digit == -1) { - throw new RuntimeException("Illegal hexadecimal character " + ch - + " at index " + index); - } - return digit; - } - - /** - * 数字字符串转ASCII码字符串 - * - * @param String 字符串 - * @return ASCII字符串 - */ - public static String StringToAsciiString(String content) { - String result = ""; - int max = content.length(); - for (int i = 0; i < max; i++) { - char c = content.charAt(i); - String b = Integer.toHexString(c); - result = result + b; - } - return result; - } - - /** - * 十六进制转字符串 - * - * @param hexString 十六进制字符串 - * @param encodeType 编码类型4:Unicode,2:普通编码 - * @return 字符串 - */ - public static String hexStringToString(String hexString, int encodeType) { - String result = ""; - int max = hexString.length() / encodeType; - for (int i = 0; i < max; i++) { - char c = (char) hexStringToAlgorism(hexString - .substring(i * encodeType, (i + 1) * encodeType)); - result += c; - } - return result; - } - - /** - * 十六进制字符串装十进制 - * - * @param hex 十六进制字符串 - * @return 十进制数值 - */ - public static int hexStringToAlgorism(String hex) { - hex = hex.toUpperCase(); - int max = hex.length(); - int result = 0; - for (int i = max; i > 0; i--) { - char c = hex.charAt(i - 1); - int algorism = 0; - if (c >= '0' && c <= '9') { - algorism = c - '0'; - } else { - algorism = c - 55; - } - result += Math.pow(16, max - i) * algorism; - } - return result; - } - - /** - * 十六转二进制 - * - * @param hex 十六进制字符串 - * @return 二进制字符串 - */ - public static String hexStringToBinary(String hex) { - hex = hex.toUpperCase(); - String result = ""; - int max = hex.length(); - for (int i = 0; i < max; i++) { - char c = hex.charAt(i); - switch (c) { - case '0': - result += "0000"; - break; - case '1': - result += "0001"; - break; - case '2': - result += "0010"; - break; - case '3': - result += "0011"; - break; - case '4': - result += "0100"; - break; - case '5': - result += "0101"; - break; - case '6': - result += "0110"; - break; - case '7': - result += "0111"; - break; - case '8': - result += "1000"; - break; - case '9': - result += "1001"; - break; - case 'A': - result += "1010"; - break; - case 'B': - result += "1011"; - break; - case 'C': - result += "1100"; - break; - case 'D': - result += "1101"; - break; - case 'E': - result += "1110"; - break; - case 'F': - result += "1111"; - break; - } - } - return result; - } - - /** - * ASCII码字符串转数字字符串 - * - * @param String ASCII字符串 - * @return 字符串 - */ - public static String AsciiStringToString(String content) { - String result = ""; - int length = content.length() / 2; - for (int i = 0; i < length; i++) { - String c = content.substring(i * 2, i * 2 + 2); - int a = hexStringToAlgorism(c); - char b = (char) a; - String d = String.valueOf(b); - result += d; - } - return result; - } - - /** - * 将十进制转换为指定长度的十六进制字符串 - * - * @param algorism int 十进制数字 - * @param maxLength int 转换后的十六进制字符串长度 - * @return String 转换后的十六进制字符串 - */ - public static String algorismToHexString(int algorism, int maxLength) { - String result = ""; - result = Integer.toHexString(algorism); - - if (result.length() % 2 == 1) { - result = "0" + result; - } - return patchHexString(result.toUpperCase(), maxLength); - } - - /** - * 字节数组转为普通字符串(ASCII对应的字符) - * - * @param bytearray byte[] - * @return String - */ - public static String byteToString(byte[] bytearray) { - String result = ""; - char temp; - - int length = bytearray.length; - for (int i = 0; i < length; i++) { - temp = (char) bytearray[i]; - result += temp; - } - return result; - } - - /** - * 二进制字符串转十进制 - * - * @param binary 二进制字符串 - * @return 十进制数值 - */ - public static int binaryToAlgorism(String binary) { - int max = binary.length(); - int result = 0; - for (int i = max; i > 0; i--) { - char c = binary.charAt(i - 1); - int algorism = c - '0'; - result += Math.pow(2, max - i) * algorism; - } - return result; - } - - /** - * 十进制转换为十六进制字符串 - * - * @param algorism int 十进制的数字 - * @return String 对应的十六进制字符串 - */ - public static String algorismToHEXString(int algorism) { - String result = ""; - result = Integer.toHexString(algorism); - - if (result.length() % 2 == 1) { - result = "0" + result; - - } - result = result.toUpperCase(); - - return result; - } - - /** - * HEX字符串前补0,主要用于长度位数不足。 - * - * @param str String 需要补充长度的十六进制字符串 - * @param maxLength int 补充后十六进制字符串的长度 - * @return 补充结果 - */ - static public String patchHexString(String str, int maxLength) { - String temp = ""; - for (int i = 0; i < maxLength - str.length(); i++) { - temp = "0" + temp; - } - str = (temp + str).substring(0, maxLength); - return str; - } - - /** - * 将一个字符串转换为int - * - * @param s String 要转换的字符串 - * @param defaultInt int 如果出现异常,默认返回的数字 - * @param radix int 要转换的字符串是什么进制的,如16 8 10. - * @return int 转换后的数字 - */ - public static int parseToInt(String s, int defaultInt, int radix) { - int i = 0; - try { - i = Integer.parseInt(s, radix); - } catch (NumberFormatException ex) { - i = defaultInt; - } - return i; - } - - /** - * 将一个十进制形式的数字字符串转换为int - * - * @param s String 要转换的字符串 - * @param defaultInt int 如果出现异常,默认返回的数字 - * @return int 转换后的数字 - */ - public static int parseToInt(String s, int defaultInt) { - int i = 0; - try { - i = Integer.parseInt(s); - } catch (NumberFormatException ex) { - i = defaultInt; - } - return i; - } - - /** - * 十六进制串转化为byte数组 - * - * @return the array of byte - */ - public static byte[] hexToByte(String hex) - throws IllegalArgumentException { - if (hex.length() % 2 != 0) { - throw new IllegalArgumentException(); - } - char[] arr = hex.toCharArray(); - byte[] b = new byte[hex.length() / 2]; - for (int i = 0, j = 0, l = hex.length(); i < l; i++, j++) { - String swap = "" + arr[i++] + arr[i]; - int byteint = Integer.parseInt(swap, 16) & 0xFF; - b[j] = new Integer(byteint).byteValue(); - } - return b; - } - - /** - * 字节数组转换为十六进制字符串 - * - * @param b byte[] 需要转换的字节数组 - * @return String 十六进制字符串 - */ - public static String byteToHex(byte b[]) { - if (b == null) { - throw new IllegalArgumentException( - "Argument b ( byte array ) is null! "); - } - String hs = ""; - String stmp = ""; - for (int n = 0; n < b.length; n++) { - stmp = Integer.toHexString(b[n] & 0xff); - if (stmp.length() == 1) { - hs = hs + "0" + stmp; - } else { - hs = hs + stmp; - } - } - return hs.toLowerCase(); - //return hs.toUpperCase(); - } - - public static byte[] subByte(byte[] input, int startIndex, int length) { - byte[] bt = new byte[length]; - for (int i = 0; i < length; i++) { - bt[i] = input[i + startIndex]; - } - return bt; - } -} \ No newline at end of file diff --git a/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala b/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala deleted file mode 100644 index 7ee297ee4..000000000 --- a/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala +++ /dev/null @@ -1,27 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise - -import org.apache.spark.sql.SparkSession -import com.bytedance.aml.enterprise.sparkudf.SM4 - - -object Main { - def main(args: Array[String]) { - var sm4udf = new SM4() - System.out.println(sm4udf.call("123")) - } -} diff --git a/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/Hist.scala b/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/Hist.scala deleted file mode 100644 index 5f1cfb38a..000000000 --- a/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/Hist.scala +++ /dev/null @@ -1,25 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise.sparkudaf - -import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder -import org.apache.spark.sql.expressions.UserDefinedFunction -import org.apache.spark.sql.functions - -object Hist{ - def getFunc: UserDefinedFunction = functions.udaf(HistUDAF, ExpressionEncoder[HistIn]) - -} \ No newline at end of file diff --git a/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/HistUDAF.scala b/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/HistUDAF.scala deleted file mode 100644 index 44cabc257..000000000 --- a/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/HistUDAF.scala +++ /dev/null @@ -1,65 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise.sparkudaf - -import org.apache.spark.sql.expressions.Aggregator -import org.apache.spark.sql.{Encoder, Encoders} - -case class HistIn(var value: Double, var min: Double, var max: Double, var binsNum: Int, var interval: Double) -case class Bucket(var bins: Array[Double], var counts: Array[Int]) - -object HistUDAF extends Aggregator[HistIn, Bucket, Bucket]{ - - def zero: Bucket = Bucket(bins = new Array[Double](0), counts = new Array[Int](0)) - - def reduce(buffer: Bucket, data: HistIn): Bucket = { - if (buffer.bins.length == 0) { - buffer.bins = new Array[Double](data.binsNum + 1) - for (i <- 0 until data.binsNum) { - buffer.bins(i) = i * data.interval + data.min - } - buffer.bins(data.binsNum) = data.max - buffer.counts = new Array[Int](data.binsNum) - } - if (data.interval != 0.0){ - var bucket_idx = ((data.value - data.min) / data.interval).toInt - if (bucket_idx < 0) { - bucket_idx = 0 - } else if (bucket_idx > (data.binsNum - 1)){ - bucket_idx = data.binsNum - 1 - } - buffer.counts(bucket_idx) += 1 - } - buffer - } - - - def merge(b1: Bucket, b2: Bucket): Bucket = { - (b1.bins.length, b2.bins.length) match { - case (_, 0) => b1 - case (0, _) => b2 - case _ => b1.counts = (b1.counts zip b2.counts) map (x => x._1 + x._2) - b1 - } - } - - def finish(reduction: Bucket): Bucket = reduction - - def bufferEncoder: Encoder[Bucket] = Encoders.product - - def outputEncoder: Encoder[Bucket] = Encoders.product - -} \ No newline at end of file From 769c2280ac799d8c4f4965c6a50c3189b0e56d49 Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Tue, 7 Feb 2023 17:10:07 +0800 Subject: [PATCH 06/15] [WebConsole] Sync to github manually --- data_processing/.gitignore | 1 + data_processing/README.md | 14 +++ data_processing/pom.xml | 115 ++++++++++++++++++ data_processing/spark/csv_to_hive.py | 36 ++++++ data_processing/spark/hive_to_csv.py | 36 ++++++ .../com/bytedance/aml/enterprise/Main.scala | 26 ++++ .../aml/enterprise/sparkudaf/Hist.scala | 25 ++++ .../aml/enterprise/sparkudaf/HistUDAF.scala | 65 ++++++++++ operator/hack/boilerplate.go.txt | 2 +- 9 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 data_processing/.gitignore create mode 100644 data_processing/README.md create mode 100644 data_processing/pom.xml create mode 100644 data_processing/spark/csv_to_hive.py create mode 100644 data_processing/spark/hive_to_csv.py create mode 100644 data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala create mode 100644 data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/Hist.scala create mode 100644 data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/HistUDAF.scala diff --git a/data_processing/.gitignore b/data_processing/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/data_processing/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/data_processing/README.md b/data_processing/README.md new file mode 100644 index 000000000..7f8290fe6 --- /dev/null +++ b/data_processing/README.md @@ -0,0 +1,14 @@ +## Package +```shell +mvn clean scala:compile assembly:single +``` + +## Run +```shell +mvn scala:run -DmainClass=com.bytedance.aml.enterprise.Main +``` + +## Dependencies +* Spark 3.0.1 +* Java 8 +* Scala 2.12 diff --git a/data_processing/pom.xml b/data_processing/pom.xml new file mode 100644 index 000000000..4308b76ac --- /dev/null +++ b/data_processing/pom.xml @@ -0,0 +1,115 @@ +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <groupId>com.bytedance.aml.enterprise</groupId> + <artifactId>sm4spark</artifactId> + <version>0.0.1-SNAPSHOT</version> + + <properties> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + <spark.version>3.0.3</spark.version> + + <java.version>1.8</java.version> + <scala.major.version>2.12</scala.major.version> + <scala.version>2.12.10</scala.version> + + <maven.compiler.target>1.8</maven.compiler.target> + <maven.compiler.source>1.8</maven.compiler.source> + <maven.compiler.release>8</maven.compiler.release> + </properties> + + <dependencies> + <!-- Scala --> + <dependency> + <groupId>org.scala-lang</groupId> + <artifactId>scala-library</artifactId> + <version>${scala.version}</version> + </dependency> + + <dependency> + <groupId>commons-codec</groupId> + <artifactId>commons-codec</artifactId> + <version>1.15</version> + </dependency> + + <dependency> + <groupId>org.apache.spark</groupId> + <artifactId>spark-sql_2.12</artifactId> + <version>${spark.version}</version> + <scope>provided</scope> + <exclusions> + <exclusion> + <groupId>com.google.guava</groupId> + <artifactId>guava</artifactId> + </exclusion> + </exclusions> + </dependency> + </dependencies> + + <build> + <pluginManagement> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <configuration> + <source>${java.version}</source> + <target>${java.version}</target> + </configuration> + </plugin> + + <plugin> + <groupId>net.alchim31.maven</groupId> + <artifactId>scala-maven-plugin</artifactId> + <version>4.3.0</version> + <executions> + <execution> + <id>scala-compile-first</id> + <phase>process-resources</phase> + <goals> + <goal>add-source</goal> + <goal>compile</goal> + </goals> + </execution> + <execution> + <id>scala-test-compile</id> + <phase>process-test-resources</phase> + <goals> + <goal>testCompile</goal> + </goals> + </execution> + </executions> + <configuration> + <scalaVersion>${scala.version}</scalaVersion> + </configuration> + </plugin> + + <!-- scala assembly--> + <plugin> + <artifactId>maven-assembly-plugin</artifactId> + <configuration> + <finalName>${project.artifactId}-${project.version}-RELEASE</finalName> + <archive> + <manifest> + <mainClass>fully.qualified.MainClass</mainClass> + </manifest> + </archive> + <descriptorRefs> + <descriptorRef>jar-with-dependencies</descriptorRef> + </descriptorRefs> + </configuration> + <executions> + <execution> + <id>make-assembly</id> + <phase>package</phase> + <goals> + <goal>single</goal> + </goals> + </execution> + </executions> + </plugin> + </plugins> + </pluginManagement> + </build> +</project> \ No newline at end of file diff --git a/data_processing/spark/csv_to_hive.py b/data_processing/spark/csv_to_hive.py new file mode 100644 index 000000000..ef50a7c83 --- /dev/null +++ b/data_processing/spark/csv_to_hive.py @@ -0,0 +1,36 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from pyspark.sql import SparkSession +from pyspark.sql.functions import col, lower + + +def run(): + spark = SparkSession \ + .builder \ + .enableHiveSupport() \ + .config('hive.exec.dynamic.partition', 'true') \ + .config('hive.exec.dynamic.partition.mode', 'nonstrict') \ + .getOrCreate() + + df = spark.read.option('header', 'false') \ + .csv('/home/byte_aml_tob/fedlearner_v2/njb/reduced.csv') + df = df.select(lower(col(df.columns[0])).alias('phone_sha256')) + df.write.mode('overwrite').insertInto('aml_tob.njb_intersection_sha256') + spark.stop() + + +if __name__ == '__main__': + run() diff --git a/data_processing/spark/hive_to_csv.py b/data_processing/spark/hive_to_csv.py new file mode 100644 index 000000000..aa1a39b7c --- /dev/null +++ b/data_processing/spark/hive_to_csv.py @@ -0,0 +1,36 @@ +# Copyright 2023 The FedLearner Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from pyspark.sql import SparkSession + + +def run(): + spark = SparkSession \ + .builder \ + .enableHiveSupport() \ + .getOrCreate() + + df = spark.sql('SELECT DISTINCT uid FROM aml_tob.njb_intersection_uid WHERE uid IS NOT NULL') + + # Partition automatically + df.write.format('csv').option('compression', + 'none').option('header', + 'false').save('/home/byte_aml_tob/fedlearner_v2/njb/reduced_uid', + mode='overwrite') + spark.stop() + + +if __name__ == '__main__': + run() diff --git a/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala b/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala new file mode 100644 index 000000000..6fc87ce8e --- /dev/null +++ b/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala @@ -0,0 +1,26 @@ +/* Copyright 2023 The FedLearner Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytedance.aml.enterprise + +import org.apache.spark.sql.SparkSession + + +object Main { + def main(args: Array[String]) { + // trimmed + return + } +} diff --git a/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/Hist.scala b/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/Hist.scala new file mode 100644 index 000000000..5f1cfb38a --- /dev/null +++ b/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/Hist.scala @@ -0,0 +1,25 @@ +/* Copyright 2023 The FedLearner Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytedance.aml.enterprise.sparkudaf + +import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder +import org.apache.spark.sql.expressions.UserDefinedFunction +import org.apache.spark.sql.functions + +object Hist{ + def getFunc: UserDefinedFunction = functions.udaf(HistUDAF, ExpressionEncoder[HistIn]) + +} \ No newline at end of file diff --git a/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/HistUDAF.scala b/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/HistUDAF.scala new file mode 100644 index 000000000..44cabc257 --- /dev/null +++ b/data_processing/src/main/scala/com/bytedance/aml/enterprise/sparkudaf/HistUDAF.scala @@ -0,0 +1,65 @@ +/* Copyright 2023 The FedLearner Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bytedance.aml.enterprise.sparkudaf + +import org.apache.spark.sql.expressions.Aggregator +import org.apache.spark.sql.{Encoder, Encoders} + +case class HistIn(var value: Double, var min: Double, var max: Double, var binsNum: Int, var interval: Double) +case class Bucket(var bins: Array[Double], var counts: Array[Int]) + +object HistUDAF extends Aggregator[HistIn, Bucket, Bucket]{ + + def zero: Bucket = Bucket(bins = new Array[Double](0), counts = new Array[Int](0)) + + def reduce(buffer: Bucket, data: HistIn): Bucket = { + if (buffer.bins.length == 0) { + buffer.bins = new Array[Double](data.binsNum + 1) + for (i <- 0 until data.binsNum) { + buffer.bins(i) = i * data.interval + data.min + } + buffer.bins(data.binsNum) = data.max + buffer.counts = new Array[Int](data.binsNum) + } + if (data.interval != 0.0){ + var bucket_idx = ((data.value - data.min) / data.interval).toInt + if (bucket_idx < 0) { + bucket_idx = 0 + } else if (bucket_idx > (data.binsNum - 1)){ + bucket_idx = data.binsNum - 1 + } + buffer.counts(bucket_idx) += 1 + } + buffer + } + + + def merge(b1: Bucket, b2: Bucket): Bucket = { + (b1.bins.length, b2.bins.length) match { + case (_, 0) => b1 + case (0, _) => b2 + case _ => b1.counts = (b1.counts zip b2.counts) map (x => x._1 + x._2) + b1 + } + } + + def finish(reduction: Bucket): Bucket = reduction + + def bufferEncoder: Encoder[Bucket] = Encoders.product + + def outputEncoder: Encoder[Bucket] = Encoders.product + +} \ No newline at end of file diff --git a/operator/hack/boilerplate.go.txt b/operator/hack/boilerplate.go.txt index 45dbbbbcf..65b862271 100644 --- a/operator/hack/boilerplate.go.txt +++ b/operator/hack/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright 2021. +Copyright 2023. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From d7e9cf27c85e8820f9c3bc1a9a973d70edf958e2 Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Wed, 8 Feb 2023 15:23:26 +0800 Subject: [PATCH 07/15] [WebConsole] Sync to github manually --- .../data_join/psi_ot/joiner/ot_psi_joiner.py | 13 ++++----- .../psi_ot/joiner/ot_psi_joiner_test.py | 27 +++++++++++-------- pp_lite/test/psi_ot_test.py | 13 +++++++-- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/pp_lite/data_join/psi_ot/joiner/ot_psi_joiner.py b/pp_lite/data_join/psi_ot/joiner/ot_psi_joiner.py index 68d41b348..1093a472a 100644 --- a/pp_lite/data_join/psi_ot/joiner/ot_psi_joiner.py +++ b/pp_lite/data_join/psi_ot/joiner/ot_psi_joiner.py @@ -16,7 +16,6 @@ import fsspec import logging import datetime -import subprocess from enum import Enum from typing import List @@ -24,8 +23,6 @@ from pp_lite.proto.common_pb2 import DataJoinType from pp_lite.data_join.psi_ot.joiner.joiner_interface import Joiner -CMD = '/app/psi_oprf/bin/PSI_test' - def _write_ids(filename: str, ids: List[str]): with fsspec.open(filename, 'wt') as f: @@ -59,10 +56,14 @@ def _run(self, ids: List[str], role: _Role): input_path = f'{envs.STORAGE_ROOT}/data/{role.name}-input-{timestamp}' output_path = f'{envs.STORAGE_ROOT}/data/{role.name}-output-{timestamp}' _write_ids(input_path, ids) - cmd = f'{CMD} -r {role.value} -file {input_path} -ofile {output_path} -ip localhost:{self.joiner_port}'.split() - logging.info(f'[OtPsiJoiner] run cmd: {cmd}') + # cmd = f'{CMD} -r {role.value} -file {input_path} -ofile {output_path} && \ + # -ip localhost:{self.joiner_port}'.split() + # logging.info(f'[OtPsiJoiner] run cmd: {cmd}') try: - subprocess.run(cmd, check=True) + import psi_oprf # pylint: disable=import-outside-toplevel + psi_oprf.PsiRun(role.value, input_path, output_path, f'localhost:{self.joiner_port}') + logging.info('[ot_psi_joiner] PsiRun finished.') + # subprocess.run(cmd, check=True) joined_ids = _read_ids(output_path) except Exception as e: # pylint: disable=broad-except logging.exception('[OtPsiJoiner] error happened during ot psi!') diff --git a/pp_lite/data_join/psi_ot/joiner/ot_psi_joiner_test.py b/pp_lite/data_join/psi_ot/joiner/ot_psi_joiner_test.py index 15c923054..9617436a1 100644 --- a/pp_lite/data_join/psi_ot/joiner/ot_psi_joiner_test.py +++ b/pp_lite/data_join/psi_ot/joiner/ot_psi_joiner_test.py @@ -13,14 +13,15 @@ # limitations under the License. # -import os import unittest from typing import List from unittest.mock import patch from tempfile import TemporaryDirectory from concurrent.futures import ThreadPoolExecutor +import importlib.util as imutil + from pp_lite.data_join import envs -from pp_lite.data_join.psi_ot.joiner.ot_psi_joiner import OtPsiJoiner, CMD +from pp_lite.data_join.psi_ot.joiner.ot_psi_joiner import OtPsiJoiner def _write_fake_output(filename: str, ids: List[str]): @@ -28,10 +29,19 @@ def _write_fake_output(filename: str, ids: List[str]): f.write('\n'.join(ids)) +def check_psi_oprf(): + spec = imutil.find_spec('psi_oprf') + if spec is None: + psi_oprf_existed = False + else: + psi_oprf_existed = True + return psi_oprf_existed + + +@unittest.skipUnless(check_psi_oprf(), 'require ot psi file') class OtPsiJoinerTest(unittest.TestCase): @patch('pp_lite.data_join.psi_ot.joiner.ot_psi_joiner._timestamp') - @patch('subprocess.run') def test_client_run(self, mock_run, mock_timestamp): joiner = OtPsiJoiner(joiner_port=12345) timestamp = '20220310-185545' @@ -47,13 +57,10 @@ def _side_effect(*args, **kwargs): mock_run.side_effect = _side_effect ids = joiner.client_run(['1', '2', '3']) - mock_run.assert_called_with( - f'/app/psi_oprf/bin/PSI_test -r 0 -file {input_path} -ofile {output_path} -ip localhost:12345'.split(), - check=True) + mock_run.assert_called_with(0, input_path, output_path, f'localhost:{self.joiner_port}') self.assertEqual(ids, inter_ids) @patch('pp_lite.data_join.psi_ot.joiner.ot_psi_joiner._timestamp') - @patch('subprocess.run') def test_server_run(self, mock_run, mock_timestamp): joiner = OtPsiJoiner(joiner_port=12345) timestamp = '20220310-185545' @@ -69,13 +76,11 @@ def _side_effect(*args, **kwargs): mock_run.side_effect = _side_effect ids = joiner.server_run(['1', '2', '3']) - mock_run.assert_called_with( - f'/app/psi_oprf/bin/PSI_test -r 1 -file {input_path} -ofile {output_path} -ip localhost:12345'.split(), - check=True) + mock_run.assert_called_with(1, input_path, output_path, f'localhost:{self.joiner_port}') self.assertEqual(ids, inter_ids) -@unittest.skipUnless(os.path.exists(CMD), 'require ot psi file') +@unittest.skipUnless(check_psi_oprf(), 'require ot psi file') class OtPsiJoinerInContainerTest(unittest.TestCase): def test_joiner(self): diff --git a/pp_lite/test/psi_ot_test.py b/pp_lite/test/psi_ot_test.py index ef11759d8..2716522ef 100644 --- a/pp_lite/test/psi_ot_test.py +++ b/pp_lite/test/psi_ot_test.py @@ -18,6 +18,7 @@ import unittest import tempfile from concurrent.futures import ThreadPoolExecutor +import importlib.util as imutil from pp_lite.data_join import envs from pp_lite.proto.arguments_pb2 import Arguments @@ -25,7 +26,15 @@ from pp_lite.data_join.psi_ot.client import run as client_run from pp_lite.data_join.psi_ot.server import run as server_run from pp_lite.testing.make_data import make_data -from pp_lite.data_join.psi_ot.joiner.ot_psi_joiner import CMD + + +def check_psi_oprf(): + spec = imutil.find_spec('psi_oprf') + if spec is None: + psi_oprf_existed = False + else: + psi_oprf_existed = True + return psi_oprf_existed class IntegratedTest(unittest.TestCase): @@ -80,7 +89,7 @@ def _run(self, data_join_type: DataJoinType): def test_run_hashed_data_join(self): self._run(DataJoinType.HASHED_DATA_JOIN) - @unittest.skipUnless(os.path.exists(CMD), 'require ot psi file') + @unittest.skipUnless(check_psi_oprf(), 'require ot psi file') def test_ot_psi(self): self._run(DataJoinType.OT_PSI) From 01da24301b9cdf943ea22e5d90b2c67dde9b32ac Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Wed, 8 Feb 2023 19:26:33 +0800 Subject: [PATCH 08/15] [WebConsole] Sync to github manually --- docs/licenses/LICENCE-BurntSushi_toml.txt | 21 + docs/licenses/LICENCE-Go-Logrus.txt | 21 + docs/licenses/LICENCE-Go-Testify.txt | 21 + docs/licenses/LICENCE-GoDoc-Text.txt | 31 ++ docs/licenses/LICENCE-Microsoft-go-winio.txt | 21 + docs/licenses/LICENCE-Python_six.txt | 18 + docs/licenses/LICENCE-armon_go-socks5.txt | 20 + docs/licenses/LICENCE-benbjohnson-clock.txt | 21 + docs/licenses/LICENCE-beorn7-perks.txt | 20 + docs/licenses/LICENCE-cespare_xxhash.txt | 22 + docs/licenses/LICENCE-charset-normalizer.txt | 21 + docs/licenses/LICENCE-cpuguy83-go-md2man.txt | 21 + docs/licenses/LICENCE-create-react-app.txt | 26 ++ docs/licenses/LICENCE-dsnet_compress.txt | 28 ++ docs/licenses/LICENCE-evanphx_json-patch.txt | 25 ++ docs/licenses/LICENCE-frankban_quicktest.txt | 21 + docs/licenses/LICENCE-fsnotify.txt | 28 ++ docs/licenses/LICENCE-go-ansiterm.txt | 21 + docs/licenses/LICENCE-go-check-check.txt | 23 + docs/licenses/LICENCE-go-inf-inf.txt | 28 ++ docs/licenses/LICENCE-go-restful.txt | 21 + docs/licenses/LICENCE-go-spew.txt | 15 + docs/licenses/LICENCE-go-tomb-tomb.txt | 27 ++ docs/licenses/LICENCE-go-zap.txt | 19 + docs/licenses/LICENCE-go_uber_org_goleak.txt | 21 + .../licenses/LICENCE-go_uber_org_multierr.txt | 19 + docs/licenses/LICENCE-gogo-protobuf.txt | 28 ++ .../LICENCE-golang-github-spf13-pflag-dev.txt | 28 ++ docs/licenses/LICENCE-golang-jwt_jwt.txt | 8 + docs/licenses/LICENCE-golang-protobuf.txt | 27 ++ .../licenses/LICENCE-golang-snappy-go-dev.txt | 27 ++ docs/licenses/LICENCE-golang_org_x_net.txt | 28 ++ docs/licenses/LICENCE-golang_org_x_oauth2.txt | 28 ++ docs/licenses/LICENCE-golang_org_x_sync.txt | 28 ++ docs/licenses/LICENCE-golang_org_x_term.txt | 28 ++ docs/licenses/LICENCE-golang_org_x_time.txt | 28 ++ docs/licenses/LICENCE-golang_text.txt | 28 ++ docs/licenses/LICENCE-gomega.txt | 21 + docs/licenses/LICENCE-google_go-cmp.txt | 28 ++ .../LICENCE-google_golang_org_protobuf.txt | 28 ++ docs/licenses/LICENCE-google_uuid.txt | 27 ++ docs/licenses/LICENCE-goproxy.txt | 28 ++ docs/licenses/LICENCE-gorilla_mux.txt | 27 ++ docs/licenses/LICENCE-idna.txt | 28 ++ docs/licenses/LICENCE-josharian_intern.txt | 23 + docs/licenses/LICENCE-jsoniter-go.txt | 21 + docs/licenses/LICENCE-kr-fs.txt | 27 ++ docs/licenses/LICENCE-kr_pretty.txt | 21 + docs/licenses/LICENCE-mailru_easyjson.txt | 7 + docs/licenses/LICENCE-melbahja_goph.txt | 21 + docs/licenses/LICENCE-mergo.txt | 28 ++ docs/licenses/LICENCE-mholt_archiver.txt | 21 + docs/licenses/LICENCE-morikuni_aec.txt | 21 + docs/licenses/LICENCE-munnerz_goautoneg.txt | 28 ++ docs/licenses/LICENCE-nwaples_rardecode.txt | 23 + docs/licenses/LICENCE-nxadm_tail.txt | 21 + docs/licenses/LICENCE-onsi_ginkgo.txt | 21 + docs/licenses/LICENCE-pierrec-lz4.txt | 27 ++ docs/licenses/LICENCE-pkg_errors.txt | 23 + docs/licenses/LICENCE-pmezard-go-difflib.txt | 35 ++ docs/licenses/LICENCE-purell.txt | 12 + docs/licenses/LICENCE-pypi_setuptools.txt | 19 + docs/licenses/LICENCE-python-certifi.txt | 409 ++++++++++++++++++ .../licenses/LICENCE-rogpeppe_go-internal.txt | 28 ++ docs/licenses/LICENCE-sftp.txt | 23 + docs/licenses/LICENCE-sigs_k8s_io_json.txt | 28 ++ docs/licenses/LICENCE-uber-go_atomic.txt | 19 + docs/licenses/LICENCE-ulikunitz_xz.txt | 27 ++ docs/licenses/LICENCE-urfAVE_cli.txt | 21 + docs/licenses/LICENCE-urlesc.txt | 27 ++ docs/licenses/LICENCE-urllib3.txt | 21 + docs/licenses/LICENCE-xrash_smetrics.txt | 21 + docs/licenses/LICENCE-yaml-for-Go.txt | 21 + 73 files changed, 2097 insertions(+) create mode 100644 docs/licenses/LICENCE-BurntSushi_toml.txt create mode 100644 docs/licenses/LICENCE-Go-Logrus.txt create mode 100644 docs/licenses/LICENCE-Go-Testify.txt create mode 100644 docs/licenses/LICENCE-GoDoc-Text.txt create mode 100644 docs/licenses/LICENCE-Microsoft-go-winio.txt create mode 100644 docs/licenses/LICENCE-Python_six.txt create mode 100644 docs/licenses/LICENCE-armon_go-socks5.txt create mode 100644 docs/licenses/LICENCE-benbjohnson-clock.txt create mode 100644 docs/licenses/LICENCE-beorn7-perks.txt create mode 100644 docs/licenses/LICENCE-cespare_xxhash.txt create mode 100644 docs/licenses/LICENCE-charset-normalizer.txt create mode 100644 docs/licenses/LICENCE-cpuguy83-go-md2man.txt create mode 100644 docs/licenses/LICENCE-create-react-app.txt create mode 100644 docs/licenses/LICENCE-dsnet_compress.txt create mode 100644 docs/licenses/LICENCE-evanphx_json-patch.txt create mode 100644 docs/licenses/LICENCE-frankban_quicktest.txt create mode 100644 docs/licenses/LICENCE-fsnotify.txt create mode 100644 docs/licenses/LICENCE-go-ansiterm.txt create mode 100644 docs/licenses/LICENCE-go-check-check.txt create mode 100644 docs/licenses/LICENCE-go-inf-inf.txt create mode 100644 docs/licenses/LICENCE-go-restful.txt create mode 100644 docs/licenses/LICENCE-go-spew.txt create mode 100644 docs/licenses/LICENCE-go-tomb-tomb.txt create mode 100644 docs/licenses/LICENCE-go-zap.txt create mode 100644 docs/licenses/LICENCE-go_uber_org_goleak.txt create mode 100644 docs/licenses/LICENCE-go_uber_org_multierr.txt create mode 100644 docs/licenses/LICENCE-gogo-protobuf.txt create mode 100644 docs/licenses/LICENCE-golang-github-spf13-pflag-dev.txt create mode 100644 docs/licenses/LICENCE-golang-jwt_jwt.txt create mode 100644 docs/licenses/LICENCE-golang-protobuf.txt create mode 100644 docs/licenses/LICENCE-golang-snappy-go-dev.txt create mode 100644 docs/licenses/LICENCE-golang_org_x_net.txt create mode 100644 docs/licenses/LICENCE-golang_org_x_oauth2.txt create mode 100644 docs/licenses/LICENCE-golang_org_x_sync.txt create mode 100644 docs/licenses/LICENCE-golang_org_x_term.txt create mode 100644 docs/licenses/LICENCE-golang_org_x_time.txt create mode 100644 docs/licenses/LICENCE-golang_text.txt create mode 100644 docs/licenses/LICENCE-gomega.txt create mode 100644 docs/licenses/LICENCE-google_go-cmp.txt create mode 100644 docs/licenses/LICENCE-google_golang_org_protobuf.txt create mode 100644 docs/licenses/LICENCE-google_uuid.txt create mode 100644 docs/licenses/LICENCE-goproxy.txt create mode 100644 docs/licenses/LICENCE-gorilla_mux.txt create mode 100644 docs/licenses/LICENCE-idna.txt create mode 100644 docs/licenses/LICENCE-josharian_intern.txt create mode 100644 docs/licenses/LICENCE-jsoniter-go.txt create mode 100644 docs/licenses/LICENCE-kr-fs.txt create mode 100644 docs/licenses/LICENCE-kr_pretty.txt create mode 100644 docs/licenses/LICENCE-mailru_easyjson.txt create mode 100644 docs/licenses/LICENCE-melbahja_goph.txt create mode 100644 docs/licenses/LICENCE-mergo.txt create mode 100644 docs/licenses/LICENCE-mholt_archiver.txt create mode 100644 docs/licenses/LICENCE-morikuni_aec.txt create mode 100644 docs/licenses/LICENCE-munnerz_goautoneg.txt create mode 100644 docs/licenses/LICENCE-nwaples_rardecode.txt create mode 100644 docs/licenses/LICENCE-nxadm_tail.txt create mode 100644 docs/licenses/LICENCE-onsi_ginkgo.txt create mode 100644 docs/licenses/LICENCE-pierrec-lz4.txt create mode 100644 docs/licenses/LICENCE-pkg_errors.txt create mode 100644 docs/licenses/LICENCE-pmezard-go-difflib.txt create mode 100644 docs/licenses/LICENCE-purell.txt create mode 100644 docs/licenses/LICENCE-pypi_setuptools.txt create mode 100644 docs/licenses/LICENCE-python-certifi.txt create mode 100644 docs/licenses/LICENCE-rogpeppe_go-internal.txt create mode 100644 docs/licenses/LICENCE-sftp.txt create mode 100644 docs/licenses/LICENCE-sigs_k8s_io_json.txt create mode 100644 docs/licenses/LICENCE-uber-go_atomic.txt create mode 100644 docs/licenses/LICENCE-ulikunitz_xz.txt create mode 100644 docs/licenses/LICENCE-urfAVE_cli.txt create mode 100644 docs/licenses/LICENCE-urlesc.txt create mode 100644 docs/licenses/LICENCE-urllib3.txt create mode 100644 docs/licenses/LICENCE-xrash_smetrics.txt create mode 100644 docs/licenses/LICENCE-yaml-for-Go.txt diff --git a/docs/licenses/LICENCE-BurntSushi_toml.txt b/docs/licenses/LICENCE-BurntSushi_toml.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-BurntSushi_toml.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-Go-Logrus.txt b/docs/licenses/LICENCE-Go-Logrus.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-Go-Logrus.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-Go-Testify.txt b/docs/licenses/LICENCE-Go-Testify.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-Go-Testify.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-GoDoc-Text.txt b/docs/licenses/LICENCE-GoDoc-Text.txt new file mode 100644 index 000000000..77113a54b --- /dev/null +++ b/docs/licenses/LICENCE-GoDoc-Text.txt @@ -0,0 +1,31 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: github.com/kr/text +Source: https://github.com/kr/text/ + +Files: * +Copyright: 2013 Keith Rarick <kr@xph.us> +License: Expat + +Files: debian/* +Copyright: 2013 Tonnerre Lombard <tonnerre@ancient-solutions.com> +License: Expat + +License: Expat + +Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE diff --git a/docs/licenses/LICENCE-Microsoft-go-winio.txt b/docs/licenses/LICENCE-Microsoft-go-winio.txt new file mode 100644 index 000000000..fa365be22 --- /dev/null +++ b/docs/licenses/LICENCE-Microsoft-go-winio.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Microsoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/docs/licenses/LICENCE-Python_six.txt b/docs/licenses/LICENCE-Python_six.txt new file mode 100644 index 000000000..01de9e22d --- /dev/null +++ b/docs/licenses/LICENCE-Python_six.txt @@ -0,0 +1,18 @@ +Copyright (c) 2010-2018 Benjamin Peterson + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE diff --git a/docs/licenses/LICENCE-armon_go-socks5.txt b/docs/licenses/LICENCE-armon_go-socks5.txt new file mode 100644 index 000000000..94fadc2a9 --- /dev/null +++ b/docs/licenses/LICENCE-armon_go-socks5.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Armon Dadgar + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE diff --git a/docs/licenses/LICENCE-benbjohnson-clock.txt b/docs/licenses/LICENCE-benbjohnson-clock.txt new file mode 100644 index 000000000..0dfeb1d6a --- /dev/null +++ b/docs/licenses/LICENCE-benbjohnson-clock.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ben Johnson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/docs/licenses/LICENCE-beorn7-perks.txt b/docs/licenses/LICENCE-beorn7-perks.txt new file mode 100644 index 000000000..9316a10d2 --- /dev/null +++ b/docs/licenses/LICENCE-beorn7-perks.txt @@ -0,0 +1,20 @@ +Copyright (C) 2013 Blake Mizerany + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE diff --git a/docs/licenses/LICENCE-cespare_xxhash.txt b/docs/licenses/LICENCE-cespare_xxhash.txt new file mode 100644 index 000000000..341bd91f0 --- /dev/null +++ b/docs/licenses/LICENCE-cespare_xxhash.txt @@ -0,0 +1,22 @@ +Copyright (c) 2016 Caleb Spare + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE diff --git a/docs/licenses/LICENCE-charset-normalizer.txt b/docs/licenses/LICENCE-charset-normalizer.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-charset-normalizer.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-cpuguy83-go-md2man.txt b/docs/licenses/LICENCE-cpuguy83-go-md2man.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-cpuguy83-go-md2man.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-create-react-app.txt b/docs/licenses/LICENCE-create-react-app.txt new file mode 100644 index 000000000..a73b785a6 --- /dev/null +++ b/docs/licenses/LICENCE-create-react-app.txt @@ -0,0 +1,26 @@ +Copyright (c) 2016-present, Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-dsnet_compress.txt b/docs/licenses/LICENCE-dsnet_compress.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-dsnet_compress.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-evanphx_json-patch.txt b/docs/licenses/LICENCE-evanphx_json-patch.txt new file mode 100644 index 000000000..050fe60f0 --- /dev/null +++ b/docs/licenses/LICENCE-evanphx_json-patch.txt @@ -0,0 +1,25 @@ +Copyright (c) 2014, Evan Phoenix +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the Evan Phoenix nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-frankban_quicktest.txt b/docs/licenses/LICENCE-frankban_quicktest.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-frankban_quicktest.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-fsnotify.txt b/docs/licenses/LICENCE-fsnotify.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-fsnotify.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-go-ansiterm.txt b/docs/licenses/LICENCE-go-ansiterm.txt new file mode 100644 index 000000000..b86c36e25 --- /dev/null +++ b/docs/licenses/LICENCE-go-ansiterm.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE diff --git a/docs/licenses/LICENCE-go-check-check.txt b/docs/licenses/LICENCE-go-check-check.txt new file mode 100644 index 000000000..9ac6ae0a6 --- /dev/null +++ b/docs/licenses/LICENCE-go-check-check.txt @@ -0,0 +1,23 @@ +BSD Two Clause License +====================== + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/licenses/LICENCE-go-inf-inf.txt b/docs/licenses/LICENCE-go-inf-inf.txt new file mode 100644 index 000000000..e923f606e --- /dev/null +++ b/docs/licenses/LICENCE-go-inf-inf.txt @@ -0,0 +1,28 @@ +Copyright (c) 2012 Péter Surányi. Portions Copyright (c) 2009 The Go +Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-go-restful.txt b/docs/licenses/LICENCE-go-restful.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-go-restful.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-go-spew.txt b/docs/licenses/LICENCE-go-spew.txt new file mode 100644 index 000000000..223583735 --- /dev/null +++ b/docs/licenses/LICENCE-go-spew.txt @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2012-2016 Dave Collins <dave@davec.name> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE diff --git a/docs/licenses/LICENCE-go-tomb-tomb.txt b/docs/licenses/LICENCE-go-tomb-tomb.txt new file mode 100644 index 000000000..db0834849 --- /dev/null +++ b/docs/licenses/LICENCE-go-tomb-tomb.txt @@ -0,0 +1,27 @@ +Copyright (c) 2010-2011 - Gustavo Niemeyer <gustavo@niemeyer.net> + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-go-zap.txt b/docs/licenses/LICENCE-go-zap.txt new file mode 100644 index 000000000..82a1dd0dc --- /dev/null +++ b/docs/licenses/LICENCE-go-zap.txt @@ -0,0 +1,19 @@ +Copyright (c) 2016-2017 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE diff --git a/docs/licenses/LICENCE-go_uber_org_goleak.txt b/docs/licenses/LICENCE-go_uber_org_goleak.txt new file mode 100644 index 000000000..a0e4cc690 --- /dev/null +++ b/docs/licenses/LICENCE-go_uber_org_goleak.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2018 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE diff --git a/docs/licenses/LICENCE-go_uber_org_multierr.txt b/docs/licenses/LICENCE-go_uber_org_multierr.txt new file mode 100644 index 000000000..fe9e5258b --- /dev/null +++ b/docs/licenses/LICENCE-go_uber_org_multierr.txt @@ -0,0 +1,19 @@ +Copyright (c) 2017 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE diff --git a/docs/licenses/LICENCE-gogo-protobuf.txt b/docs/licenses/LICENCE-gogo-protobuf.txt new file mode 100644 index 000000000..748f3b3ee --- /dev/null +++ b/docs/licenses/LICENCE-gogo-protobuf.txt @@ -0,0 +1,28 @@ +Copyright 2010 The Go Authors. All rights reserved. +https://github.com/golang/protobuf + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-golang-github-spf13-pflag-dev.txt b/docs/licenses/LICENCE-golang-github-spf13-pflag-dev.txt new file mode 100644 index 000000000..e6a8ddc0d --- /dev/null +++ b/docs/licenses/LICENCE-golang-github-spf13-pflag-dev.txt @@ -0,0 +1,28 @@ +Copyright (c) 2012 Alex Ogier. All rights reserved. +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-golang-jwt_jwt.txt b/docs/licenses/LICENCE-golang-jwt_jwt.txt new file mode 100644 index 000000000..95135bb75 --- /dev/null +++ b/docs/licenses/LICENCE-golang-jwt_jwt.txt @@ -0,0 +1,8 @@ +Copyright (c) 2012 Dave Grijalva +Copyright (c) 2021 golang-jwt maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE diff --git a/docs/licenses/LICENCE-golang-protobuf.txt b/docs/licenses/LICENCE-golang-protobuf.txt new file mode 100644 index 000000000..ed122f2d6 --- /dev/null +++ b/docs/licenses/LICENCE-golang-protobuf.txt @@ -0,0 +1,27 @@ +Copyright 2010 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-golang-snappy-go-dev.txt b/docs/licenses/LICENCE-golang-snappy-go-dev.txt new file mode 100644 index 000000000..cf9059d9d --- /dev/null +++ b/docs/licenses/LICENCE-golang-snappy-go-dev.txt @@ -0,0 +1,27 @@ +Copyright (c) 2011 The Snappy-Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-golang_org_x_net.txt b/docs/licenses/LICENCE-golang_org_x_net.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-golang_org_x_net.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_org_x_oauth2.txt b/docs/licenses/LICENCE-golang_org_x_oauth2.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-golang_org_x_oauth2.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_org_x_sync.txt b/docs/licenses/LICENCE-golang_org_x_sync.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-golang_org_x_sync.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_org_x_term.txt b/docs/licenses/LICENCE-golang_org_x_term.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-golang_org_x_term.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_org_x_time.txt b/docs/licenses/LICENCE-golang_org_x_time.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-golang_org_x_time.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_text.txt b/docs/licenses/LICENCE-golang_text.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-golang_text.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-gomega.txt b/docs/licenses/LICENCE-gomega.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-gomega.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-google_go-cmp.txt b/docs/licenses/LICENCE-google_go-cmp.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-google_go-cmp.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-google_golang_org_protobuf.txt b/docs/licenses/LICENCE-google_golang_org_protobuf.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-google_golang_org_protobuf.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-google_uuid.txt b/docs/licenses/LICENCE-google_uuid.txt new file mode 100644 index 000000000..3726ed0a0 --- /dev/null +++ b/docs/licenses/LICENCE-google_uuid.txt @@ -0,0 +1,27 @@ +Copyright (c) 2009,2014 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-goproxy.txt b/docs/licenses/LICENCE-goproxy.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-goproxy.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-gorilla_mux.txt b/docs/licenses/LICENCE-gorilla_mux.txt new file mode 100644 index 000000000..5da121e53 --- /dev/null +++ b/docs/licenses/LICENCE-gorilla_mux.txt @@ -0,0 +1,27 @@ +Copyright (c) 2012-2018 The Gorilla Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-idna.txt b/docs/licenses/LICENCE-idna.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-idna.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-josharian_intern.txt b/docs/licenses/LICENCE-josharian_intern.txt new file mode 100644 index 000000000..0096c79c6 --- /dev/null +++ b/docs/licenses/LICENCE-josharian_intern.txt @@ -0,0 +1,23 @@ +2020 Roger Shimizu <rosh@debian.org> +License: Expat +Comment: Debian packaging is licensed under the same terms as upstream + +License: Expat + +Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/docs/licenses/LICENCE-jsoniter-go.txt b/docs/licenses/LICENCE-jsoniter-go.txt new file mode 100644 index 000000000..f6dfb8773 --- /dev/null +++ b/docs/licenses/LICENCE-jsoniter-go.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 json-iterator + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/docs/licenses/LICENCE-kr-fs.txt b/docs/licenses/LICENCE-kr-fs.txt new file mode 100644 index 000000000..76427ff52 --- /dev/null +++ b/docs/licenses/LICENCE-kr-fs.txt @@ -0,0 +1,27 @@ +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-kr_pretty.txt b/docs/licenses/LICENCE-kr_pretty.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-kr_pretty.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-mailru_easyjson.txt b/docs/licenses/LICENCE-mailru_easyjson.txt new file mode 100644 index 000000000..620fb1f5b --- /dev/null +++ b/docs/licenses/LICENCE-mailru_easyjson.txt @@ -0,0 +1,7 @@ +Copyright (c) 2016 Mail.Ru Group + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE diff --git a/docs/licenses/LICENCE-melbahja_goph.txt b/docs/licenses/LICENCE-melbahja_goph.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-melbahja_goph.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-mergo.txt b/docs/licenses/LICENCE-mergo.txt new file mode 100644 index 000000000..068cab72d --- /dev/null +++ b/docs/licenses/LICENCE-mergo.txt @@ -0,0 +1,28 @@ +Copyright (c) 2013 Dario Castañé. All rights reserved. +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-mholt_archiver.txt b/docs/licenses/LICENCE-mholt_archiver.txt new file mode 100644 index 000000000..54bc89fa0 --- /dev/null +++ b/docs/licenses/LICENCE-mholt_archiver.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Matthew Holt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/docs/licenses/LICENCE-morikuni_aec.txt b/docs/licenses/LICENCE-morikuni_aec.txt new file mode 100644 index 000000000..7504d0682 --- /dev/null +++ b/docs/licenses/LICENCE-morikuni_aec.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Taihei Morikuni + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/docs/licenses/LICENCE-munnerz_goautoneg.txt b/docs/licenses/LICENCE-munnerz_goautoneg.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-munnerz_goautoneg.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-nwaples_rardecode.txt b/docs/licenses/LICENCE-nwaples_rardecode.txt new file mode 100644 index 000000000..160337a36 --- /dev/null +++ b/docs/licenses/LICENCE-nwaples_rardecode.txt @@ -0,0 +1,23 @@ +Copyright (c) 2015, Nicholas Waples +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-nxadm_tail.txt b/docs/licenses/LICENCE-nxadm_tail.txt new file mode 100644 index 000000000..595de48cd --- /dev/null +++ b/docs/licenses/LICENCE-nxadm_tail.txt @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +# © Copyright 2015 Hewlett Packard Enterprise Development LP +Copyright (c) 2014 ActiveState + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/docs/licenses/LICENCE-onsi_ginkgo.txt b/docs/licenses/LICENCE-onsi_ginkgo.txt new file mode 100644 index 000000000..73030cabb --- /dev/null +++ b/docs/licenses/LICENCE-onsi_ginkgo.txt @@ -0,0 +1,21 @@ +Copyright (c) 2013-2014 Onsi Fakhouri + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE + diff --git a/docs/licenses/LICENCE-pierrec-lz4.txt b/docs/licenses/LICENCE-pierrec-lz4.txt new file mode 100644 index 000000000..bb8c35c0b --- /dev/null +++ b/docs/licenses/LICENCE-pierrec-lz4.txt @@ -0,0 +1,27 @@ +Copyright (c) 2015, Pierre Curto +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of xxHash nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-pkg_errors.txt b/docs/licenses/LICENCE-pkg_errors.txt new file mode 100644 index 000000000..141995377 --- /dev/null +++ b/docs/licenses/LICENCE-pkg_errors.txt @@ -0,0 +1,23 @@ +Copyright (c) 2015, Dave Cheney <dave@cheney.net> +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-pmezard-go-difflib.txt b/docs/licenses/LICENCE-pmezard-go-difflib.txt new file mode 100644 index 000000000..a635f8b06 --- /dev/null +++ b/docs/licenses/LICENCE-pmezard-go-difflib.txt @@ -0,0 +1,35 @@ +Copyright: 2013 Patrick Mézard +License: BSD-3-clause + +Files: debian/* +Copyright: 2016 Dmitry Smirnov <onlyjob@debian.org> +License: BSD-3-clause + +License: BSD-3-clause + +Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + . + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + . + Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + . + The names of its contributors may not be used to endorse or promote + products derived from this software without specific prior written + permission. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-purell.txt b/docs/licenses/LICENCE-purell.txt new file mode 100644 index 000000000..8cf42fe5b --- /dev/null +++ b/docs/licenses/LICENCE-purell.txt @@ -0,0 +1,12 @@ +Copyright (c) 2012, Martin Angers +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +* Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-pypi_setuptools.txt b/docs/licenses/LICENCE-pypi_setuptools.txt new file mode 100644 index 000000000..323d2c18e --- /dev/null +++ b/docs/licenses/LICENCE-pypi_setuptools.txt @@ -0,0 +1,19 @@ +Copyright (C) 2016 Jason R Coombs <jaraco@jaraco.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE diff --git a/docs/licenses/LICENCE-python-certifi.txt b/docs/licenses/LICENCE-python-certifi.txt new file mode 100644 index 000000000..383c7a63d --- /dev/null +++ b/docs/licenses/LICENCE-python-certifi.txt @@ -0,0 +1,409 @@ +Mozilla Public License +Version 2.0 +====================== + + +1. Definitions +-------------- + + 1.1. "Contributor" + + means each individual or legal entity that creates, contributes to the creation + of, or owns Covered Software. + + 1.2. "Contributor Version" + + means the combination of the Contributions of others (if any) used by a + Contributor and that particular Contributor's Contribution. + + 1.3. "Contribution" + + means Covered Software of a particular Contributor. + + 1.4. "Covered Software" + + means Source Code Form to which the initial Contributor has attached the notice + in Exhibit A, the Executable Form of such Source Code Form, and Modifications + of such Source Code Form, in each case including portions thereof. + + 1.5. "Incompatible With Secondary Licenses" + + means + + a. + + that the initial Contributor has attached the notice described in Exhibit B + to the Covered Software; or + + b. + + that the Covered Software was made available under the terms of version 1.1 + or earlier of the License, but not also under the terms of a Secondary + License. + + 1.6. "Executable Form" + + means any form of the work other than Source Code Form. + + 1.7. "Larger Work" + + means a work that combines Covered Software with other material, in a separate + file or files, that is not Covered Software. + + 1.8. "License" + + means this document. + + 1.9. "Licensable" + + means having the right to grant, to the maximum extent possible, whether at the + time of the initial grant or subsequently, any and all of the rights conveyed + by this License. + + 1.10. "Modifications" + + means any of the following: + + a. + + any file in Source Code Form that results from an addition to, deletion + from, or modification of the contents of Covered Software; or + + b. + + any new file in Source Code Form that contains any Covered Software. + + 1.11. "Patent Claims" of a Contributor + + means any patent claim(s), including without limitation, method, process, and + apparatus claims, in any patent Licensable by such Contributor that would be + infringed, but for the grant of the License, by the making, using, selling, + offering for sale, having made, import, or transfer of either its Contributions + or its Contributor Version. + + 1.12. "Secondary License" + + means either the GNU General Public License, Version 2.0, the GNU Lesser + General Public License, Version 2.1, the GNU Affero General Public License, + Version 3.0, or any later versions of those licenses. + + 1.13. "Source Code Form" + + means the form of the work preferred for making modifications. + + 1.14. "You" (or "Your") + + means an individual or a legal entity exercising rights under this License. For + legal entities, "You" includes any entity that controls, is controlled by, or + is under common control with You. For purposes of this definition, "control" + means (a) the power, direct or indirect, to cause the direction or management + of such entity, whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial ownership of such + entity. + + +2. License Grants and Conditions +-------------------------------- + + + 2.1. Grants + + Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive + license: + + a. + + under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, modify, + display, perform, distribute, and otherwise exploit its Contributions, + either on an unmodified basis, with Modifications, or as part of a Larger + Work; and + + b. + + under Patent Claims of such Contributor to make, use, sell, offer for sale, + have made, import, and otherwise transfer either its Contributions or its + Contributor Version. + + + 2.2. Effective Date + + The licenses granted in Section 2.1 with respect to any Contribution become + effective for each Contribution on the date the Contributor first distributes + such Contribution. + + + 2.3. Limitations on Grant Scope + + The licenses granted in this Section 2 are the only rights granted under this + License. No additional rights or licenses will be implied from the distribution + or licensing of Covered Software under this License. Notwithstanding + Section 2.1(b) above, no patent license is granted by a Contributor: + + a. + + for any code that a Contributor has removed from Covered Software; or + + b. + + for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + + c. + + under Patent Claims infringed by Covered Software in the absence of its + Contributions. + + This License does not grant any rights in the trademarks, service marks, or + logos of any Contributor (except as may be necessary to comply with the notice + requirements in Section 3.4). + + + 2.4. Subsequent Licenses + + No Contributor makes additional grants as a result of Your choice to distribute + the Covered Software under a subsequent version of this License (see + Section 10.2) or under the terms of a Secondary License (if permitted under the + terms of Section 3.3). + + + 2.5. Representation + + Each Contributor represents that the Contributor believes its Contributions are + its original creation(s) or it has sufficient rights to grant the rights to its + Contributions conveyed by this License. + + + 2.6. Fair Use + + This License is not intended to limit any rights You have under applicable + copyright doctrines of fair use, fair dealing, or other equivalents. + + + 2.7. Conditions + + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in + Section 2.1. + + +3. Responsibilities +------------------- + + + 3.1. Distribution of Source Form + + All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under the + terms of this License. You must inform recipients that the Source Code Form of + the Covered Software is governed by the terms of this License, and how they can + obtain a copy of this License. You may not attempt to alter or restrict the + recipients' rights in the Source Code Form. + + + 3.2. Distribution of Executable Form + + If You distribute Covered Software in Executable Form then: + + a. + + such Covered Software must also be made available in Source Code Form, as + described in Section 3.1, and You must inform recipients of the Executable + Form how they can obtain a copy of such Source Code Form by reasonable + means in a timely manner, at a charge no more than the cost of distribution + to the recipient; and + + b. + + You may distribute such Executable Form under the terms of this License, or + sublicense it under different terms, provided that the license for the + Executable Form does not attempt to limit or alter the recipients' rights + in the Source Code Form under this License. + + + 3.3. Distribution of a Larger Work + + You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for the + Covered Software. If the Larger Work is a combination of Covered Software with + a work governed by one or more Secondary Licenses, and the Covered Software is + not Incompatible With Secondary Licenses, this License permits You to + additionally distribute such Covered Software under the terms of such Secondary + License(s), so that the recipient of the Larger Work may, at their option, + further distribute the Covered Software under the terms of either this License + or such Secondary License(s). + + + 3.4. Notices + + You may not remove or alter the substance of any license notices (including + copyright notices, patent notices, disclaimers of warranty, or limitations of + liability) contained within the Source Code Form of the Covered Software, + except that You may alter any license notices to the extent required to remedy + known factual inaccuracies. + + + 3.5. Application of Additional Terms + + You may choose to offer, and to charge a fee for, warranty, support, indemnity + or liability obligations to one or more recipients of Covered Software. + However, You may do so only on Your own behalf, and not on behalf of any + Contributor. You must make it absolutely clear that any such warranty, support, + indemnity, or liability obligation is offered by You alone, and You hereby + agree to indemnify every Contributor for any liability incurred by such + Contributor as a result of warranty, support, indemnity or liability terms You + offer. You may include additional disclaimers of warranty and limitations of + liability specific to any jurisdiction. + + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this License with +respect to some or all of the Covered Software due to statute, judicial order, or +regulation then You must: (a) comply with the terms of this License to the +maximum extent possible; and (b) describe the limitations and the code they +affect. Such description must be placed in a text file included with all +distributions of the Covered Software under this License. Except to the extent +prohibited by statute or regulation, such description must be sufficiently +detailed for a recipient of ordinary skill to be able to understand it. + + +5. Termination +-------------- + + 5.1. The rights granted under this License will terminate automatically if You + fail to comply with any of its terms. However, if You become compliant, then + the rights granted under this License from a particular Contributor are + reinstated (a) provisionally, unless and until such Contributor explicitly and + finally terminates Your grants, and (b) on an ongoing basis, if such + Contributor fails to notify You of the non-compliance by some reasonable means + prior to 60 days after You have come back into compliance. Moreover, Your + grants from a particular Contributor are reinstated on an ongoing basis if such + Contributor notifies You of the non-compliance by some reasonable means, this + is the first time You have received notice of non-compliance with this License + from such Contributor, and You become compliant prior to 30 days after Your + receipt of the notice. + + 5.2. If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, counter-claims, and + cross-claims) alleging that a Contributor Version directly or indirectly + infringes any patent, then the rights granted to You by any and all + Contributors for the Covered Software under Section 2.1 of this License shall + terminate. + + 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user + license agreements (excluding distributors and resellers) which have been + validly granted by You or Your distributors under this License prior to + termination shall survive termination. + + +6. Disclaimer of Warranty +------------------------- + +Covered Software is provided under this License on an "as is" basis, without +warranty of any kind, either expressed, implied, or statutory, including, without +limitation, warranties that the Covered Software is free of defects, +merchantable, fit for a particular purpose or non-infringing. The entire risk as +to the quality and performance of the Covered Software is with You. Should any +Covered Software prove defective in any respect, You (not any Contributor) assume +the cost of any necessary servicing, repair, or correction. This disclaimer of +warranty constitutes an essential part of this License. No use of any Covered +Software is authorized under this License except under this disclaimer. + + +7. Limitation of Liability +-------------------------- + +Under no circumstances and under no legal theory, whether tort (including +negligence), contract, or otherwise, shall any Contributor, or anyone who +distributes Covered Software as permitted above, be liable to You for any direct, +indirect, special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of goodwill, work +stoppage, computer failure or malfunction, or any and all other commercial +damages or losses, even if such party shall have been informed of the possibility +of such damages. This limitation of liability shall not apply to liability for +death or personal injury resulting from such party's negligence to the extent +applicable law prohibits such limitation. Some jurisdictions do not allow the +exclusion or limitation of incidental or consequential damages, so this exclusion +and limitation may not apply to You. + + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the courts of a +jurisdiction where the defendant maintains its principal place of business and +such litigation shall be governed by laws of that jurisdiction, without reference +to its conflict-of-law provisions. Nothing in this Section shall prevent a +party's ability to bring cross-claims or counter-claims. + + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject matter +hereof. If any provision of this License is held to be unenforceable, such +provision shall be reformed only to the extent necessary to make it enforceable. +Any law or regulation which provides that the language of a contract shall be +construed against the drafter shall not be used to construe this License against +a Contributor. + + +10. Versions of the License +--------------------------- + + + 10.1. New Versions + + Mozilla Foundation is the license steward. Except as provided in Section 10.3, + no one other than the license steward has the right to modify or publish new + versions of this License. Each version will be given a distinguishing version + number. + + + 10.2. Effect of New Versions + + You may distribute the Covered Software under the terms of the version of the + License under which You originally received the Covered Software, or under the + terms of any subsequent version published by the license steward. + + + 10.3. Modified Versions + + If you create software not governed by this License, and you want to create a + new license for such software, you may create and use a modified version of + this License if you rename the license and remove any references to the name of + the license steward (except to note that such modified license differs from + this License). + + + 10.4. Distributing Source Code Form that is Incompatible With Secondary + Licenses + + If You choose to distribute Source Code Form that is Incompatible With + Secondary Licenses under the terms of this version of the License, the notice + described in Exhibit B of this License must be attached. + + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public License, + v. 2.0. If a copy of the MPL was not distributed with this file, You can + obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular file, then +You may include the notice in a location (such as a LICENSE file in a relevant +directory) where a recipient would be likely to look for such a notice. + +You may add additional accurate notices of copyright ownership. + + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as defined + by the Mozilla Public License, v. 2.0. + diff --git a/docs/licenses/LICENCE-rogpeppe_go-internal.txt b/docs/licenses/LICENCE-rogpeppe_go-internal.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-rogpeppe_go-internal.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-sftp.txt b/docs/licenses/LICENCE-sftp.txt new file mode 100644 index 000000000..9ac6ae0a6 --- /dev/null +++ b/docs/licenses/LICENCE-sftp.txt @@ -0,0 +1,23 @@ +BSD Two Clause License +====================== + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. diff --git a/docs/licenses/LICENCE-sigs_k8s_io_json.txt b/docs/licenses/LICENCE-sigs_k8s_io_json.txt new file mode 100644 index 000000000..965b00716 --- /dev/null +++ b/docs/licenses/LICENCE-sigs_k8s_io_json.txt @@ -0,0 +1,28 @@ +Copyright (c) <YEAR>, <OWNER> +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of the <ORGANIZATION> nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-uber-go_atomic.txt b/docs/licenses/LICENCE-uber-go_atomic.txt new file mode 100644 index 000000000..12cd09580 --- /dev/null +++ b/docs/licenses/LICENCE-uber-go_atomic.txt @@ -0,0 +1,19 @@ +Copyright (c) 2016 Uber Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE diff --git a/docs/licenses/LICENCE-ulikunitz_xz.txt b/docs/licenses/LICENCE-ulikunitz_xz.txt new file mode 100644 index 000000000..d358ed04d --- /dev/null +++ b/docs/licenses/LICENCE-ulikunitz_xz.txt @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-urfAVE_cli.txt b/docs/licenses/LICENCE-urfAVE_cli.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-urfAVE_cli.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-urlesc.txt b/docs/licenses/LICENCE-urlesc.txt new file mode 100644 index 000000000..76427ff52 --- /dev/null +++ b/docs/licenses/LICENCE-urlesc.txt @@ -0,0 +1,27 @@ +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE diff --git a/docs/licenses/LICENCE-urllib3.txt b/docs/licenses/LICENCE-urllib3.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-urllib3.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-xrash_smetrics.txt b/docs/licenses/LICENCE-xrash_smetrics.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-xrash_smetrics.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-yaml-for-Go.txt b/docs/licenses/LICENCE-yaml-for-Go.txt new file mode 100644 index 000000000..df0a3e7ff --- /dev/null +++ b/docs/licenses/LICENCE-yaml-for-Go.txt @@ -0,0 +1,21 @@ +The MIT License +=============== + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From d8bf97cb6ae19a36e916d2b3dc3a12c8155f85ae Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Wed, 8 Feb 2023 20:15:48 +0800 Subject: [PATCH 09/15] [WebConsole] Sync to github manually --- docs/licenses/LICENCE-BurntSushi_toml.txt | 32 +-- docs/licenses/LICENCE-Go-Logrus.txt | 32 +-- docs/licenses/LICENCE-Go-Testify.txt | 28 +- docs/licenses/LICENCE-charset-normalizer.txt | 28 +- docs/licenses/LICENCE-cpuguy83-go-md2man.txt | 28 +- docs/licenses/LICENCE-dsnet_compress.txt | 40 ++- docs/licenses/LICENCE-frankban_quicktest.txt | 28 +- docs/licenses/LICENCE-fsnotify.txt | 33 +-- docs/licenses/LICENCE-go-restful.txt | 33 +-- docs/licenses/LICENCE-golang_org_x_net.txt | 49 ++-- docs/licenses/LICENCE-golang_org_x_oauth2.txt | 49 ++-- docs/licenses/LICENCE-golang_org_x_sync.txt | 49 ++-- docs/licenses/LICENCE-golang_org_x_term.txt | 49 ++-- docs/licenses/LICENCE-golang_org_x_time.txt | 49 ++-- docs/licenses/LICENCE-golang_text.txt | 49 ++-- docs/licenses/LICENCE-gomega.txt | 33 ++- docs/licenses/LICENCE-google_go-cmp.txt | 49 ++-- .../LICENCE-google_golang_org_protobuf.txt | 48 ++-- docs/licenses/LICENCE-goproxy.txt | 49 ++-- docs/licenses/LICENCE-idna.txt | 47 ++-- docs/licenses/LICENCE-kr_pretty.txt | 32 +-- docs/licenses/LICENCE-melbahja_goph.txt | 28 +- docs/licenses/LICENCE-munnerz_goautoneg.txt | 47 ++-- docs/licenses/LICENCE-onsi_ginkgo.txt | 3 +- .../licenses/LICENCE-rogpeppe_go-internal.txt | 49 ++-- docs/licenses/LICENCE-sigs_k8s_io_json.txt | 266 ++++++++++++++++-- docs/licenses/LICENCE-urfAVE_cli.txt | 28 +- docs/licenses/LICENCE-urllib3.txt | 28 +- docs/licenses/LICENCE-xrash_smetrics.txt | 20 +- docs/licenses/LICENCE-yaml-for-Go.txt | 55 +++- 30 files changed, 792 insertions(+), 566 deletions(-) diff --git a/docs/licenses/LICENCE-BurntSushi_toml.txt b/docs/licenses/LICENCE-BurntSushi_toml.txt index df0a3e7ff..01b574320 100644 --- a/docs/licenses/LICENCE-BurntSushi_toml.txt +++ b/docs/licenses/LICENCE-BurntSushi_toml.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +The MIT License (MIT) -Copyright (c) <year> <copyright holders> +Copyright (c) 2013 TOML authors -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/docs/licenses/LICENCE-Go-Logrus.txt b/docs/licenses/LICENCE-Go-Logrus.txt index df0a3e7ff..f090cb42f 100644 --- a/docs/licenses/LICENCE-Go-Logrus.txt +++ b/docs/licenses/LICENCE-Go-Logrus.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +The MIT License (MIT) -Copyright (c) <year> <copyright holders> +Copyright (c) 2014 Simon Eskildsen -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/docs/licenses/LICENCE-Go-Testify.txt b/docs/licenses/LICENCE-Go-Testify.txt index df0a3e7ff..4b0421cf9 100644 --- a/docs/licenses/LICENCE-Go-Testify.txt +++ b/docs/licenses/LICENCE-Go-Testify.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +MIT License -Copyright (c) <year> <copyright holders> +Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors. -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/LICENCE-charset-normalizer.txt b/docs/licenses/LICENCE-charset-normalizer.txt index df0a3e7ff..a86dd9559 100644 --- a/docs/licenses/LICENCE-charset-normalizer.txt +++ b/docs/licenses/LICENCE-charset-normalizer.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +MIT License -Copyright (c) <year> <copyright holders> +Copyright (c) 2019 TAHRI Ahmed R. -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/LICENCE-cpuguy83-go-md2man.txt b/docs/licenses/LICENCE-cpuguy83-go-md2man.txt index df0a3e7ff..1cade6cef 100644 --- a/docs/licenses/LICENCE-cpuguy83-go-md2man.txt +++ b/docs/licenses/LICENCE-cpuguy83-go-md2man.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +The MIT License (MIT) -Copyright (c) <year> <copyright holders> +Copyright (c) 2014 Brian Goff -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/LICENCE-dsnet_compress.txt b/docs/licenses/LICENCE-dsnet_compress.txt index 965b00716..945b396cf 100644 --- a/docs/licenses/LICENCE-dsnet_compress.txt +++ b/docs/licenses/LICENCE-dsnet_compress.txt @@ -1,28 +1,24 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright © 2015, Joe Tsai and The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +* Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. +* Neither the copyright holder nor the names of its contributors may be used to +endorse or promote products derived from this software without specific prior +written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-frankban_quicktest.txt b/docs/licenses/LICENCE-frankban_quicktest.txt index df0a3e7ff..23a294c75 100644 --- a/docs/licenses/LICENCE-frankban_quicktest.txt +++ b/docs/licenses/LICENCE-frankban_quicktest.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +MIT License -Copyright (c) <year> <copyright holders> +Copyright (c) 2017 Canonical Ltd. -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/LICENCE-fsnotify.txt b/docs/licenses/LICENCE-fsnotify.txt index 965b00716..fb03ade75 100644 --- a/docs/licenses/LICENCE-fsnotify.txt +++ b/docs/licenses/LICENCE-fsnotify.txt @@ -1,28 +1,25 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright © 2012 The Go Authors. All rights reserved. +Copyright © fsnotify Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. +* Neither the name of Google Inc. nor the names of its contributors may be used + to endorse or promote products derived from this software without specific + prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-go-restful.txt b/docs/licenses/LICENCE-go-restful.txt index df0a3e7ff..812a5c834 100644 --- a/docs/licenses/LICENCE-go-restful.txt +++ b/docs/licenses/LICENCE-go-restful.txt @@ -1,21 +1,22 @@ -The MIT License -=============== +Copyright (c) 2012,2013 Ernest Micklei -Copyright (c) <year> <copyright holders> +MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-golang_org_x_net.txt b/docs/licenses/LICENCE-golang_org_x_net.txt index 965b00716..6a66aea5e 100644 --- a/docs/licenses/LICENCE-golang_org_x_net.txt +++ b/docs/licenses/LICENCE-golang_org_x_net.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2009 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_org_x_oauth2.txt b/docs/licenses/LICENCE-golang_org_x_oauth2.txt index 965b00716..6a66aea5e 100644 --- a/docs/licenses/LICENCE-golang_org_x_oauth2.txt +++ b/docs/licenses/LICENCE-golang_org_x_oauth2.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2009 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_org_x_sync.txt b/docs/licenses/LICENCE-golang_org_x_sync.txt index 965b00716..6a66aea5e 100644 --- a/docs/licenses/LICENCE-golang_org_x_sync.txt +++ b/docs/licenses/LICENCE-golang_org_x_sync.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2009 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_org_x_term.txt b/docs/licenses/LICENCE-golang_org_x_term.txt index 965b00716..6a66aea5e 100644 --- a/docs/licenses/LICENCE-golang_org_x_term.txt +++ b/docs/licenses/LICENCE-golang_org_x_term.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2009 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_org_x_time.txt b/docs/licenses/LICENCE-golang_org_x_time.txt index 965b00716..6a66aea5e 100644 --- a/docs/licenses/LICENCE-golang_org_x_time.txt +++ b/docs/licenses/LICENCE-golang_org_x_time.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2009 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-golang_text.txt b/docs/licenses/LICENCE-golang_text.txt index 965b00716..6a66aea5e 100644 --- a/docs/licenses/LICENCE-golang_text.txt +++ b/docs/licenses/LICENCE-golang_text.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2009 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-gomega.txt b/docs/licenses/LICENCE-gomega.txt index df0a3e7ff..9415ee72c 100644 --- a/docs/licenses/LICENCE-gomega.txt +++ b/docs/licenses/LICENCE-gomega.txt @@ -1,21 +1,20 @@ -The MIT License -=============== +Copyright (c) 2013-2014 Onsi Fakhouri -Copyright (c) <year> <copyright holders> +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-google_go-cmp.txt b/docs/licenses/LICENCE-google_go-cmp.txt index 965b00716..32017f8fa 100644 --- a/docs/licenses/LICENCE-google_go-cmp.txt +++ b/docs/licenses/LICENCE-google_go-cmp.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2017 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-google_golang_org_protobuf.txt b/docs/licenses/LICENCE-google_golang_org_protobuf.txt index 965b00716..0f646931a 100644 --- a/docs/licenses/LICENCE-google_golang_org_protobuf.txt +++ b/docs/licenses/LICENCE-google_golang_org_protobuf.txt @@ -1,28 +1,28 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright 2010 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-goproxy.txt b/docs/licenses/LICENCE-goproxy.txt index 965b00716..2067e567c 100644 --- a/docs/licenses/LICENCE-goproxy.txt +++ b/docs/licenses/LICENCE-goproxy.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2012 Elazar Leibovich. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Elazar Leibovich. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-idna.txt b/docs/licenses/LICENCE-idna.txt index 965b00716..cc7d6baac 100644 --- a/docs/licenses/LICENCE-idna.txt +++ b/docs/licenses/LICENCE-idna.txt @@ -1,28 +1,31 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +BSD 3-Clause License -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Copyright (c) 2013-2022, Kim Davies and contributors. +All rights reserved. - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-kr_pretty.txt b/docs/licenses/LICENCE-kr_pretty.txt index df0a3e7ff..480a32805 100644 --- a/docs/licenses/LICENCE-kr_pretty.txt +++ b/docs/licenses/LICENCE-kr_pretty.txt @@ -1,21 +1,19 @@ -The MIT License -=============== +Copyright 2012 Keith Rarick -Copyright (c) <year> <copyright holders> +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/docs/licenses/LICENCE-melbahja_goph.txt b/docs/licenses/LICENCE-melbahja_goph.txt index df0a3e7ff..42d540c38 100644 --- a/docs/licenses/LICENCE-melbahja_goph.txt +++ b/docs/licenses/LICENCE-melbahja_goph.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +MIT License -Copyright (c) <year> <copyright holders> +Copyright (c) 2020-present Mohamed El Bahja -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/LICENCE-munnerz_goautoneg.txt b/docs/licenses/LICENCE-munnerz_goautoneg.txt index 965b00716..bbc7b897c 100644 --- a/docs/licenses/LICENCE-munnerz_goautoneg.txt +++ b/docs/licenses/LICENCE-munnerz_goautoneg.txt @@ -1,28 +1,31 @@ -Copyright (c) <YEAR>, <OWNER> +Copyright (c) 2011, Open Knowledge Foundation Ltd. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. + Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. + Neither the name of the Open Knowledge Foundation Ltd. nor the + names of its contributors may be used to endorse or promote + products derived from this software without specific prior written + permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-onsi_ginkgo.txt b/docs/licenses/LICENCE-onsi_ginkgo.txt index 73030cabb..9415ee72c 100644 --- a/docs/licenses/LICENCE-onsi_ginkgo.txt +++ b/docs/licenses/LICENCE-onsi_ginkgo.txt @@ -17,5 +17,4 @@ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE - +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-rogpeppe_go-internal.txt b/docs/licenses/LICENCE-rogpeppe_go-internal.txt index 965b00716..49ea0f928 100644 --- a/docs/licenses/LICENCE-rogpeppe_go-internal.txt +++ b/docs/licenses/LICENCE-rogpeppe_go-internal.txt @@ -1,28 +1,27 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. +Copyright (c) 2018 The Go Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-sigs_k8s_io_json.txt b/docs/licenses/LICENCE-sigs_k8s_io_json.txt index 965b00716..e5adf7f0c 100644 --- a/docs/licenses/LICENCE-sigs_k8s_io_json.txt +++ b/docs/licenses/LICENCE-sigs_k8s_io_json.txt @@ -1,28 +1,238 @@ -Copyright (c) <YEAR>, <OWNER> -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the <ORGANIZATION> nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS -OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN -IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Files other than internal/golang/* licensed under: + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +------------------ + +internal/golang/* files licensed under: + + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/licenses/LICENCE-urfAVE_cli.txt b/docs/licenses/LICENCE-urfAVE_cli.txt index df0a3e7ff..2c84c78a1 100644 --- a/docs/licenses/LICENCE-urfAVE_cli.txt +++ b/docs/licenses/LICENCE-urfAVE_cli.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +MIT License -Copyright (c) <year> <copyright holders> +Copyright (c) 2022 urfave/cli maintainers -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/LICENCE-urllib3.txt b/docs/licenses/LICENCE-urllib3.txt index df0a3e7ff..429a1767e 100644 --- a/docs/licenses/LICENCE-urllib3.txt +++ b/docs/licenses/LICENCE-urllib3.txt @@ -1,21 +1,21 @@ -The MIT License -=============== +MIT License -Copyright (c) <year> <copyright holders> +Copyright (c) 2008-2020 Andrey Petrov and contributors (see CONTRIBUTORS.txt) -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/licenses/LICENCE-xrash_smetrics.txt b/docs/licenses/LICENCE-xrash_smetrics.txt index df0a3e7ff..80445682f 100644 --- a/docs/licenses/LICENCE-xrash_smetrics.txt +++ b/docs/licenses/LICENCE-xrash_smetrics.txt @@ -1,13 +1,13 @@ -The MIT License -=============== +Copyright (C) 2016 Felipe da Cunha Gonçalves +All Rights Reserved. -Copyright (c) <year> <copyright holders> +MIT LICENSE Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all @@ -16,6 +16,6 @@ copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/LICENCE-yaml-for-Go.txt b/docs/licenses/LICENCE-yaml-for-Go.txt index df0a3e7ff..2683e4bb1 100644 --- a/docs/licenses/LICENCE-yaml-for-Go.txt +++ b/docs/licenses/LICENCE-yaml-for-Go.txt @@ -1,21 +1,50 @@ -The MIT License -=============== -Copyright (c) <year> <copyright holders> +This project is covered by two different licenses: MIT and Apache. + +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN -AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. From 24962ca4ed80d831b29f9ab0dd405fd146f224bd Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Tue, 21 Mar 2023 20:01:23 +0800 Subject: [PATCH 10/15] [WebConsole] Sync to github manually --- data_processing/README.md | 5 --- data_processing/spark/csv_to_hive.py | 36 ------------------- data_processing/spark/hive_to_csv.py | 36 ------------------- .../com/bytedance/aml/enterprise/Main.scala | 26 -------------- 4 files changed, 103 deletions(-) delete mode 100644 data_processing/spark/csv_to_hive.py delete mode 100644 data_processing/spark/hive_to_csv.py delete mode 100644 data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala diff --git a/data_processing/README.md b/data_processing/README.md index 7f8290fe6..c17f8581f 100644 --- a/data_processing/README.md +++ b/data_processing/README.md @@ -3,11 +3,6 @@ mvn clean scala:compile assembly:single ``` -## Run -```shell -mvn scala:run -DmainClass=com.bytedance.aml.enterprise.Main -``` - ## Dependencies * Spark 3.0.1 * Java 8 diff --git a/data_processing/spark/csv_to_hive.py b/data_processing/spark/csv_to_hive.py deleted file mode 100644 index ef50a7c83..000000000 --- a/data_processing/spark/csv_to_hive.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2023 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from pyspark.sql import SparkSession -from pyspark.sql.functions import col, lower - - -def run(): - spark = SparkSession \ - .builder \ - .enableHiveSupport() \ - .config('hive.exec.dynamic.partition', 'true') \ - .config('hive.exec.dynamic.partition.mode', 'nonstrict') \ - .getOrCreate() - - df = spark.read.option('header', 'false') \ - .csv('/home/byte_aml_tob/fedlearner_v2/njb/reduced.csv') - df = df.select(lower(col(df.columns[0])).alias('phone_sha256')) - df.write.mode('overwrite').insertInto('aml_tob.njb_intersection_sha256') - spark.stop() - - -if __name__ == '__main__': - run() diff --git a/data_processing/spark/hive_to_csv.py b/data_processing/spark/hive_to_csv.py deleted file mode 100644 index aa1a39b7c..000000000 --- a/data_processing/spark/hive_to_csv.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2023 The FedLearner Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -from pyspark.sql import SparkSession - - -def run(): - spark = SparkSession \ - .builder \ - .enableHiveSupport() \ - .getOrCreate() - - df = spark.sql('SELECT DISTINCT uid FROM aml_tob.njb_intersection_uid WHERE uid IS NOT NULL') - - # Partition automatically - df.write.format('csv').option('compression', - 'none').option('header', - 'false').save('/home/byte_aml_tob/fedlearner_v2/njb/reduced_uid', - mode='overwrite') - spark.stop() - - -if __name__ == '__main__': - run() diff --git a/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala b/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala deleted file mode 100644 index 6fc87ce8e..000000000 --- a/data_processing/src/main/scala/com/bytedance/aml/enterprise/Main.scala +++ /dev/null @@ -1,26 +0,0 @@ -/* Copyright 2023 The FedLearner Authors. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.bytedance.aml.enterprise - -import org.apache.spark.sql.SparkSession - - -object Main { - def main(args: Array[String]) { - // trimmed - return - } -} From 77fb2d1459b3ad62e9e35ba99575c431dc96c1e5 Mon Sep 17 00:00:00 2001 From: Li Xiaoguang <lixiaoguang.01@bytedance.com> Date: Tue, 21 Mar 2023 20:06:43 +0800 Subject: [PATCH 11/15] [WebConsole] Sync to github manually --- .../src/services/mocks/v2/algorithms/__id__/index.ts | 2 +- .../src/services/mocks/v2/data_sources/index.ts | 2 +- .../client/src/services/mocks/v2/files/index.ts | 2 +- .../mocks/v2/intersection_datasets/examples.ts | 12 ++++++------ .../client/src/services/mocks/v2/models/index.ts | 8 ++++---- .../client/src/views/ModelCenter/shared.test.ts | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/index.ts b/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/index.ts index 3b37e166a..d5eb6bd34 100644 --- a/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/index.ts +++ b/web_console_v2/client/src/services/mocks/v2/algorithms/__id__/index.ts @@ -15,7 +15,7 @@ const get = (config: AxiosRequestConfig) => { username: 'admin', participant_id: null, path: - 'hdfs:///home/byte_aml_tob/fedlearner_v2/algorithms/hang-e2e-test-122323-v3-20220106_100837-2ddea', + 'hdfs:///trimmed', parameter: { variables: [ { diff --git a/web_console_v2/client/src/services/mocks/v2/data_sources/index.ts b/web_console_v2/client/src/services/mocks/v2/data_sources/index.ts index 153e52569..e3cc1e204 100644 --- a/web_console_v2/client/src/services/mocks/v2/data_sources/index.ts +++ b/web_console_v2/client/src/services/mocks/v2/data_sources/index.ts @@ -20,7 +20,7 @@ const list: DataSource[] = [ name: 'mock数据源2', created_at: 1609582145, url: - 'hdfs://home/byte_aml_tob/fedlearner_v2/dataset/20220218_141000_e2e-test-dataset-20220218-060927', + 'hdfs:///trimmed', project_id: 1, dataset_format: 'TABULAR', dataset_type: 'STREAMING', diff --git a/web_console_v2/client/src/services/mocks/v2/files/index.ts b/web_console_v2/client/src/services/mocks/v2/files/index.ts index fc467939a..8caadf34a 100644 --- a/web_console_v2/client/src/services/mocks/v2/files/index.ts +++ b/web_console_v2/client/src/services/mocks/v2/files/index.ts @@ -30,7 +30,7 @@ export const post = { { display_file_name: 'mock-file.tar.gz', internal_path: - 'hdfs:///home/byte_aml_tob/fedlearner_v2/upload/20211015_041720010240/mock-file.tar.gz', + 'hdfs:///trimmed', }, ], }, diff --git a/web_console_v2/client/src/services/mocks/v2/intersection_datasets/examples.ts b/web_console_v2/client/src/services/mocks/v2/intersection_datasets/examples.ts index 393c5cb18..c5e274a60 100644 --- a/web_console_v2/client/src/services/mocks/v2/intersection_datasets/examples.ts +++ b/web_console_v2/client/src/services/mocks/v2/intersection_datasets/examples.ts @@ -12,7 +12,7 @@ export const readyToRun: IntersectionDataset = { kind: 0, name: '求交数据集 2021-05-20-17:31:54', path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/u9d9fd94b01324f5ba90-data-join-job', + 'hdfs:///trimmed', peer_name: 'aliyun-test1', project_id: 14, status: 'READY_TO_RUN', @@ -27,7 +27,7 @@ export const invalid: IntersectionDataset = { workflow_id: 121734, name: '求交数据集 2021-08-11-21:27:44', path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/u9d9fd94b01324f5ba90-data-join-job', + 'hdfs:///trimmed', comment: '', kind: 0, created_at: 1628688464, @@ -47,7 +47,7 @@ export const running: IntersectionDataset = { workflow_id: 153713, name: '求交数据集 2021-09-02-15:47:02', path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/u7e0239d6f0f94271a33-data-join-job', + 'hdfs:///trimmed', comment: '', kind: 0, created_at: 1630568822, @@ -72,7 +72,7 @@ export const completed: IntersectionDataset = { kind: 0, name: '求交数据集 2021-09-09-14:14:57', path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/ue24311305a4a4b6397d-psi-data-join-job', + 'hdfs:///trimmed', peer_name: 'aliyun-test1', project_id: 14, status: 'COMPLETED', @@ -87,7 +87,7 @@ export const stopped: IntersectionDataset = { workflow_id: 88949, name: '求交数据集 2021-07-19-21:06:12', path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/ua01bceb669ee42a3b6b-data-join-job', + 'hdfs:///trimmed', comment: '', kind: 0, created_at: 1626699972, @@ -107,7 +107,7 @@ export const failed: IntersectionDataset = { workflow_id: 68479, name: '求交数据集 2021-07-01-12:37:00', path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/ua01bceb669ee42a3b6b-data-join-job', + 'hdfs:///trimmed', comment: '', kind: 0, created_at: 1625114220, diff --git a/web_console_v2/client/src/services/mocks/v2/models/index.ts b/web_console_v2/client/src/services/mocks/v2/models/index.ts index e0acbd700..4864a0e82 100644 --- a/web_console_v2/client/src/services/mocks/v2/models/index.ts +++ b/web_console_v2/client/src/services/mocks/v2/models/index.ts @@ -7,7 +7,7 @@ const modelList: Model[] = [ uuid: 'u3a0507d64bc442a2b66', model_type: 'NN_MODEL', model_path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/model_output/u3a0507d64bc442a2b66', + 'hdfs:///trimmed', favorite: false, comment: 'created_by ucce42a49cbff4c4e930-nn-train at 2021-09-27 12:44:32.419496+00:00', group_id: null, @@ -30,7 +30,7 @@ const modelList: Model[] = [ uuid: 'u195c5a39d1e44804a1a', model_type: 'NN_MODEL', model_path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/model_output/u195c5a39d1e44804a1a', + 'hdfs:///trimmed', favorite: false, comment: 'created_by ucce42a49cbff4c4e930-nn-train at 2021-09-27 12:44:37.030088+00:00', group_id: null, @@ -53,7 +53,7 @@ const modelList: Model[] = [ uuid: 'u70ca285687eb4d2fbb0', model_type: 'NN_MODEL', model_path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/model_output/u70ca285687eb4d2fbb0', + 'hdfs:///trimmed', favorite: false, comment: 'created_by ud8b9cb500fc3435cb66-nn-train at 2021-10-09 07:08:48.729397+00:00', group_id: 1, @@ -76,7 +76,7 @@ const modelList: Model[] = [ uuid: 'u4b6e82b75e644c90907', model_type: 'NN_MODEL', model_path: - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/model_output/u4b6e82b75e644c90907', + 'hdfs:///trimmed', favorite: false, comment: 'created_by ud8b9cb500fc3435cb66-nn-train at 2021-10-09 07:08:56.799515+00:00', group_id: 2, diff --git a/web_console_v2/client/src/views/ModelCenter/shared.test.ts b/web_console_v2/client/src/views/ModelCenter/shared.test.ts index 4c6e123e5..ad9e9f2a3 100644 --- a/web_console_v2/client/src/views/ModelCenter/shared.test.ts +++ b/web_console_v2/client/src/views/ModelCenter/shared.test.ts @@ -682,7 +682,7 @@ it('getDataSource', () => { expect(getDataSource('/data_source/abc')).toBe('abc'); expect( getDataSource( - 'hdfs:///home/byte_aml_tob/experiments/fedlearner/data_source/u0bae4aa7dcde477e8ee-psi-data-join-job', + 'hdfs:///trimmed', ), ).toBe('u0bae4aa7dcde477e8ee-psi-data-join-job'); }); From 92c11e35e2ebc2aea3d2b1631788c50df2c77555 Mon Sep 17 00:00:00 2001 From: gezhengqiang <gezhengqiang@bytedance.com> Date: Wed, 28 Feb 2024 17:20:53 +0800 Subject: [PATCH 12/15] feat(sgx): add sidecar dockerfile --- sgx_network_simulation/Dockerfile | 33 ++++ sgx_network_simulation/nginx/sidecar.conf | 22 +++ sgx_network_simulation/sidecar.sh | 66 ++++++++ tools/tcp_grpc_proxy/Makefile | 13 ++ tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go | 4 +- tools/tcp_grpc_proxy/cmd/grpcclient/main.go | 51 ++++++ tools/tcp_grpc_proxy/cmd/grpcserver/main.go | 11 ++ tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go | 42 ++++- tools/tcp_grpc_proxy/cmd/tcpclient/main.go | 38 +++++ tools/tcp_grpc_proxy/cmd/tcpserver/main.go | 46 ++++++ tools/tcp_grpc_proxy/go.mod | 12 ++ tools/tcp_grpc_proxy/go.sum | 106 +++++++++++++ tools/tcp_grpc_proxy/proto/tunnel.proto | 12 ++ tools/tcp_grpc_proxy/proxy/grpc2tcp.go | 106 +++++++++++++ tools/tcp_grpc_proxy/proxy/proto/tunnel.pb.go | 147 ++++++++++++++++++ .../proxy/proto/tunnel_grpc.pb.go | 133 ++++++++++++++++ tools/tcp_grpc_proxy/proxy/tcp2grpc.go | 104 +++++++++++++ 17 files changed, 942 insertions(+), 4 deletions(-) create mode 100644 sgx_network_simulation/Dockerfile create mode 100644 sgx_network_simulation/nginx/sidecar.conf create mode 100644 sgx_network_simulation/sidecar.sh create mode 100644 tools/tcp_grpc_proxy/Makefile create mode 100644 tools/tcp_grpc_proxy/cmd/grpcclient/main.go create mode 100644 tools/tcp_grpc_proxy/cmd/grpcserver/main.go create mode 100644 tools/tcp_grpc_proxy/cmd/tcpclient/main.go create mode 100644 tools/tcp_grpc_proxy/cmd/tcpserver/main.go create mode 100644 tools/tcp_grpc_proxy/go.mod create mode 100644 tools/tcp_grpc_proxy/go.sum create mode 100644 tools/tcp_grpc_proxy/proto/tunnel.proto create mode 100644 tools/tcp_grpc_proxy/proxy/grpc2tcp.go create mode 100644 tools/tcp_grpc_proxy/proxy/proto/tunnel.pb.go create mode 100644 tools/tcp_grpc_proxy/proxy/proto/tunnel_grpc.pb.go create mode 100644 tools/tcp_grpc_proxy/proxy/tcp2grpc.go diff --git a/sgx_network_simulation/Dockerfile b/sgx_network_simulation/Dockerfile new file mode 100644 index 000000000..279b3ac5c --- /dev/null +++ b/sgx_network_simulation/Dockerfile @@ -0,0 +1,33 @@ +FROM golang:1.16 AS go + +RUN apt-get update && \ + apt-get install -y make g++ libgmp-dev libglib2.0-dev libssl-dev && \ + apt-get install -y protobuf-compiler && \ + apt-get clean + +WORKDIR /app +COPY tools/tcp_grpc_proxy ./ +RUN make build + +FROM python:3.6.8 + +RUN echo "deb http://archive.debian.org/debian stretch main contrib non-free" > /etc/apt/sources.list + +RUN apt-get update && \ + apt-get install -y curl vim make nginx && \ + apt-get clean + +# upgrade nginx +RUN echo "deb http://nginx.org/packages/mainline/debian/ stretch nginx deb-src http://nginx.org/packages/mainline/debian/ stretch nginx" > /etc/apt/sources.list.d/nginx.list +RUN wget -qO - https://nginx.org/keys/nginx_signing.key | apt-key add - +RUN apt update && \ + apt remove nginx-common -y && \ + apt install nginx + +COPY sgx_network_simulation/ /app/ +WORKDIR /app +COPY --from=go /app/tcp2grpc ./ +COPY --from=go /app/grpc2tcp ./ +RUN pip3 install -r requirements.txt && make protobuf + +ENTRYPOINT ["bash", "docker_entrypoint.sh"] diff --git a/sgx_network_simulation/nginx/sidecar.conf b/sgx_network_simulation/nginx/sidecar.conf new file mode 100644 index 000000000..2586392d2 --- /dev/null +++ b/sgx_network_simulation/nginx/sidecar.conf @@ -0,0 +1,22 @@ +# Forwards all traffic to nginx controller +server { + listen 32102 http2; + + # No limits + client_max_body_size 0; + grpc_read_timeout 3600s; + grpc_send_timeout 3600s; + client_body_timeout 3600s; + # grpc_socket_keepalive is recommended but not required + # grpc_socket_keepalive is supported after nginx 1.15.6 + grpc_socket_keepalive on; + + grpc_set_header Authority fl-bytedance-client-auth.com; + grpc_set_header Host fl-bytedance-client-auth.com; + grpc_set_header X-Host sgx-test.fl-cmcc.com; + + location / { + # Redirects to nginx controller + grpc_pass grpc://fedlearner-stack-ingress-nginx-controller.default.svc:80; + } +} diff --git a/sgx_network_simulation/sidecar.sh b/sgx_network_simulation/sidecar.sh new file mode 100644 index 000000000..dae4a124b --- /dev/null +++ b/sgx_network_simulation/sidecar.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -ex + +FILE_PATH="/pod-data/listen_port" +while [ ! -s "$FILE_PATH" ]; do + echo "wait for $FILE_PATH ..." + sleep 1 +done +WORKER_LISTEN_PORT=$(cat "$FILE_PATH") + +echo "# Forwards all traffic to nginx controller +server { + listen 32102 http2; + + # No limits + client_max_body_size 0; + grpc_read_timeout 3600s; + grpc_send_timeout 3600s; + client_body_timeout 3600s; + # grpc_socket_keepalive is recommended but not required + # grpc_socket_keepalive is supported after nginx 1.15.6 + grpc_socket_keepalive on; + + grpc_set_header Authority ${EGRESS_HOST}; + grpc_set_header Host ${EGRESS_HOST}; + grpc_set_header X-Host ${SERVICE_ID}.${EGRESS_DOMAIN}; + + location / { + # Redirects to nginx controller + grpc_pass grpc://fedlearner-stack-ingress-nginx-controller.default.svc:80; + } +} +" > nginx/sidecar.conf + +if [ -z "$PORT0" ]; then + PORT0=32001 +fi + +if [ -z "$PORT2" ]; then + PORT2=32102 +fi + +sed -i "s/listen [0-9]* http2;/listen $PORT2 http2;/" nginx/sidecar.conf + +cp nginx/sidecar.conf /etc/nginx/conf.d/ +service nginx restart + +# Server sidecar: grpc to tcp, 5001 is the server port of main container +echo "Starting server sidecar" +./grpc2tcp --grpc_server_port=$PORT0 \ + --target_tcp_address="localhost:$WORKER_LISTEN_PORT" & + +echo "Starting client sidecar" +./tcp2grpc --tcp_server_port="$PROXY_LOCAL_PORT" \ + --target_grpc_address="localhost:$PORT2" & + +echo "===========Sidecar started!!=============" + +while true +do + if [[ -f "/pod-data/main-terminated" ]] + then + exit 0 + fi + sleep 5 +done diff --git a/tools/tcp_grpc_proxy/Makefile b/tools/tcp_grpc_proxy/Makefile new file mode 100644 index 000000000..67e1889f9 --- /dev/null +++ b/tools/tcp_grpc_proxy/Makefile @@ -0,0 +1,13 @@ +install: + go get tcp_grpc_proxy + go mod download + +protobuf: install + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26 + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1 + PATH="${PATH}:$(shell go env GOPATH)/bin" \ + protoc -I=proto --go_out=. --go-grpc_out=. proto/*.proto + +build: protobuf + go build -o tcp2grpc cmd/tcp2grpc/main.go + go build -o grpc2tcp cmd/grpc2tcp/main.go diff --git a/tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go b/tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go index 872924922..2b04343bb 100644 --- a/tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go +++ b/tools/tcp_grpc_proxy/cmd/grpc2tcp/main.go @@ -1,9 +1,9 @@ package main import ( - "fedlearner.net/tools/tcp_grpc_proxy/pkg/proxy" "flag" "fmt" + "tcp_grpc_proxy/proxy" ) func main() { @@ -14,6 +14,6 @@ func main() { flag.Parse() grpcServerAddress := fmt.Sprintf("0.0.0.0:%d", grpcServerPort) - grpc2tcpServer := proxy.NewGrpc2TcpServer(grpcServerAddress, targetTCPAddress) + grpc2tcpServer := proxy.NewGrpc2TCPServer(grpcServerAddress, targetTCPAddress) grpc2tcpServer.Run() } diff --git a/tools/tcp_grpc_proxy/cmd/grpcclient/main.go b/tools/tcp_grpc_proxy/cmd/grpcclient/main.go new file mode 100644 index 000000000..670a89f02 --- /dev/null +++ b/tools/tcp_grpc_proxy/cmd/grpcclient/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "bytes" + "context" + "os" + "time" + + "tcp_grpc_proxy/proto" + + "github.com/sirupsen/logrus" + "google.golang.org/grpc" +) + +func main() { + // Set up a connection to the server. + grpcServer := "127.0.0.1:7766" + conn, err := grpc.Dial(grpcServer, grpc.WithInsecure()) + if err != nil { + logrus.Fatalf("did not connect: %v", err) + } + defer conn.Close() + tsc := proto.NewTunnelServiceClient(conn) + + tc, err := tsc.Tunnel(context.Background()) + if err != nil { + logrus.Fatalln(err) + } + + sendPacket := func(data []byte) error { + return tc.Send(&proto.Chunk{Data: data}) + } + + go func() { + for { + chunk, err := tc.Recv() + if err != nil { + logrus.Println("Recv terminated:", err) + os.Exit(0) + } + logrus.Println(string(chunk.Data)) + } + + }() + + for { + time.Sleep(time.Duration(2) * time.Second) + buf := bytes.NewBufferString("************Hello World**********").Bytes() + sendPacket(buf) + } +} diff --git a/tools/tcp_grpc_proxy/cmd/grpcserver/main.go b/tools/tcp_grpc_proxy/cmd/grpcserver/main.go new file mode 100644 index 000000000..b17e4432f --- /dev/null +++ b/tools/tcp_grpc_proxy/cmd/grpcserver/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "tcp_grpc_proxy/grpc2tcp" +) + +func main() { + grpcServerAddress := "0.0.0.0:7766" + targetTCPAddress := "127.0.0.1:17766" + grpc2tcp.RunServer(grpcServerAddress, targetTCPAddress) +} diff --git a/tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go b/tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go index 9b81e8f75..fee88a884 100644 --- a/tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go +++ b/tools/tcp_grpc_proxy/cmd/tcp2grpc/main.go @@ -1,11 +1,49 @@ package main import ( - "fedlearner.net/tools/tcp_grpc_proxy/pkg/proxy" "flag" "fmt" + "io" + "net" + "os" + "tcp_grpc_proxy/proxy" ) +func test() { + client, err := net.Dial("tcp", "127.0.0.1:17767") + if err != nil { + fmt.Println("err:", err) + return + } + defer client.Close() + + go func() { + input := make([]byte, 1024) + for { + n, err := os.Stdin.Read(input) + if err != nil { + fmt.Println("input err:", err) + continue + } + client.Write([]byte(input[:n])) + } + }() + + buf := make([]byte, 1024) + for { + n, err := client.Read(buf) + if err != nil { + if err == io.EOF { + return + } + fmt.Println("read err:", err) + continue + } + fmt.Println(string(buf[:n])) + + } +} + func main() { var tcpServerPort int var targetGrpcAddress string @@ -14,6 +52,6 @@ func main() { flag.Parse() tcpServerAddress := fmt.Sprintf("0.0.0.0:%d", tcpServerPort) - tcp2grpcServer := proxy.NewTcp2GrpcServer(tcpServerAddress, targetGrpcAddress) + tcp2grpcServer := proxy.NewTCP2GrpcServer(tcpServerAddress, targetGrpcAddress) tcp2grpcServer.Run() } diff --git a/tools/tcp_grpc_proxy/cmd/tcpclient/main.go b/tools/tcp_grpc_proxy/cmd/tcpclient/main.go new file mode 100644 index 000000000..7e0c97467 --- /dev/null +++ b/tools/tcp_grpc_proxy/cmd/tcpclient/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "net" + "time" + + "github.com/sirupsen/logrus" +) + +func main() { + var tcpServerAddress string + flag.StringVar(&tcpServerAddress, "tcp_server_address", "127.0.0.1:17767", + "TCP server address which the client connects to.") + + conn, err := net.Dial("tcp", tcpServerAddress) + if err != nil { + logrus.Fatalf("Dail to tcp target %s error: %v", tcpServerAddress, err) + } + logrus.Infoln("Connected to", tcpServerAddress) + // Makes sure the connection gets closed + defer conn.Close() + defer logrus.Infoln("Connection closed to ", tcpServerAddress) + + for { + conn.Write([]byte("hello world")) + logrus.Infof("Sent 'hello world' to server %s", tcpServerAddress) + + tcpData := make([]byte, 64*1024) + _, err := conn.Read(tcpData) + if err != nil { + logrus.Fatalln("Read from tcp error: ", err) + } + logrus.Infof("Received '%s' from server", string(tcpData)) + + time.Sleep(time.Duration(5) * time.Second) + } +} diff --git a/tools/tcp_grpc_proxy/cmd/tcpserver/main.go b/tools/tcp_grpc_proxy/cmd/tcpserver/main.go new file mode 100644 index 000000000..592c7b6bd --- /dev/null +++ b/tools/tcp_grpc_proxy/cmd/tcpserver/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "flag" + "fmt" + "net" + + "github.com/sirupsen/logrus" +) + +func handleTCPConn(conn net.Conn) { + for { + tcpData := make([]byte, 64*1024) + bytesRead, err := conn.Read(tcpData) + if err != nil { + logrus.Fatalln("Read from tcp error: ", err) + } + logrus.Infof("TCP server got %d bytes", bytesRead) + conn.Write([]byte("This is a string from TCP server")) + } +} + +func main() { + var tcpServerPort int + flag.IntVar(&tcpServerPort, "tcp_server_port", 17766, "TCP server port") + flag.Parse() + tcpServerAddress := fmt.Sprintf("0.0.0.0:%d", tcpServerPort) + + listener, err := net.Listen("tcp", tcpServerAddress) + if err != nil { + logrus.Fatalln("Listen TCP error: ", err) + } + defer listener.Close() + logrus.Infoln("Run TCPServer at ", tcpServerAddress) + + for { + conn, err := listener.Accept() + if err != nil { + logrus.Errorln("TCP listener error:", err) + continue + } + + logrus.Infoln("Got tcp connection") + go handleTCPConn(conn) + } +} diff --git a/tools/tcp_grpc_proxy/go.mod b/tools/tcp_grpc_proxy/go.mod new file mode 100644 index 000000000..7507c284b --- /dev/null +++ b/tools/tcp_grpc_proxy/go.mod @@ -0,0 +1,12 @@ +module tcp_grpc_proxy + +go 1.16 + +require ( + github.com/golang/protobuf v1.5.2 // indirect + github.com/sirupsen/logrus v1.8.1 + golang.org/x/net v0.0.0-20210525063256-abc453219eb5 // indirect + google.golang.org/genproto v0.0.0-20200806141610-86f49bd18e98 // indirect + google.golang.org/grpc v1.38.0 + google.golang.org/protobuf v1.26.0 +) diff --git a/tools/tcp_grpc_proxy/go.sum b/tools/tcp_grpc_proxy/go.sum new file mode 100644 index 000000000..a372202d1 --- /dev/null +++ b/tools/tcp_grpc_proxy/go.sum @@ -0,0 +1,106 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5 h1:wjuX4b5yYQnEQHzd+CBcrcC6OVR2J1CN6mUy0oSxIPo= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200806141610-86f49bd18e98 h1:LCO0fg4kb6WwkXQXRQQgUYsFeFb5taTX5WAx5O/Vt28= +google.golang.org/genproto v0.0.0-20200806141610-86f49bd18e98/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/tools/tcp_grpc_proxy/proto/tunnel.proto b/tools/tcp_grpc_proxy/proto/tunnel.proto new file mode 100644 index 000000000..22ce1080b --- /dev/null +++ b/tools/tcp_grpc_proxy/proto/tunnel.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package proto; +option go_package = "proxy/proto"; + +service TunnelService { + rpc Tunnel (stream Chunk) returns (stream Chunk); +} + +message Chunk { + bytes data = 1; +} diff --git a/tools/tcp_grpc_proxy/proxy/grpc2tcp.go b/tools/tcp_grpc_proxy/proxy/grpc2tcp.go new file mode 100644 index 000000000..a9c5f598d --- /dev/null +++ b/tools/tcp_grpc_proxy/proxy/grpc2tcp.go @@ -0,0 +1,106 @@ +package proxy + +import ( + "fmt" + "io" + "net" + + "tcp_grpc_proxy/proxy/proto" + + "github.com/sirupsen/logrus" + "google.golang.org/grpc" +) + +// Grpc2TCPServer A server to proxy grpc traffic to TCP +type Grpc2TCPServer struct { + proto.UnimplementedTunnelServiceServer + grpcServerAddress string + targetTCPAddress string +} + +// Tunnel the implementation of gRPC Tunnel service +func (s *Grpc2TCPServer) Tunnel(stream proto.TunnelService_TunnelServer) error { + tcpConnection, err := net.Dial("tcp", s.targetTCPAddress) + if err != nil { + logrus.Errorf("Dail to tcp target %s error: %v", s.targetTCPAddress, err) + return err + } + logrus.Infoln("Connected to", s.targetTCPAddress) + // Makes sure the connection gets closed + defer tcpConnection.Close() + defer logrus.Infoln("Connection closed to ", s.targetTCPAddress) + + errChan := make(chan error) + + // Gets data from gRPC client and proxy to remote TCP server + go func() { + for { + chunk, err := stream.Recv() + if err == io.EOF { + return + } + if err != nil { + errChan <- fmt.Errorf("error while receiving gRPC data: %v", err) + return + } + + data := chunk.Data + logrus.Infof("Sending %d bytes to tcp server", len(data)) + _, err = tcpConnection.Write(data) + if err != nil { + errChan <- fmt.Errorf("error while sending TCP data: %v", err) + return + } + } + }() + + // Gets data from remote TCP server and proxy to gRPC client + go func() { + buff := make([]byte, 64*1024) + for { + bytesRead, err := tcpConnection.Read(buff) + if err == io.EOF { + logrus.Infoln("Remote TCP connection closed") + return + } + if err != nil { + errChan <- fmt.Errorf("error while receiving TCP data: %v", err) + return + } + + logrus.Infof("Sending %d bytes to gRPC client", bytesRead) + if err = stream.Send(&proto.Chunk{Data: buff[0:bytesRead]}); err != nil { + errChan <- fmt.Errorf("Error while sending gRPC data: %v", err) + return + } + } + }() + + // Blocking read + returnedError := <-errChan + return returnedError +} + +// NewGrpc2TCPServer constructs a Grpc2TCP server +func NewGrpc2TCPServer(grpcServerAddress, targetTCPAddress string) *Grpc2TCPServer { + return &Grpc2TCPServer{ + grpcServerAddress: grpcServerAddress, + targetTCPAddress: targetTCPAddress, + } +} + +// Run starts the Grpc2TCP server +func (s *Grpc2TCPServer) Run() { + listener, err := net.Listen("tcp", s.grpcServerAddress) + if err != nil { + logrus.Errorf("Failed to listen: ", err) + } + + // Starts a gRPC server and register services + grpcServer := grpc.NewServer() + proto.RegisterTunnelServiceServer(grpcServer, s) + logrus.Infof("Starting gRPC server at: %s, target to %s", s.grpcServerAddress, s.targetTCPAddress) + if err := grpcServer.Serve(listener); err != nil { + logrus.Errorln("Unable to start gRPC serve:", err) + } +} diff --git a/tools/tcp_grpc_proxy/proxy/proto/tunnel.pb.go b/tools/tcp_grpc_proxy/proxy/proto/tunnel.pb.go new file mode 100644 index 000000000..79602bc44 --- /dev/null +++ b/tools/tcp_grpc_proxy/proxy/proto/tunnel.pb.go @@ -0,0 +1,147 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.26.0 +// protoc v3.17.3 +// source: tunnel.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Chunk struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *Chunk) Reset() { + *x = Chunk{} + if protoimpl.UnsafeEnabled { + mi := &file_tunnel_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Chunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Chunk) ProtoMessage() {} + +func (x *Chunk) ProtoReflect() protoreflect.Message { + mi := &file_tunnel_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Chunk.ProtoReflect.Descriptor instead. +func (*Chunk) Descriptor() ([]byte, []int) { + return file_tunnel_proto_rawDescGZIP(), []int{0} +} + +func (x *Chunk) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +var File_tunnel_proto protoreflect.FileDescriptor + +var file_tunnel_proto_rawDesc = []byte{ + 0x0a, 0x0c, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x1b, 0x0a, 0x05, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x12, 0x12, + 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, + 0x74, 0x61, 0x32, 0x39, 0x0a, 0x0d, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x28, 0x0a, 0x06, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x0c, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x1a, 0x0c, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x28, 0x01, 0x30, 0x01, 0x42, 0x0d, 0x5a, + 0x0b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_tunnel_proto_rawDescOnce sync.Once + file_tunnel_proto_rawDescData = file_tunnel_proto_rawDesc +) + +func file_tunnel_proto_rawDescGZIP() []byte { + file_tunnel_proto_rawDescOnce.Do(func() { + file_tunnel_proto_rawDescData = protoimpl.X.CompressGZIP(file_tunnel_proto_rawDescData) + }) + return file_tunnel_proto_rawDescData +} + +var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_tunnel_proto_goTypes = []interface{}{ + (*Chunk)(nil), // 0: proto.Chunk +} +var file_tunnel_proto_depIdxs = []int32{ + 0, // 0: proto.TunnelService.Tunnel:input_type -> proto.Chunk + 0, // 1: proto.TunnelService.Tunnel:output_type -> proto.Chunk + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_tunnel_proto_init() } +func file_tunnel_proto_init() { + if File_tunnel_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_tunnel_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Chunk); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_tunnel_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_tunnel_proto_goTypes, + DependencyIndexes: file_tunnel_proto_depIdxs, + MessageInfos: file_tunnel_proto_msgTypes, + }.Build() + File_tunnel_proto = out.File + file_tunnel_proto_rawDesc = nil + file_tunnel_proto_goTypes = nil + file_tunnel_proto_depIdxs = nil +} diff --git a/tools/tcp_grpc_proxy/proxy/proto/tunnel_grpc.pb.go b/tools/tcp_grpc_proxy/proxy/proto/tunnel_grpc.pb.go new file mode 100644 index 000000000..f60817673 --- /dev/null +++ b/tools/tcp_grpc_proxy/proxy/proto/tunnel_grpc.pb.go @@ -0,0 +1,133 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// TunnelServiceClient is the client API for TunnelService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type TunnelServiceClient interface { + Tunnel(ctx context.Context, opts ...grpc.CallOption) (TunnelService_TunnelClient, error) +} + +type tunnelServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewTunnelServiceClient(cc grpc.ClientConnInterface) TunnelServiceClient { + return &tunnelServiceClient{cc} +} + +func (c *tunnelServiceClient) Tunnel(ctx context.Context, opts ...grpc.CallOption) (TunnelService_TunnelClient, error) { + stream, err := c.cc.NewStream(ctx, &TunnelService_ServiceDesc.Streams[0], "/proto.TunnelService/Tunnel", opts...) + if err != nil { + return nil, err + } + x := &tunnelServiceTunnelClient{stream} + return x, nil +} + +type TunnelService_TunnelClient interface { + Send(*Chunk) error + Recv() (*Chunk, error) + grpc.ClientStream +} + +type tunnelServiceTunnelClient struct { + grpc.ClientStream +} + +func (x *tunnelServiceTunnelClient) Send(m *Chunk) error { + return x.ClientStream.SendMsg(m) +} + +func (x *tunnelServiceTunnelClient) Recv() (*Chunk, error) { + m := new(Chunk) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// TunnelServiceServer is the server API for TunnelService service. +// All implementations must embed UnimplementedTunnelServiceServer +// for forward compatibility +type TunnelServiceServer interface { + Tunnel(TunnelService_TunnelServer) error + mustEmbedUnimplementedTunnelServiceServer() +} + +// UnimplementedTunnelServiceServer must be embedded to have forward compatible implementations. +type UnimplementedTunnelServiceServer struct { +} + +func (UnimplementedTunnelServiceServer) Tunnel(TunnelService_TunnelServer) error { + return status.Errorf(codes.Unimplemented, "method Tunnel not implemented") +} +func (UnimplementedTunnelServiceServer) mustEmbedUnimplementedTunnelServiceServer() {} + +// UnsafeTunnelServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TunnelServiceServer will +// result in compilation errors. +type UnsafeTunnelServiceServer interface { + mustEmbedUnimplementedTunnelServiceServer() +} + +func RegisterTunnelServiceServer(s grpc.ServiceRegistrar, srv TunnelServiceServer) { + s.RegisterService(&TunnelService_ServiceDesc, srv) +} + +func _TunnelService_Tunnel_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(TunnelServiceServer).Tunnel(&tunnelServiceTunnelServer{stream}) +} + +type TunnelService_TunnelServer interface { + Send(*Chunk) error + Recv() (*Chunk, error) + grpc.ServerStream +} + +type tunnelServiceTunnelServer struct { + grpc.ServerStream +} + +func (x *tunnelServiceTunnelServer) Send(m *Chunk) error { + return x.ServerStream.SendMsg(m) +} + +func (x *tunnelServiceTunnelServer) Recv() (*Chunk, error) { + m := new(Chunk) + if err := x.ServerStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// TunnelService_ServiceDesc is the grpc.ServiceDesc for TunnelService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TunnelService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "proto.TunnelService", + HandlerType: (*TunnelServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Tunnel", + Handler: _TunnelService_Tunnel_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "tunnel.proto", +} diff --git a/tools/tcp_grpc_proxy/proxy/tcp2grpc.go b/tools/tcp_grpc_proxy/proxy/tcp2grpc.go new file mode 100644 index 000000000..63b5586b8 --- /dev/null +++ b/tools/tcp_grpc_proxy/proxy/tcp2grpc.go @@ -0,0 +1,104 @@ +package proxy + +import ( + "context" + "io" + "net" + "tcp_grpc_proxy/proxy/proto" + + "github.com/sirupsen/logrus" + "google.golang.org/grpc" +) + +// TCP2GrpcServer to proxy TCP traffic to gRPC +type TCP2GrpcServer struct { + tcpServerAddress string + targetGrpcAddress string +} + +// NewTCP2GrpcServer constructs a TCP2GrpcServer +func NewTCP2GrpcServer(tcpServerAddress, targetGrpcAddress string) *TCP2GrpcServer { + return &TCP2GrpcServer{ + tcpServerAddress: tcpServerAddress, + targetGrpcAddress: targetGrpcAddress, + } +} + +func handleTCPConn(tcpConn net.Conn, targetGrpcAddress string) { + logrus.Infoln("Handle tcp connection, target to:", targetGrpcAddress) + defer tcpConn.Close() + + grpcConn, err := grpc.Dial(targetGrpcAddress, grpc.WithInsecure()) + if err != nil { + logrus.Errorf("Error during connect to grpc %s: %v", targetGrpcAddress, err) + return + } + defer grpcConn.Close() + + grpcClient := proto.NewTunnelServiceClient(grpcConn) + stream, err := grpcClient.Tunnel(context.Background()) + if err != nil { + logrus.Errorf("Error of tunnel service: %v", err) + return + } + + // Gets data from remote gRPC server and proxy to TCP client + go func() { + for { + chunk, err := stream.Recv() + if err != nil { + logrus.Errorf("Recv from grpc target %s terminated: %v", targetGrpcAddress, err) + return + } + logrus.Infof("Sending %d bytes to TCP client", len(chunk.Data)) + tcpConn.Write(chunk.Data) + } + }() + + // Gets data from TCP client and proxy to remote gRPC server + func() { + for { + tcpData := make([]byte, 64*1024) + bytesRead, err := tcpConn.Read(tcpData) + + if err == io.EOF { + logrus.Infoln("Connection finished") + return + } + if err != nil { + logrus.Errorf("Read from tcp error: %v", err) + return + } + logrus.Infof("Sending %d bytes to gRPC server", bytesRead) + if err := stream.Send(&proto.Chunk{Data: tcpData[0:bytesRead]}); err != nil { + logrus.Errorf("Failed to send gRPC data: %v", err) + return + } + } + }() + + // If tcp connection gets closed, then we close the gRPC connection. + stream.CloseSend() + return +} + +// Run Starts the server +func (s *TCP2GrpcServer) Run() { + listener, err := net.Listen("tcp", s.tcpServerAddress) + if err != nil { + logrus.Fatalln("Listen TCP error: ", err) + } + defer listener.Close() + logrus.Infoln("Run TCPServer at ", s.tcpServerAddress) + + for { + conn, err := listener.Accept() + if err != nil { + logrus.Errorln("TCP listener error:", err) + continue + } + + logrus.Infoln("Got tcp connection") + go handleTCPConn(conn, s.targetGrpcAddress) + } +} From 78e92f3a31dc201ff9b56207473d25ff61ac9af0 Mon Sep 17 00:00:00 2001 From: gezhengqiang <gezhengqiang@bytedance.com> Date: Thu, 29 Feb 2024 17:30:00 +0800 Subject: [PATCH 13/15] feat(sgx): add sidecar dockerfile --- sgx_network_simulation/Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/sgx_network_simulation/Dockerfile b/sgx_network_simulation/Dockerfile index 279b3ac5c..224d5f11e 100644 --- a/sgx_network_simulation/Dockerfile +++ b/sgx_network_simulation/Dockerfile @@ -28,6 +28,3 @@ COPY sgx_network_simulation/ /app/ WORKDIR /app COPY --from=go /app/tcp2grpc ./ COPY --from=go /app/grpc2tcp ./ -RUN pip3 install -r requirements.txt && make protobuf - -ENTRYPOINT ["bash", "docker_entrypoint.sh"] From 4b02103b502e42a5459e20161ae5889d46895e76 Mon Sep 17 00:00:00 2001 From: gezhengqiang <gezhengqiang@bytedance.com> Date: Wed, 6 Mar 2024 17:56:22 +0800 Subject: [PATCH 14/15] fix(client): fix tag set bug --- .../VariableItem/WidgetSchema.tsx | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.tsx b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.tsx index c72ad8bf3..73cdebcc5 100644 --- a/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.tsx +++ b/web_console_v2/client/src/views/WorkflowTemplates/TemplateConfig/JobComposeDrawer/VariableList/VariableItem/WidgetSchema.tsx @@ -403,23 +403,15 @@ const WidgetSchema: FC<Props> = ({ form, path, value, isCheck }) => { function onTypeChange(type: VariableValueType) { setValueType(type); - const variables = form.getFieldValue('variables'); - - set(variables, `[${variableIdx[0]}].value_type`, type); - - form.setFieldsValue({ - variables, - }); + const result = { variables: form.getFieldValue('variables') }; + set(result, `[${variableIdx[0]}].value_type`, type); + form.setFieldsValue(result); } function onTagChange(tag: string) { - const variables = form.getFieldValue('variables'); - - set(variables, `[${variableIdx[0]}].tag`, tag); - - form.setFieldsValue({ - variables, - }); + const result = { variables: form.getFieldValue('variables') }; + set(result, `[${variableIdx[0]}].tag`, tag); + form.setFieldsValue(result); } function onComponentChange(val: VariableComponent) { From 8fc18beb7d709e848dff32ac3cd0f0b904690e43 Mon Sep 17 00:00:00 2001 From: gezhengqiang <gezhengqiang@bytedance.com> Date: Tue, 19 Mar 2024 14:07:03 +0800 Subject: [PATCH 15/15] feat(simulation): update sidecar.sh for ports --- sgx_network_simulation/sidecar.sh | 42 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/sgx_network_simulation/sidecar.sh b/sgx_network_simulation/sidecar.sh index dae4a124b..5933fc361 100644 --- a/sgx_network_simulation/sidecar.sh +++ b/sgx_network_simulation/sidecar.sh @@ -1,16 +1,33 @@ #!/bin/bash set -ex -FILE_PATH="/pod-data/listen_port" -while [ ! -s "$FILE_PATH" ]; do - echo "wait for $FILE_PATH ..." +LISTEN_PORT_PATH="/pod-data/listen_port" +while [ ! -s "$LISTEN_PORT_PATH" ]; do + echo "wait for $LISTEN_PORT_PATH ..." sleep 1 done -WORKER_LISTEN_PORT=$(cat "$FILE_PATH") +WORKER_LISTEN_PORT=$(cat "$LISTEN_PORT_PATH") + +PROXY_LOCAL_PORT_PATH="/pod-data/proxy_local_port" +while [ ! -s "$PROXY_LOCAL_PORT_PATH" ]; do + echo "wait for $PROXY_LOCAL_PORT_PATH ..." + sleep 1 +done +PROXY_LOCAL_PORT=$(cat "$PROXY_LOCAL_PORT_PATH") + +GRPC_SERVER_PORT=32001 +if [ -n "$PORT0" ]; then + GRPC_SERVER_PORT=$PORT0 +fi + +TARGET_GRPC_PORT=32102 +if [ -n "$PORT1" ]; then + TARGET_GRPC_PORT=$PORT1 +fi echo "# Forwards all traffic to nginx controller server { - listen 32102 http2; + listen ${TARGET_GRPC_PORT} http2; # No limits client_max_body_size 0; @@ -32,27 +49,18 @@ server { } " > nginx/sidecar.conf -if [ -z "$PORT0" ]; then - PORT0=32001 -fi - -if [ -z "$PORT2" ]; then - PORT2=32102 -fi - -sed -i "s/listen [0-9]* http2;/listen $PORT2 http2;/" nginx/sidecar.conf - +rm -rf /etc/nginx/conf.d/* cp nginx/sidecar.conf /etc/nginx/conf.d/ service nginx restart # Server sidecar: grpc to tcp, 5001 is the server port of main container echo "Starting server sidecar" -./grpc2tcp --grpc_server_port=$PORT0 \ +./grpc2tcp --grpc_server_port=$GRPC_SERVER_PORT \ --target_tcp_address="localhost:$WORKER_LISTEN_PORT" & echo "Starting client sidecar" ./tcp2grpc --tcp_server_port="$PROXY_LOCAL_PORT" \ - --target_grpc_address="localhost:$PORT2" & + --target_grpc_address="localhost:$TARGET_GRPC_PORT" & echo "===========Sidecar started!!============="