You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by su...@apache.org on 2021/04/15 01:01:04 UTC
[apisix-dashboard] branch master updated: feat: added cors plugin
form (#1733)
This is an automated email from the ASF dual-hosted git repository.
sunyi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new fb75809 feat: added cors plugin form (#1733)
fb75809 is described below
commit fb758091ddcbb445f6b529b3f26defbeddddab91
Author: guoqqqi <72...@users.noreply.github.com>
AuthorDate: Thu Apr 15 09:00:56 2021 +0800
feat: added cors plugin form (#1733)
---
.../create-upstream-with-cors-form.spec.js | 101 ++++++++++++
.../route/create-route-with-cors-form.spec.js | 93 +++++++++++
web/src/components/Plugin/PluginDetail.tsx | 30 +++-
web/src/components/Plugin/UI/cors.tsx | 179 +++++++++++++++++++++
web/src/components/Plugin/UI/plugin.tsx | 5 +-
web/src/components/Plugin/locales/en-US.ts | 9 ++
web/src/components/Plugin/locales/zh-CN.ts | 9 ++
7 files changed, 419 insertions(+), 7 deletions(-)
diff --git a/web/cypress/integration/consumer/create-upstream-with-cors-form.spec.js b/web/cypress/integration/consumer/create-upstream-with-cors-form.spec.js
new file mode 100644
index 0000000..3bf2111
--- /dev/null
+++ b/web/cypress/integration/consumer/create-upstream-with-cors-form.spec.js
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+/* eslint-disable no-undef */
+
+context('Create and Delete Consumer', () => {
+ beforeEach(() => {
+ cy.login();
+
+ cy.fixture('selector.json').as('domSelector');
+ cy.fixture('data.json').as('data');
+ });
+
+ const selector = {
+ max_age: "#max_age"
+ }
+
+ const data = {
+ time: 2,
+ }
+
+ it('creates consumer with cors form', function () {
+ cy.visit('/');
+ cy.contains('Consumer').click();
+ cy.get(this.domSelector.empty).should('be.visible');
+ cy.contains('Create').click();
+ // basic information
+ cy.get(this.domSelector.username).type(this.data.consumerName);
+ cy.get(this.domSelector.description).type(this.data.description);
+ cy.contains('Next').click();
+
+ // config auth plugin
+ cy.contains(this.domSelector.pluginCard, 'key-auth').within(() => {
+ cy.contains('Enable').click({ force: true });
+ });
+ cy.focused(this.domSelector.drawer).should('exist');
+ cy.get(this.domSelector.disabledSwitcher).click().should('have.class', 'ant-switch-checked');
+ // edit codemirror
+ cy.get(this.domSelector.codeMirror)
+ .first()
+ .then((editor) => {
+ editor[0].CodeMirror.setValue(
+ JSON.stringify({
+ key: 'test',
+ }),
+ );
+ cy.contains('button', 'Submit').click();
+ });
+
+ cy.contains(this.domSelector.pluginCard, 'cors').within(() => {
+ cy.contains('Enable').click({
+ force: true,
+ });
+ });
+
+ cy.get(this.domSelector.drawer).should('be.visible');
+
+ cy.get(selector.max_age).clear();
+ // config proxy-mirror form
+ cy.get(this.domSelector.drawer).within(() => {
+ cy.contains('Submit').click({
+ force: true,
+ });
+ });
+ cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data');
+ cy.get(this.domSelector.notificationCloseIcon).click().should('not.exist');
+
+ cy.get(selector.max_age).type(data.time);
+ cy.get(this.domSelector.drawer).within(() => {
+ cy.contains('Submit').click({
+ force: true,
+ });
+ });
+ cy.get(this.domSelector.drawer).should('not.exist');
+
+ cy.contains('button', 'Next').click();
+ cy.contains('button', 'Submit').click();
+ cy.get(this.domSelector.notification).should('contain', this.data.createConsumerSuccess);
+ cy.get(this.domSelector.notificationCloseIcon).click().should('not.exist');
+ });
+
+ it('delete the consumer', function () {
+ cy.visit('/consumer/list');
+ cy.contains(this.data.consumerName).should('be.visible').siblings().contains('Delete').click();
+ cy.contains('button', 'Confirm').click();
+ cy.get(this.domSelector.notification).should('contain', this.data.deleteConsumerSuccess);
+ });
+});
diff --git a/web/cypress/integration/route/create-route-with-cors-form.spec.js b/web/cypress/integration/route/create-route-with-cors-form.spec.js
new file mode 100644
index 0000000..915effb
--- /dev/null
+++ b/web/cypress/integration/route/create-route-with-cors-form.spec.js
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+/* eslint-disable no-undef */
+
+context('Create and delete route with cors form', () => {
+ const selector = {
+ allow_credential: "#allow_credential",
+ allow_origins_by_regex: "#allow_origins_by_regex_0"
+ }
+
+ beforeEach(() => {
+ cy.login();
+
+ cy.fixture('selector.json').as('domSelector');
+ cy.fixture('data.json').as('data');
+ });
+
+ it('should create route with cors form', function () {
+ cy.visit('/');
+ cy.contains('Route').click();
+ cy.get(this.domSelector.empty).should('be.visible');
+ cy.contains('Create').click();
+ cy.contains('Next').click().click();
+ cy.get(this.domSelector.name).type('routeName');
+ cy.get(this.domSelector.description).type('desc');
+ cy.contains('Next').click();
+
+ cy.get(this.domSelector.nodes_0_host).type('127.0.0.1');
+ cy.contains('Next').click();
+
+ // config cors plugin
+ cy.contains('cors').parents(this.domSelector.pluginCardBordered).within(() => {
+ cy.get('button').click({
+ force: true
+ });
+ });
+
+ cy.get(this.domSelector.drawer).should('be.visible').within(() => {
+ cy.get(this.domSelector.disabledSwitcher).click();
+ cy.get(this.domSelector.checkedSwitcher).should('exist');
+ });
+
+ // config cors form
+ cy.get(selector.allow_credential).click();
+ cy.get(selector.allow_origins_by_regex).type('.*.test.com');
+ cy.get(this.domSelector.drawer).within(() => {
+ cy.contains('Submit').click({
+ force: true,
+ });
+ });
+ cy.get(this.domSelector.drawer).should('not.exist');
+
+ cy.contains('button', 'Next').click();
+ cy.contains('button', 'Submit').click();
+ cy.contains(this.data.submitSuccess);
+
+ // back to route list page
+ cy.contains('Goto List').click();
+ cy.url().should('contains', 'routes/list');
+ });
+
+ it('should delete the route', function () {
+ cy.visit('/routes/list');
+ const {
+ domSelector,
+ data
+ } = this;
+
+ cy.get(domSelector.name).clear().type('routeName');
+ cy.contains('Search').click();
+ cy.contains('routeName').siblings().contains('More').click();
+ cy.contains('Delete').click();
+ cy.get(domSelector.deleteAlert).should('be.visible').within(() => {
+ cy.contains('OK').click();
+ });
+ cy.get(domSelector.notification).should('contain', data.deleteRouteSuccess);
+ cy.get(domSelector.notificationCloseIcon).click();
+ });
+});
diff --git a/web/src/components/Plugin/PluginDetail.tsx b/web/src/components/Plugin/PluginDetail.tsx
index a807b2d..385f967 100644
--- a/web/src/components/Plugin/PluginDetail.tsx
+++ b/web/src/components/Plugin/PluginDetail.tsx
@@ -116,6 +116,24 @@ const PluginDetail: React.FC<Props> = ({
modeOptions.push({ label: formatMessage({ id: 'component.plugin.form' }), value: codeMirrorModeList.UIForm });
}
+ const getUIFormData = () => {
+ if (name === 'cors') {
+ const formData = UIForm.getFieldsValue();
+ const newMethods = formData.allow_methods.join(",");
+ return { ...formData, allow_methods: newMethods };
+ }
+ return UIForm.getFieldsValue();
+ };
+
+ const setUIFormData = (formData: any) => {
+ if (name === 'cors' && formData) {
+ const methods = (formData.allow_methods || '').length ? formData.allow_methods.split(",") : ["*"];
+ UIForm.setFieldsValue({ ...formData, allow_methods: methods });
+ return;
+ }
+ UIForm.setFieldsValue(formData);
+ };
+
useEffect(() => {
form.setFieldsValue({
disable: initialData[name] && !initialData[name].disable,
@@ -123,7 +141,7 @@ const PluginDetail: React.FC<Props> = ({
});
if (PLUGIN_UI_LIST.includes(name)) {
setCodeMirrorMode(codeMirrorModeList.UIForm);
- UIForm.setFieldsValue(initialData[name]);
+ setUIFormData(initialData[name]);
};
}, []);
@@ -189,7 +207,7 @@ const PluginDetail: React.FC<Props> = ({
);
} else {
ref.current.editor.setValue(
- js_beautify(JSON.stringify(UIForm.getFieldsValue()), {
+ js_beautify(JSON.stringify(getUIFormData()), {
indent_size: 2,
}),
);
@@ -197,7 +215,7 @@ const PluginDetail: React.FC<Props> = ({
break;
}
case codeMirrorModeList.YAML: {
- const { data: jsonData, error } = json2yaml(codeMirrorMode === codeMirrorModeList.JSON ? ref.current.editor.getValue() : JSON.stringify(UIForm.getFieldsValue()));
+ const { data: jsonData, error } = json2yaml(codeMirrorMode === codeMirrorModeList.JSON ? ref.current.editor.getValue() : JSON.stringify(getUIFormData()));
if (error) {
notification.error({
@@ -211,7 +229,7 @@ const PluginDetail: React.FC<Props> = ({
case codeMirrorModeList.UIForm: {
if (codeMirrorMode === codeMirrorModeList.JSON) {
- UIForm.setFieldsValue(JSON.parse(ref.current.editor.getValue()));
+ setUIFormData(JSON.parse(ref.current.editor.getValue()));
} else {
const { data: yamlData, error } = yaml2json(ref.current.editor.getValue(), true);
if (error) {
@@ -220,7 +238,7 @@ const PluginDetail: React.FC<Props> = ({
});
return;
}
- UIForm.setFieldsValue(JSON.parse(yamlData));
+ setUIFormData(JSON.parse(yamlData));
}
break;
}
@@ -292,7 +310,7 @@ const PluginDetail: React.FC<Props> = ({
} else if (codeMirrorMode === codeMirrorModeList.YAML) {
editorData = yaml2json(ref.current?.editor.getValue(), false).data;
} else {
- editorData = UIForm.getFieldsValue();
+ editorData = getUIFormData();
}
validateData(name, editorData).then((value) => {
diff --git a/web/src/components/Plugin/UI/cors.tsx b/web/src/components/Plugin/UI/cors.tsx
new file mode 100644
index 0000000..d075fc4
--- /dev/null
+++ b/web/src/components/Plugin/UI/cors.tsx
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 React from 'react';
+import type { FormInstance } from 'antd/es/form';
+import { Button, Col, Form, Input, InputNumber, Row, Select, Switch } from 'antd';
+import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
+import { useIntl } from '@/.umi/plugin-locale/localeExports';
+
+type Props = {
+ form: FormInstance;
+ ref?: any;
+};
+
+const FORM_ITEM_LAYOUT = {
+ labelCol: {
+ span: 7,
+ },
+ wrapperCol: {
+ span: 8
+ },
+};
+
+export const FORM_ITEM_WITHOUT_LABEL = {
+ wrapperCol: {
+ sm: { span: 8, offset: 7 },
+ },
+};
+
+const Cors: React.FC<Props> = ({ form }) => {
+ const { formatMessage } = useIntl();
+
+ const HTTPMethods: React.FC = () => (
+ <Form.Item
+ label="allow_methods"
+ tooltip={formatMessage({ id: 'component.pluginForm.cors.allow_methods.tooltip' })}
+ >
+ <Row>
+ <Col span={24}>
+ <Form.Item
+ name="allow_methods"
+ initialValue={["*"]}
+ >
+ <Select
+ mode="multiple"
+ optionLabelProp="label"
+ onChange={(value) => {
+ ((value as string[]).join(","));
+ if ((value as string[]).includes('*')) {
+ form.setFieldsValue({
+ allow_methods: ['*'],
+ });
+ }
+ }}
+ >
+ {['*', 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'CONNECT', 'TRACE'].map((item) => {
+ return <Select.Option value={item} key={item}>{item}</Select.Option>
+ })}
+ </Select>
+ </Form.Item>
+ </Col>
+ </Row>
+ </Form.Item >
+ );
+
+ return (
+ <Form
+ form={form}
+ {...FORM_ITEM_LAYOUT}
+ initialValues={{ allow_origins_by_regex: [''] }}
+ >
+ <Form.Item
+ extra={formatMessage({ id: 'component.pluginForm.cors.allow_origins.extra' })}
+ name="allow_origins"
+ label="allow_origins"
+ initialValue="*"
+ tooltip={formatMessage({ id: 'component.pluginForm.cors.allow_origins.tooltip' })}
+ >
+ <Input />
+ </Form.Item>
+ <HTTPMethods />
+
+ <Form.Item
+ name="allow_headers"
+ label="allow_headers"
+ initialValue="*"
+ tooltip={formatMessage({ id: 'component.pluginForm.cors.allow_headers.tooltip' })}
+ >
+ <Input />
+ </Form.Item>
+ <Form.Item
+ name="expose_headers"
+ label="expose_headers"
+ initialValue="*"
+ tooltip={formatMessage({ id: 'component.pluginForm.cors.expose_headers.tooltip' })}
+ >
+ <Input />
+ </Form.Item>
+ <Form.Item
+ name="max_age"
+ label="max_age"
+ initialValue={5}
+ tooltip={formatMessage({ id: 'component.pluginForm.cors.max_age.tooltip' })}
+ >
+ <InputNumber />
+ </Form.Item>
+ <Form.Item
+ name="allow_credential"
+ label="allow_credential"
+ valuePropName="checked"
+ initialValue={false}
+ tooltip={formatMessage({ id: 'component.pluginForm.cors.allow_credential.tooltip' })}
+ >
+ <Switch />
+ </Form.Item>
+
+ <Form.List name={['allow_origins_by_regex']}>
+ {(fields, { add, remove }) => {
+ return (
+ <div>
+ {fields.map((field, index) => (
+ <Form.Item
+ {...(index === 0 ? FORM_ITEM_LAYOUT : FORM_ITEM_WITHOUT_LABEL)}
+ label={index === 0 && 'allow_origins_by_regex'}
+ key={field.key}
+ tooltip={formatMessage({ id: 'component.pluginForm.cors.allow_origins_by_regex.tooltip' })}
+ >
+ <Form.Item
+ {...field}
+ validateTrigger={['onChange', 'onBlur']}
+ noStyle
+ >
+ <Input style={{ width: '80%' }} />
+ </Form.Item>
+ {fields.length > 1 ? (
+ <MinusCircleOutlined
+ className="dynamic-delete-button"
+ style={{ margin: '0 8px' }}
+ onClick={() => {
+ remove(field.name);
+ }}
+ />
+ ) : null}
+ </Form.Item>
+ ))}
+ {
+ <Form.Item {...FORM_ITEM_WITHOUT_LABEL}>
+ <Button
+ type="dashed"
+ onClick={() => {
+ add();
+ }}
+ >
+ <PlusOutlined /> {formatMessage({ id: 'component.global.create' })}
+ </Button>
+ </Form.Item>
+ }
+ </div>
+ );
+ }}
+ </Form.List>
+ </Form >
+ );
+}
+
+export default Cors;
diff --git a/web/src/components/Plugin/UI/plugin.tsx b/web/src/components/Plugin/UI/plugin.tsx
index e4e4cda..5c82aa9 100644
--- a/web/src/components/Plugin/UI/plugin.tsx
+++ b/web/src/components/Plugin/UI/plugin.tsx
@@ -24,6 +24,7 @@ import LimitReq from './limit-req';
import ApiBreaker from './api-breaker';
import ProxyMirror from './proxy-mirror';
import LimitConn from './limit-conn';
+import Cors from './cors';
type Props = {
name: string,
@@ -31,7 +32,7 @@ type Props = {
renderForm: boolean
}
-export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-req', 'limit-conn', 'proxy-mirror'];
+export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-conn', 'cors', 'proxy-mirror', 'limit-req'];
export const PluginForm: React.FC<Props> = ({ name, renderForm, form }) => {
@@ -44,6 +45,8 @@ export const PluginForm: React.FC<Props> = ({ name, renderForm, form }) => {
return <ApiBreaker form={form} />
case 'basic-auth':
return <BasicAuth form={form} />
+ case 'cors':
+ return <Cors form={form} />
case 'limit-req':
return <LimitReq form={form} />
case 'proxy-mirror':
diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts
index f65bfa0..3e896cc 100644
--- a/web/src/components/Plugin/locales/en-US.ts
+++ b/web/src/components/Plugin/locales/en-US.ts
@@ -21,6 +21,15 @@ export default {
'component.step.select.pluginTemplate.select.option': 'Custom',
'component.plugin.pluginTemplate.tip1': '1. When a route already have plugins field configured, the plugins in the plugin template will be merged into it.',
'component.plugin.pluginTemplate.tip2': '2. The same plugin in the plugin template will override one in the plugins',
+ // cors
+ 'component.pluginForm.cors.allow_origins.tooltip': 'Which Origins is allowed to enable CORS, format as:scheme://host:port, for example: https://somehost.com:8081. Multiple origin use , to split. When allow_credential is false, you can use * to indicate allow any origin. you also can allow all any origins forcefully using ** even already enable allow_credential, but it will bring some security risks.',
+ 'component.pluginForm.cors.allow_origins.extra': 'For example: https://somehost.com:8081',
+ 'component.pluginForm.cors.allow_methods.tooltip': 'Which Method is allowed to enable CORS, such as: GET, POST etc. Multiple method use , to split. When allow_credential is false, you can use * to indicate allow all any method. You also can allow any method forcefully using ** even already enable allow_credential, but it will bring some security risks.',
+ 'component.pluginForm.cors.allow_headers.tooltip': 'Which headers are allowed to set in request when access cross-origin resource. Multiple value use , to split. When allow_credential is false, you can use * to indicate allow all request headers. You also can allow any header forcefully using ** even already enable allow_credential, but it will bring some security risks.',
+ 'component.pluginForm.cors.expose_headers.tooltip': ' Which headers are allowed to set in response when access cross-origin resource. Multiple value use , to split.',
+ 'component.pluginForm.cors.max_age.tooltip': 'Maximum number of seconds the results can be cached.. Within this time range, the browser will reuse the last check result. -1 means no cache. Please note that the maximum value is depended on browser, please refer to MDN for details.',
+ 'component.pluginForm.cors.allow_credential.tooltip': 'If you set this option to true, you can not use \'*\' for other options.',
+ 'component.pluginForm.cors.allow_origins_by_regex.tooltip': 'Use regex expressions to match which origin is allowed to enable CORS, for example, [".*.test.com"] can use to match all subdomain of test.com.',
// api-breaker
'component.pluginForm.api-breaker.break_response_code.tooltip': 'Return error code when unhealthy.',
diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts
index a0decff..1dca43a 100644
--- a/web/src/components/Plugin/locales/zh-CN.ts
+++ b/web/src/components/Plugin/locales/zh-CN.ts
@@ -21,6 +21,15 @@ export default {
'component.step.select.pluginTemplate.select.option': '手动配置',
'component.plugin.pluginTemplate.tip1': '1. 若路由已配置插件,则插件模板数据将与已配置的插件数据合并。',
'component.plugin.pluginTemplate.tip2': '2. 插件模板相同的插件会覆盖掉原有的插件。',
+ // cors
+ 'component.pluginForm.cors.allow_origins.tooltip': '允许跨域访问的 Origin,格式如:scheme://host:port,比如: https://somehost.com:8081 。多个值使用 , 分割,allow_credential 为 false 时可以使用 * 来表示所有 Origin 均允许通过。你也可以在启用了 allow_credential 后使用 ** 强制允许所有 Origin 都通过,但请注意这样存在安全隐患。',
+ 'component.pluginForm.cors.allow_origins.extra': '例如: https://somehost.com:8081',
+ 'component.pluginForm.cors.allow_methods.tooltip': '允许跨域访问的 Method,比如: GET,POST等。多个值使用 , 分割,allow_credential 为 false 时可以使用 * 来表示所有 Origin 均允许通过。你也可以在启用了 allow_credential 后使用 ** 强制允许所有 Method 都通过,但请注意这样存在安全隐患。',
+ 'component.pluginForm.cors.allow_headers.tooltip': '允许跨域访问时请求方携带哪些非 CORS规范 以外的 Header, 多个值使用 , 分割,allow_credential 为 false 时可以使用 * 来表示所 有 Header 均允许通过。你也可以在启用了 allow_credential 后使用 ** 强制允许所有 Method 都通过,但请注意这样存在安全隐患。',
+ 'component.pluginForm.cors.expose_headers.tooltip': '允许跨域访问时响应方携带哪些非 CORS规范 以外的 Header, 多个值使用 , 分割。',
+ 'component.pluginForm.cors.max_age.tooltip': '浏览器缓存 CORS 结果的最大时间,单位为秒,在这个时间范围内浏览器会复用上一次的检查结果,-1 表示不缓存。',
+ 'component.pluginForm.cors.allow_credential.tooltip': '是否允许跨域访问的请求方携带凭据(如 Cookie 等)。根据 CORS 规范,如果设置该选项为 true,那么将不能在其他选项中使用 * 。',
+ 'component.pluginForm.cors.allow_origins_by_regex.tooltip': '使用正则表达式数组来匹配允许跨域访问的 Origin, 如[".*.test.com"] 可以匹配任何test.com的子域名 * 。',
// api-breaker
'component.pluginForm.api-breaker.break_response_code.tooltip': '不健康返回错误码。',