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 部分)',