diff --git a/bkflow/apigw/docs/apigw-docs.zip b/bkflow/apigw/docs/apigw-docs.zip index 1e14b43..fa4d4e2 100644 Binary files a/bkflow/apigw/docs/apigw-docs.zip and b/bkflow/apigw/docs/apigw-docs.zip differ diff --git a/bkflow/apigw/docs/zh/get_tasks_states.md b/bkflow/apigw/docs/zh/get_tasks_states.md new file mode 100644 index 0000000..b6efce2 --- /dev/null +++ b/bkflow/apigw/docs/zh/get_tasks_states.md @@ -0,0 +1,62 @@ +### 资源描述 + +批量获取任务状态 + +### 输入通用参数说明 +| 参数名称 | 参数类型 | 必须 | 参数说明 | +|---------------|--------|----|------------------------------------------------------------| +| bk_app_code | string | 是 | 应用ID(app id),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | +| bk_app_secret | string | 是 | 安全秘钥(app secret),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | + +#### 接口参数 +| 字段 | 类型 | 必选 | 描述 | +|----------|-----------|----|---------------| +| task_ids | list[int] | 是 | 需要查询的任务 id 列表 | + + +### 请求参数示例 +```json +{ + "bk_app_code": "xxxx", + "bk_app_secret": "xxxx", + "bk_username or bk_token": "xxxx", + "task_ids": [1, 2, 3, 4] +} +``` + + +### 返回结果示例 + +```json +{ + "result": true, + "data": { + "3": { + "state": "FINISHED" + }, + "2": { + "state": "FINISHED" + }, + "1": { + "state": "FINISHED" + } + }, + "code": "0", + "message": "" +} +``` +### 返回结果参数说明 + +| 字段 | 类型 | 描述 | +|---------|--------|-----------------------| +| result | bool | 返回结果,true为成功,false为失败 | +| code | int | 返回码,0表示成功,其他值表示失败 | +| message | string | 错误信息 | +| data | dict | 返回数据 | + +#### data[item] + +| 字段 | 类型 | 描述 | +|-----------------|--------|-----------| +| id | string | 任务实例ID | +| state | string | 任务状态 | diff --git a/bkflow/apigw/docs/zh/validate_pipeline_tree.md b/bkflow/apigw/docs/zh/validate_pipeline_tree.md new file mode 100644 index 0000000..edbe61f --- /dev/null +++ b/bkflow/apigw/docs/zh/validate_pipeline_tree.md @@ -0,0 +1,260 @@ +### 资源描述 + +校验任务结构树是否合法 + +### 输入通用参数说明 +| 参数名称 | 参数类型 | 必须 | 参数说明 | +|---------------|--------|----|------------------------------------------------------------| +| bk_app_code | string | 是 | 应用ID(app id),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | +| bk_app_secret | string | 是 | 安全秘钥(app secret),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | + + +#### 接口参数 + +| 字段 | 类型 | 必选 | 描述 | +|---------------|--------|----|-------| +| pipeline_tree | json | 是 | 任务结构树 | + + + +### 请求参数示例 + +```json +{ + "bk_app_code": "xxxx", + "bk_app_secret": "xxxx", + "bk_username or bk_token": "xxxx", + "pipeline_tree": { + "name": "test", + "activities": { + "nf834705dbbb37c59ad114aa37314975": { + "component": { + "code": "bk_display", + "data": { + "bk_display_message": { + "hook": false, + "need_render": true, + "value": "" + } + }, + "version": "v1.0" + }, + "error_ignorable": false, + "id": "nf834705dbbb37c59ad114aa37314975", + "incoming": [ + "lee4ca362c673536958aa656cb36efda" + ], + "loop": null, + "name": "消息展示", + "optional": true, + "outgoing": "l2e819009a9a3714ab108ad5c594bb73", + "stage_name": "", + "type": "ServiceActivity", + "retryable": true, + "skippable": true, + "auto_retry": { + "enable": false, + "interval": 0, + "times": 1 + }, + "timeout_config": { + "enable": false, + "seconds": 10, + "action": "forced_fail" + }, + "labels": [] + } + }, + "end_event": { + "id": "n2dfd1233f633cdf864fc3681eb2b0b3", + "incoming": [ + "l2e819009a9a3714ab108ad5c594bb73" + ], + "name": "", + "outgoing": "", + "type": "EmptyEndEvent", + "labels": [] + }, + "flows": { + "lee4ca362c673536958aa656cb36efda": { + "id": "lee4ca362c673536958aa656cb36efda", + "is_default": false, + "source": "n1df1598dba137aba81094851379435a", + "target": "nf834705dbbb37c59ad114aa37314975" + }, + "l2e819009a9a3714ab108ad5c594bb73": { + "id": "l2e819009a9a3714ab108ad5c594bb73", + "is_default": false, + "source": "nf834705dbbb37c59ad114aa37314975", + "target": "n2dfd1233f633cdf864fc3681eb2b0b3" + } + }, + "gateways": {}, + "line": [ + { + "id": "lee4ca362c673536958aa656cb36efda", + "source": { + "arrow": "Right", + "id": "n1df1598dba137aba81094851379435a" + }, + "target": { + "arrow": "Left", + "id": "nf834705dbbb37c59ad114aa37314975" + } + }, + { + "id": "l2e819009a9a3714ab108ad5c594bb73", + "source": { + "arrow": "Right", + "id": "nf834705dbbb37c59ad114aa37314975" + }, + "target": { + "arrow": "Left", + "id": "n2dfd1233f633cdf864fc3681eb2b0b3" + } + } + ], + "location": [ + { + "id": "n1df1598dba137aba81094851379435a", + "type": "startpoint", + "x": 40, + "y": 150 + }, + { + "id": "nf834705dbbb37c59ad114aa37314975", + "type": "tasknode", + "name": "消息展示", + "stage_name": "", + "x": 240, + "y": 140, + "group": "蓝鲸服务(BK)", + "icon": "", + "optional": true, + "error_ignorable": false, + "retryable": true, + "skippable": true, + "auto_retry": { + "enable": false, + "interval": 0, + "times": 1 + }, + "timeout_config": { + "enable": false, + "seconds": 10, + "action": "forced_fail" + } + }, + { + "id": "n2dfd1233f633cdf864fc3681eb2b0b3", + "type": "endpoint", + "x": 540, + "y": 150 + } + ], + "outputs": [], + "start_event": { + "id": "n1df1598dba137aba81094851379435a", + "incoming": "", + "name": "", + "outgoing": "lee4ca362c673536958aa656cb36efda", + "type": "EmptyStartEvent", + "labels": [] + }, + "constants": {}, + "projectBaseInfo": {}, + "notify_receivers": { + "receiver_group": [], + "more_receiver": "" + }, + "notify_type": { + "success": [], + "fail": [] + }, + "template_labels": [], + "internalVariable": { + "${_system.task_name}": { + "key": "${_system.task_name}", + "name": "任务名称", + "index": -1, + "desc": "", + "show_type": "hide", + "source_type": "system", + "source_tag": "", + "source_info": {}, + "custom_type": "", + "value": "", + "hook": false, + "validation": "" + }, + "${_system.task_id}": { + "key": "${_system.task_id}", + "index": -2, + "name": "任务ID", + "desc": "", + "show_type": "hide", + "source_type": "system", + "source_tag": "", + "source_info": {}, + "custom_type": "", + "value": "", + "hook": false, + "validation": "" + }, + "${_system.task_start_time}": { + "key": "${_system.task_start_time}", + "name": "任务开始时间", + "index": -3, + "desc": "", + "show_type": "hide", + "source_type": "system", + "source_tag": "", + "source_info": {}, + "custom_type": "", + "value": "", + "hook": false, + "validation": "" + }, + "${_system.operator}": { + "key": "${_system.operator}", + "name": "任务的执行人(点击开始执行的人员)", + "index": -4, + "desc": "", + "show_type": "hide", + "source_type": "system", + "source_tag": "", + "source_info": {}, + "custom_type": "", + "value": "", + "hook": false, + "validation": "" + } + }, + "spaceId": 1, + "scopeInfo": { + "scope_type": null, + "scope_value": null + } + } +} +``` + +### 返回结果示例 + +```json +{ + "result": true, + "data": {}, + "code": "0", + "message": "" +} + +``` +### 返回结果参数说明 + +| 字段 | 类型 | 描述 | +|---------|--------|-----------------------| +| result | bool | 返回结果,true为成功,false为失败 | +| code | int | 返回码,0表示成功,其他值表示失败 | +| message | string | 错误信息 | +| data | dict | 返回数据 | diff --git a/bkflow/apigw/management/commands/data/api-resources.yml b/bkflow/apigw/management/commands/data/api-resources.yml index 26d9a83..29e447c 100644 --- a/bkflow/apigw/management/commands/data/api-resources.yml +++ b/bkflow/apigw/management/commands/data/api-resources.yml @@ -127,6 +127,30 @@ paths: userVerifiedRequired: false disabledStages: [ ] descriptionEn: Create a task without template + /space/{space_id}/validate_pipeline_tree/: + post: + operationId: validate_pipeline_tree + description: 校验 pipeline_tree 是否合法 + tags: [ ] + responses: + default: + description: '' + x-bk-apigateway-resource: + isPublic: true + allowApplyPermission: true + matchSubpath: false + backend: + type: HTTP + method: post + path: /{env.api_sub_path}apigw/space/{space_id}/validate_pipeline_tree/ + matchSubpath: false + timeout: 0 + upstreams: { } + transformHeaders: { } + authConfig: + userVerifiedRequired: false + disabledStages: [ ] + descriptionEn: Validate a pipeline tree /space/{space_id}/create_mock_task/: post: operationId: create_mock_task @@ -319,6 +343,30 @@ paths: userVerifiedRequired: false disabledStages: [] descriptionEn: Get states of a task + /space/{space_id}/get_tasks_states/: + post: + operationId: get_tasks_states + description: 批量获取任务状态 + tags: [ ] + responses: + default: + description: '' + x-bk-apigateway-resource: + isPublic: true + allowApplyPermission: true + matchSubpath: false + backend: + type: HTTP + method: post + path: /{env.api_sub_path}apigw/space/{space_id}/get_tasks_states/ + matchSubpath: false + timeout: 0 + upstreams: { } + transformHeaders: { } + authConfig: + userVerifiedRequired: false + disabledStages: [ ] + descriptionEn: Get states of a task with filter params /space/{space_id}/task/{task_id}/operate_task/{operation}/: post: operationId: operate_task diff --git a/bkflow/apigw/serializers/task.py b/bkflow/apigw/serializers/task.py index 5c17f1b..db1bdd1 100644 --- a/bkflow/apigw/serializers/task.py +++ b/bkflow/apigw/serializers/task.py @@ -18,9 +18,12 @@ to the current version of the project delivered to anyone in the future. """ from django.utils.translation import ugettext_lazy as _ +from pipeline.exceptions import PipelineException from rest_framework import serializers from bkflow.constants import MAX_LEN_OF_TASK_NAME, USER_NAME_MAX_LENGTH +from bkflow.pipeline_web.parser.validator import validate_web_pipeline_tree +from bkflow.utils.strings import standardize_pipeline_node_name class CreateTaskSerializer(serializers.Serializer): @@ -66,6 +69,17 @@ class CreateTaskWithoutTemplateSerializer(serializers.Serializer): pipeline_tree = serializers.JSONField(help_text=_("任务树"), required=True) +class PipelineTreeSerializer(serializers.Serializer): + pipeline_tree = serializers.JSONField(help_text=_("任务树"), required=True) + + def validate_pipeline_tree(self, pipeline_tree): + try: + standardize_pipeline_node_name(pipeline_tree) + validate_web_pipeline_tree(pipeline_tree) + except PipelineException as e: + raise serializers.ValidationError(str(e)) + + class GetTaskListSerializer(serializers.Serializer): scope_type = serializers.CharField(help_text=_("流程范围类型"), max_length=128, required=False) scope_value = serializers.CharField(help_text=_("流程范围值"), max_length=128, required=False) @@ -77,6 +91,10 @@ class GetTaskListSerializer(serializers.Serializer): name = serializers.CharField(help_text=_("任务名"), max_length=MAX_LEN_OF_TASK_NAME, required=False) +class GetTasksStatesSerializer(serializers.Serializer): + task_ids = serializers.ListField(required=True, child=serializers.IntegerField()) + + class OperateTaskSerializer(serializers.Serializer): operator = serializers.CharField(help_text=_("操作人"), max_length=USER_NAME_MAX_LENGTH, required=True) diff --git a/bkflow/apigw/urls.py b/bkflow/apigw/urls.py index 9931159..8157a1b 100644 --- a/bkflow/apigw/urls.py +++ b/bkflow/apigw/urls.py @@ -41,6 +41,7 @@ from bkflow.apigw.views.get_task_list import get_task_list from bkflow.apigw.views.get_task_node_detail import get_task_node_detail from bkflow.apigw.views.get_task_states import get_task_states + from bkflow.apigw.views.get_tasks_states import get_tasks_states from bkflow.apigw.views.get_template_detail import get_template_detail from bkflow.apigw.views.get_template_list import get_template_list from bkflow.apigw.views.grant_apigw_permissions_to_app import ( @@ -51,6 +52,7 @@ from bkflow.apigw.views.renew_space_config import renew_space_config from bkflow.apigw.views.revoke_token import revoke_token from bkflow.apigw.views.update_template import update_template + from bkflow.apigw.views.validate_pipeline_tree import validate_pipeline_tree urlpatterns += [ url(r"^create_space/$", create_space), @@ -67,10 +69,12 @@ url(r"^space/(?P\d+)/create_task/$", create_task), url(r"^space/(?P\d+)/create_mock_task/$", create_mock_task), url(r"^space/(?P\d+)/create_task_without_template/$", create_task_without_template), + url(r"^space/(?P\d+)/validate_pipeline_tree/$", validate_pipeline_tree), url(r"^space/(?P\d+)/create_credential/$", create_credential), url(r"^space/(?P\d+)/get_task_list/$", get_task_list), url(r"^space/(?P\d+)/task/(?P\d+)/get_task_detail/$", get_task_detail), url(r"^space/(?P\d+)/task/(?P\d+)/get_task_states/$", get_task_states), + url(r"^space/(?P\d+)/get_tasks_states/$", get_tasks_states), url( r"^space/(?P\d+)/task/(?P\d+)/node/(?P\w+)/get_task_node_detail/$", get_task_node_detail, diff --git a/bkflow/apigw/views/get_tasks_states.py b/bkflow/apigw/views/get_tasks_states.py new file mode 100644 index 0000000..320e74d --- /dev/null +++ b/bkflow/apigw/views/get_tasks_states.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +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. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +import json + +from apigw_manager.apigw.decorators import apigw_require +from blueapps.account.decorators import login_exempt +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from bkflow.apigw.decorators import check_jwt_and_space, return_json_response +from bkflow.apigw.serializers.task import GetTasksStatesSerializer +from bkflow.contrib.api.collections.task import TaskComponentClient + + +@login_exempt +@csrf_exempt +@require_POST +@apigw_require +@check_jwt_and_space +@return_json_response +def get_tasks_states(request, space_id): + data = json.loads(request.body) + ser = GetTasksStatesSerializer(data=data) + ser.is_valid(raise_exception=True) + + client = TaskComponentClient(space_id=space_id) + data = {"space_id": space_id, **ser.validated_data} + result = client.get_tasks_states(data=data) + return result diff --git a/bkflow/apigw/views/validate_pipeline_tree.py b/bkflow/apigw/views/validate_pipeline_tree.py new file mode 100644 index 0000000..dabb8f5 --- /dev/null +++ b/bkflow/apigw/views/validate_pipeline_tree.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +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. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +import json + +from apigw_manager.apigw.decorators import apigw_require +from blueapps.account.decorators import login_exempt +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from bkflow.apigw.decorators import check_jwt_and_space, return_json_response +from bkflow.apigw.serializers.task import PipelineTreeSerializer +from bkflow.utils import err_code + + +@login_exempt +@csrf_exempt +@require_POST +@apigw_require +@check_jwt_and_space +@return_json_response +def validate_pipeline_tree(request, space_id): + data = json.loads(request.body) + ser = PipelineTreeSerializer(data=data) + ser.is_valid(raise_exception=True) + return {"result": True, "data": {}, "message": "", "code": err_code.SUCCESS.code} diff --git a/bkflow/space/configs.py b/bkflow/space/configs.py index c325a97..c55973a 100644 --- a/bkflow/space/configs.py +++ b/bkflow/space/configs.py @@ -271,14 +271,14 @@ class GatewayExpressionConfig(BaseSpaceConfig): name = "gateway_expression" desc = _("网关表达式") default_value = "boolrule" - choices = ["boolrule", "FEEL"] + choices = ["boolrule", "FEEL", "MAKO"] @classmethod def validate(cls, value: str): if value not in cls.choices: raise ValidationError( f"[validate gateway expression error]: gateway expression only support " - f"'boolrule' or 'FEEL', value: {value}" + f"'boolrule' or 'FEEL' or 'MAKO', value: {value}" ) return True diff --git a/bkflow/utils/mako.py b/bkflow/utils/mako.py new file mode 100644 index 0000000..c2169d8 --- /dev/null +++ b/bkflow/utils/mako.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +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. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" + +from bamboo_engine.template.template import Template + + +def parse_mako_expression(expression, context): + parsed_result = Template(expression).render(context) + if parsed_result not in ["True", "False"]: + raise ValueError(f"The result of the expression must be a boolean, now is {parsed_result}") + return parsed_result == "True" diff --git a/bkflow/utils/pipeline.py b/bkflow/utils/pipeline.py index a14f985..382315a 100644 --- a/bkflow/utils/pipeline.py +++ b/bkflow/utils/pipeline.py @@ -23,6 +23,8 @@ from bkflow_feel.api import parse_expression from pipeline.parser.utils import recursive_replace_id +from bkflow.utils.mako import parse_mako_expression + DEFAULT_HORIZONTAL_PIPELINE_TREE = { "activities": { "node89f4f55f853d71c6a15e83d0d0ca": { @@ -208,4 +210,6 @@ def build_default_pipeline_tree(horizontal_canvas=True): def pipeline_gateway_expr_func(expr: str, context: dict, extra_info: dict, *args, **kwargs) -> bool: if extra_info.get("parse_lang") == "FEEL": return parse_expression(expression=expr) + if extra_info.get("parse_lang") == "MAKO": + return parse_mako_expression(expression=expr, context=context) return BoolRule(expr).test() diff --git a/config/default.py b/config/default.py index 47e4dcc..094d687 100644 --- a/config/default.py +++ b/config/default.py @@ -22,6 +22,7 @@ import datetime import json +from bamboo_engine.config import Settings as BambooSettings from blueapps.conf.default_settings import * # noqa from blueapps.conf.log import get_logging_config_dict from blueapps.opentelemetry.utils import inject_logging_trace_info @@ -128,6 +129,62 @@ UUID_DIGIT_STARTS_SENSITIVE = True PIPELINE_EXCLUSIVE_GATEWAY_EXPR_FUNC = pipeline_gateway_expr_func +# pipeline mako render settings +MAKO_SANDBOX_SHIELD_WORDS = [ + "ascii", + "bytearray", + "bytes", + "callable", + "chr", + "classmethod", + "compile", + "delattr", + "dir", + "divmod", + "exec", + "eval", + "filter", + "frozenset", + "getattr", + "globals", + "hasattr", + "hash", + "help", + "id", + "input", + "isinstance", + "issubclass", + "iter", + "locals", + "map", + "memoryview", + "next", + "object", + "open", + "print", + "property", + "repr", + "setattr", + "staticmethod", + "super", + "type", + "vars", + "__import__", +] +BambooSettings.MAKO_SANDBOX_SHIELD_WORDS = MAKO_SANDBOX_SHIELD_WORDS +MAKO_SANDBOX_IMPORT_MODULES = { + "datetime": "datetime", + "re": "re", + "hashlib": "hashlib", + "random": "random", + "time": "time", + "os.path": "os.path", + "json": "json", +} +BambooSettings.MAKO_SANDBOX_IMPORT_MODULES = MAKO_SANDBOX_IMPORT_MODULES +# 支持 mako 表达式在 dict/list/tuple 情况下嵌套索引 +BambooSettings.ENABLE_RENDER_OBJ_BY_MAKO_STRING = True + # 所有环境的日志级别可以在这里配置 # LOG_LEVEL = 'INFO' diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 35f673b..37cbf87 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -191,8 +191,8 @@ body { min-width: 1366px; } .with-system-notice { - height: calc(100vh - 40px); - /deep/.container-content { + height: calc(100vh - 40px) !important; + .container-content { max-height: calc(100vh - 92px)!important; } } diff --git a/frontend/src/components/ProcessCanvas/components/dnd/index.vue b/frontend/src/components/ProcessCanvas/components/dnd/index.vue index 0dcc026..373cdf3 100644 --- a/frontend/src/components/ProcessCanvas/components/dnd/index.vue +++ b/frontend/src/components/ProcessCanvas/components/dnd/index.vue @@ -10,25 +10,10 @@ 'node-item', node.id === 'group' ? 'group-node' : `common-icon-node-${node.icon}`, { disabled: ['start', 'end'].includes(node.id) }, - { actived: node.id === 'task' && menuType === 'plugin' } ]" - :data-type="node.id" - @click="menuType = node.id === 'task' ? 'plugin' : ''"> - group + :data-type="node.id"> - - - diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/VariablePopover.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/VariablePopover.vue index ef8746e..0be27a6 100644 --- a/frontend/src/views/template/TemplateEdit/NodeConfig/VariablePopover.vue +++ b/frontend/src/views/template/TemplateEdit/NodeConfig/VariablePopover.vue @@ -77,12 +77,13 @@