You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by ju...@apache.org on 2021/04/14 21:01:28 UTC
[apisix-dashboard] branch master updated: feat: add api-breaker
plugin form (#1730)
This is an automated email from the ASF dual-hosted git repository.
juzhiyuan 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 b6a175f feat: add api-breaker plugin form (#1730)
b6a175f is described below
commit b6a175f42c5a349d0e9aab6e016fb74a14c1810b
Author: litesun <su...@apache.org>
AuthorDate: Thu Apr 15 05:01:22 2021 +0800
feat: add api-breaker plugin form (#1730)
Co-authored-by: 琚致远 <ju...@apache.org>
---
...e-consumer-with-api-breaker-plugin-form.spec.js | 101 +++++++++++
.../create-route-with-api-breaker-form.spec.js | 105 ++++++++++++
web/src/components/Plugin/UI/api-breaker.tsx | 186 +++++++++++++++++++++
web/src/components/Plugin/UI/plugin.tsx | 5 +-
web/src/components/Plugin/locales/en-US.ts | 8 +
web/src/components/Plugin/locales/zh-CN.ts | 8 +
6 files changed, 412 insertions(+), 1 deletion(-)
diff --git a/web/cypress/integration/consumer/create-consumer-with-api-breaker-plugin-form.spec.js b/web/cypress/integration/consumer/create-consumer-with-api-breaker-plugin-form.spec.js
new file mode 100644
index 0000000..c652ad9
--- /dev/null
+++ b/web/cypress/integration/consumer/create-consumer-with-api-breaker-plugin-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 with api-breaker plugin form', () => {
+ beforeEach(() => {
+ cy.login();
+
+ cy.fixture('selector.json').as('domSelector');
+ cy.fixture('data.json').as('data');
+ });
+
+ const selector = {
+ break_response_code: "#break_response_code"
+ }
+
+ const data = {
+ break_response_code: 200,
+ }
+
+ it('creates consumer with api-breaker 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();
+ // 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, 'api-breaker').within(() => {
+ cy.contains('Enable').click({
+ force: true,
+ });
+ });
+
+ cy.focused(this.domSelector.drawer).should('exist');
+
+ // config api-breaker 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(selector.break_response_code).type(data.break_response_code);
+ cy.get(this.domSelector.disabledSwitcher).click();
+ 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);
+ });
+
+ 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-api-breaker-form.spec.js b/web/cypress/integration/route/create-route-with-api-breaker-form.spec.js
new file mode 100644
index 0000000..8437d9d
--- /dev/null
+++ b/web/cypress/integration/route/create-route-with-api-breaker-form.spec.js
@@ -0,0 +1,105 @@
+/*
+ * 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 api-breaker form', () => {
+ const selector = {
+ break_response_code: '#break_response_code',
+ alert: '.ant-form-item-explain-error [role=alert]'
+ }
+
+ beforeEach(() => {
+ cy.login();
+
+ cy.fixture('selector.json').as('domSelector');
+ cy.fixture('data.json').as('data');
+ });
+
+ it('should create route with api-breaker 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 api-breaker plugin
+ cy.contains('api-breaker').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 api-breaker form without break_response_code
+ cy.get(selector.break_response_code).click();
+ cy.get(selector.alert).contains('Please Enter break_response_code');
+ 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();
+
+ // config api-breaker form with break_response_code
+ cy.get(selector.break_response_code).type('200');
+ cy.get(selector.alert).should('not.exist');
+ cy.get(this.domSelector.disabledSwitcher).click();
+ 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/UI/api-breaker.tsx b/web/src/components/Plugin/UI/api-breaker.tsx
new file mode 100644
index 0000000..2ccf2dd
--- /dev/null
+++ b/web/src/components/Plugin/UI/api-breaker.tsx
@@ -0,0 +1,186 @@
+/*
+ * 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, Form, InputNumber } from 'antd';
+import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
+import { useIntl } from 'umi';
+
+type Props = {
+ form: FormInstance;
+};
+
+const FORM_ITEM_LAYOUT = {
+ labelCol: {
+ span: 7,
+ },
+ wrapperCol: {
+ span: 7
+ },
+};
+
+const FORM_ITEM_WITHOUT_LABEL = {
+ wrapperCol: {
+ sm: { span: 14, offset: 7 },
+ },
+};
+
+const ApiBreaker: React.FC<Props> = ({ form }) => {
+ const { formatMessage } = useIntl()
+
+ return (
+ <Form
+ form={form}
+ {...FORM_ITEM_LAYOUT}
+ initialValues={{ unhealthy: { http_statuses: [500] }, healthy: { http_statuses: [200] } }}
+ >
+ <Form.Item
+ label="break_response_code"
+ name="break_response_code"
+ rules={[{
+ required: true,
+ message: `${formatMessage({ id: 'component.global.pleaseEnter' })} break_response_code`
+ }]}
+ tooltip={formatMessage({ id: 'component.pluginForm.api-breaker.break_response_code.tooltip' })}
+ validateTrigger={['onChange', 'onBlur', 'onClick']}
+ >
+ <InputNumber min={200} max={599} required />
+ </Form.Item>
+
+ <Form.Item
+ label="max_breaker_sec"
+ name="max_breaker_sec"
+ initialValue={300}
+ tooltip={formatMessage({ id: 'component.pluginForm.api-breaker.max_breaker_sec.tooltip' })}
+ >
+ <InputNumber min={60} />
+ </Form.Item>
+
+ <Form.List name={['unhealthy', 'http_statuses']}>
+ {(fields, { add, remove }) => {
+ return (
+ <div>
+ {fields.map((field, index) => (
+ <Form.Item
+ {...(index === 0 ? FORM_ITEM_LAYOUT : FORM_ITEM_WITHOUT_LABEL)}
+ label={index === 0 && 'unhealthy.http_statuses'}
+ tooltip={formatMessage({ id: 'component.pluginForm.api-breaker.unhealthy.http_statuses.tooltip' })}
+ key={field.key}
+ >
+ <Form.Item
+ {...field}
+ validateTrigger={['onChange', 'onBlur']}
+ noStyle
+ >
+ <InputNumber min={500} max={599} />
+ </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.Item
+ label="unhealthy.failures"
+ name={['unhealthy', 'failures']}
+ initialValue={3}
+ tooltip={formatMessage({ id: 'component.pluginForm.api-breaker.unhealthy.failures.tooltip' })}
+ >
+ <InputNumber min={1} />
+ </Form.Item>
+
+ <Form.List name={['healthy', 'http_statuses']}>
+ {(fields, { add, remove }) => {
+ return (
+ <div>
+ {fields.map((field, index) => (
+ <Form.Item
+ {...(index === 0 ? FORM_ITEM_LAYOUT : FORM_ITEM_WITHOUT_LABEL)}
+ key={field.key}
+ label={index === 0 && 'healthy.http_statuses'}
+ tooltip={formatMessage({ id: 'component.pluginForm.api-breaker.healthy.http_statuses.tooltip' })}
+ >
+ <Form.Item
+ {...field}
+ validateTrigger={['onChange', 'onBlur']}
+ noStyle
+ >
+ <InputNumber min={200} max={499} />
+ </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.Item
+ label="healthy.successes"
+ name={['healthy', 'successes']}
+ initialValue={3}
+ tooltip={formatMessage({ id: 'component.pluginForm.api-breaker.healthy.successes.tooltip' })}
+ >
+ <InputNumber min={1} />
+ </Form.Item>
+ </Form >
+ );
+}
+
+export default ApiBreaker;
diff --git a/web/src/components/Plugin/UI/plugin.tsx b/web/src/components/Plugin/UI/plugin.tsx
index c045750..86b3864 100644
--- a/web/src/components/Plugin/UI/plugin.tsx
+++ b/web/src/components/Plugin/UI/plugin.tsx
@@ -19,6 +19,7 @@ import type { FormInstance } from 'antd/es/form';
import { Empty } from 'antd';
import { useIntl } from 'umi';
+import ApiBreaker from './api-breaker';
import BasicAuth from './basic-auth';
import ProxyMirror from './proxy-mirror';
import LimitConn from './limit-conn';
@@ -29,7 +30,7 @@ type Props = {
renderForm: boolean
}
-export const PLUGIN_UI_LIST = ['basic-auth', 'limit-conn', 'proxy-mirror'];
+export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-conn', 'proxy-mirror'];
export const PluginForm: React.FC<Props> = ({ name, renderForm, form }) => {
@@ -38,6 +39,8 @@ export const PluginForm: React.FC<Props> = ({ name, renderForm, form }) => {
if (!renderForm) { return <Empty style={{ marginTop: 100 }} description={formatMessage({ id: 'component.plugin.noConfigurationRequired' })} /> };
switch (name) {
+ case 'api-breaker':
+ return <ApiBreaker form={form} />
case 'basic-auth':
return <BasicAuth 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 f235b95..0188dcc 100644
--- a/web/src/components/Plugin/locales/en-US.ts
+++ b/web/src/components/Plugin/locales/en-US.ts
@@ -22,6 +22,14 @@ export default {
'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',
+ // api-breaker
+ 'component.pluginForm.api-breaker.break_response_code.tooltip': 'Return error code when unhealthy.',
+ 'component.pluginForm.api-breaker.max_breaker_sec.tooltip': 'Maximum breaker time(seconds).',
+ 'component.pluginForm.api-breaker.unhealthy.http_statuses.tooltip': 'Status codes when unhealthy.',
+ 'component.pluginForm.api-breaker.unhealthy.failures.tooltip': 'Number of consecutive error requests that triggered an unhealthy state.',
+ 'component.pluginForm.api-breaker.healthy.http_statuses.tooltip': 'Status codes when healthy.',
+ 'component.pluginForm.api-breaker.healthy.successes.tooltip': 'Number of consecutive normal requests that trigger health status.',
+
// proxy-mirror
'component.pluginForm.proxy-mirror.host.tooltip': 'Specify a mirror service address, e.g. http://127.0.0.1:9797 (address needs to contain schema: http or https, not URI part)',
'component.pluginForm.proxy-mirror.host.extra': 'e.g. http://127.0.0.1:9797 (address needs to contain schema: http or https, not URI part)',
diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts
index 2f2d5ba..9a00009 100644
--- a/web/src/components/Plugin/locales/zh-CN.ts
+++ b/web/src/components/Plugin/locales/zh-CN.ts
@@ -22,6 +22,14 @@ export default {
'component.plugin.pluginTemplate.tip1': '1. 若路由已配置插件,则插件模板数据将与已配置的插件数据合并。',
'component.plugin.pluginTemplate.tip2': '2. 插件模板相同的插件会覆盖掉原有的插件。',
+ // api-breaker
+ 'component.pluginForm.api-breaker.break_response_code.tooltip': '不健康返回错误码。',
+ 'component.pluginForm.api-breaker.max_breaker_sec.tooltip': '最大熔断持续时间。',
+ 'component.pluginForm.api-breaker.unhealthy.http_statuses.tooltip': '不健康时候的状态码。',
+ 'component.pluginForm.api-breaker.unhealthy.failures.tooltip': '触发不健康状态的连续错误请求次数。',
+ 'component.pluginForm.api-breaker.healthy.http_statuses.tooltip': '健康时候的状态码。',
+ 'component.pluginForm.api-breaker.healthy.successes.tooltip': '触发健康状态的连续正常请求次数。',
+
// proxy-mirror
'component.pluginForm.proxy-mirror.host.tooltip': '指定镜像服务地址,例如:http://127.0.0.1:9797(地址中需要包含 schema :http或https,不能包含 URI 部分)',
'component.pluginForm.proxy-mirror.host.extra': '例如:http://127.0.0.1:9797(地址中需要包含 schema:http或https,不能包含 URI 部分)',