diff --git a/web/cypress/integration/consumer/create-consumer-with-limit-count-plugin-form.spec.js b/web/cypress/integration/consumer/create-consumer-with-limit-count-plugin-form.spec.js new file mode 100644 index 0000000000..83b4ca3d13 --- /dev/null +++ b/web/cypress/integration/consumer/create-consumer-with-limit-count-plugin-form.spec.js @@ -0,0 +1,150 @@ +/* + * 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 limit-count plugin form', () => { + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + const selector = { + count: '#count', + time_window: '#time_window', + redis_timeout: '#time_window', + key: '#key', + rejected_code: '#rejected_code', + policy: '#policy', + redis_host: '#redis_host', + redis_port: '#redis_port', + redis_password: '#redis_password', + redis_database: '#redis_database', + redis_timeout: '#redis_timeout', + redis_cluster_name: '#redis_cluster_name', + redis_cluster_nodes_0: '#redis_cluster_nodes_0', + redis_cluster_nodes_1: '#redis_cluster_nodes_1', + } + + it('should create consumer with limit-count 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, 'limit-count').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + + cy.focused(this.domSelector.drawer).should('exist'); + + // config limit-count form with local policy + cy.get(selector.count).type(1); + cy.get(selector.time_window).type(1); + cy.get(selector.rejected_code).type(500); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + // config limit-count form with redis policy + cy.contains(this.domSelector.pluginCard, 'limit-count').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.contains('local').click(); + cy.get(this.domSelector.dropdown).within(() => { + cy.contains('redis').click({ + force: true, + }); + }); + cy.get(selector.redis_host).type('127.0.0.1'); + cy.get(selector.redis_password).type('redis_password'); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + + // config limit-count form with redis policy + cy.contains(this.domSelector.pluginCard, 'limit-count').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.contains('redis').click(); + cy.get(this.domSelector.dropdown).within(() => { + cy.contains('redis-cluster').click({ + force: true, + }); + }); + cy.get(selector.redis_cluster_name).type('redis_cluster_name'); + cy.get(selector.redis_cluster_nodes_0).type('127.0.0.1:5000'); + cy.get(selector.redis_cluster_nodes_1).type('127.0.0.1:5001'); + 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('should 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-limit-count-plugin-form.spec.js b/web/cypress/integration/route/create-route-with-limit-count-plugin-form.spec.js new file mode 100644 index 0000000000..7992f2c085 --- /dev/null +++ b/web/cypress/integration/route/create-route-with-limit-count-plugin-form.spec.js @@ -0,0 +1,145 @@ +/* + * 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 limit-count form', () => { + const selector = { + count: '#count', + time_window: '#time_window', + redis_timeout: '#time_window', + key: '#key', + rejected_code: '#rejected_code', + policy: '#policy', + redis_host: '#redis_host', + redis_port: '#redis_port', + redis_password: '#redis_password', + redis_database: '#redis_database', + redis_timeout: '#redis_timeout', + redis_cluster_name: '#redis_cluster_name', + redis_cluster_nodes_0: '#redis_cluster_nodes_0', + redis_cluster_nodes_1: '#redis_cluster_nodes_1', + } + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + it('should create route with limit-count 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 limit-count form with local policy + cy.contains(this.domSelector.pluginCard, 'limit-count').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(selector.count).type(1); + cy.get(selector.time_window).type(1); + cy.get(selector.rejected_code).type(500); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + // config limit-count form with redis policy + cy.contains(this.domSelector.pluginCard, 'limit-count').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.contains('local').click(); + cy.get(this.domSelector.dropdown).within(() => { + cy.contains('redis').click({ + force: true, + }); + }); + cy.get(selector.redis_host).type('127.0.0.1'); + cy.get(selector.redis_password).type('redis_password'); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + // config limit-count form with redis policy + cy.contains(this.domSelector.pluginCard, 'limit-count').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.contains('redis').click(); + cy.get(this.domSelector.dropdown).within(() => { + cy.contains('redis-cluster').click({ + force: true, + }); + }); + cy.get(selector.redis_cluster_name).type('redis_cluster_name'); + cy.get(selector.redis_cluster_nodes_0).type('127.0.0.1:5000'); + cy.get(selector.redis_cluster_nodes_1).type('127.0.0.1:5001'); + 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/limit-count.tsx b/web/src/components/Plugin/UI/limit-count.tsx new file mode 100644 index 0000000000..bddeb94146 --- /dev/null +++ b/web/src/components/Plugin/UI/limit-count.tsx @@ -0,0 +1,229 @@ +/* + * 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, { useState } from 'react'; +import type { FormInstance } from 'antd/es/form'; +import { Button, Col, Form, Input, InputNumber, Row, Select } from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { useIntl } from 'umi'; + +type Props = { + form: FormInstance; +}; + +type PolicyProps = "local" | "redis" | "redis-cluster" + +const FORM_ITEM_LAYOUT = { + labelCol: { + span: 7, + }, + wrapperCol: { + span: 10 + }, +}; + +const FORM_ITEM_WITHOUT_LABEL = { + wrapperCol: { + span: 10, offset: 7, + }, +}; + +const removeBtnStyle = { + marginLeft: 20, + display: 'flex', + alignItems: 'center', +}; + +const RedisForm: React.FC = () => { + const { formatMessage } = useIntl(); + + return (<> + + + + + + + + + + + + + + + + ) +} + +const RedisClusterForm: React.FC = () => { + const { formatMessage } = useIntl(); + + return ( + <> + + + + + {(fields, { add, remove }) => { + return ( +
+ + {fields.map((field, index) => ( + + + + + + + + {fields.length > 1 ? ( + { + remove(field.name); + }} + /> + ) : null} + + + ))} + + + + + +
+ ); + }} +
+ ) +} + +const LimitCount: React.FC = ({ form }) => { + const [policy, setPoicy] = useState('local'); + const { formatMessage } = useIntl() + + return ( +
+ + + + + + + + + + + + + + + + prev.policy !== next.policy} style={{ display: 'none' }}> + {() => { + setPoicy(form.getFieldValue('policy')); + }} + + {Boolean(policy === 'redis') && } + {Boolean(policy === 'redis-cluster') && } + + ); +} + +export default LimitCount; diff --git a/web/src/components/Plugin/UI/plugin.tsx b/web/src/components/Plugin/UI/plugin.tsx index 4c6a95cd03..0d1a2b67b2 100644 --- a/web/src/components/Plugin/UI/plugin.tsx +++ b/web/src/components/Plugin/UI/plugin.tsx @@ -19,7 +19,8 @@ import type { FormInstance } from 'antd/es/form'; import { Empty } from 'antd'; import { useIntl } from 'umi'; -import BasicAuth from './basic-auth' +import BasicAuth from './basic-auth'; +import LimitCount from './limit-count'; import LimitReq from './limit-req'; import ApiBreaker from './api-breaker'; import ProxyMirror from './proxy-mirror'; @@ -33,7 +34,7 @@ type Props = { renderForm: boolean } -export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'cors', 'limit-req', 'limit-conn', 'proxy-mirror', 'referer-restriction']; +export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'cors', 'limit-req', 'limit-conn', 'proxy-mirror', 'referer-restriction', 'limit-count']; export const PluginForm: React.FC = ({ name, renderForm, form }) => { @@ -46,6 +47,8 @@ export const PluginForm: React.FC = ({ name, renderForm, form }) => { return case 'basic-auth': return + case 'limit-count': + return case 'cors': return case 'limit-req': diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts index 76e7e373cc..10363bd85d 100644 --- a/web/src/components/Plugin/locales/en-US.ts +++ b/web/src/components/Plugin/locales/en-US.ts @@ -65,4 +65,19 @@ export default { 'component.plugin.format-codes.disable': 'Format JSON or YAML data', 'component.plugin.editor': 'Plugin Editor', 'component.plugin.noConfigurationRequired': 'Doesn\'t need configuration', + + // limit-count + 'component.pluginForm.limit-count.count.tooltip': 'The specified number of requests threshold.', + 'component.pluginForm.limit-count.time_window.tooltip': 'The time window in seconds before the request count is reset.', + 'component.pluginForm.limit-count.key.tooltip': 'The user specified key to limit the count.Now accept those as key: "remote_addr"(client\'s IP), "server_addr"(server\'s IP), "X-Forwarded-For/X-Real-IP" in request header, "consumer_name"(consumer\'s username) and "service_id".', + 'component.pluginForm.limit-count.rejected_code.tooltip': 'The HTTP status code returned when the request exceeds the threshold is rejected, default 503.', + 'component.pluginForm.limit-count.policy.tooltip': 'The rate-limiting policies to use for retrieving and incrementing the limits. Available values are local(the counters will be stored locally in-memory on the node) and redis(counters are stored on a Redis server and will be shared across the nodes, usually use it to do the global speed limit).', + 'component.pluginForm.limit-count.redis_host.tooltip': 'When using the redis policy, this property specifies the address of the Redis server.', + 'component.pluginForm.limit-count.redis_port.tooltip': 'When using the redis policy, this property specifies the port of the Redis server.', + 'component.pluginForm.limit-count.redis_password.tooltip': 'When using the redis policy, this property specifies the password of the Redis server.', + 'component.pluginForm.limit-count.redis_database.tooltip': 'When using the redis policy, this property specifies the database you selected of the Redis server, and only for non Redis cluster mode (single instance mode or Redis public cloud service that provides single entry).', + 'component.pluginForm.limit-count.redis_timeout.tooltip': 'When using the redis policy, this property specifies the timeout in milliseconds of any command submitted to the Redis server.', + 'component.pluginForm.limit-count.redis_cluster_nodes.tooltip': 'When using redis-cluster policy,This property is a list of addresses of Redis cluster service nodes (at least two nodes).', + 'component.pluginForm.limit-count.redis_cluster_name.tooltip': 'When using redis-cluster policy, this property is the name of Redis cluster service nodes.', + 'component.pluginForm.limit-count.atLeast2Characters.rule': 'Please enter at least 2 characters', }; diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts index ec586771d5..6d25eb4ea8 100644 --- a/web/src/components/Plugin/locales/zh-CN.ts +++ b/web/src/components/Plugin/locales/zh-CN.ts @@ -64,4 +64,19 @@ export default { 'component.plugin.format-codes.disable': '用于格式化 JSON 或 YAML 内容', 'component.plugin.editor': '插件配置', 'component.plugin.noConfigurationRequired': '本插件无需配置', + + // limit-count + 'component.pluginForm.limit-count.count.tooltip': '指定时间窗口内的请求数量阈值。', + 'component.pluginForm.limit-count.time_window.tooltip': '时间窗口的大小(以秒为单位),超过这个时间就会重置。', + 'component.pluginForm.limit-count.key.tooltip': '用来做请求计数的有效值。例如,可以使用主机名(或服务器区域)作为关键字,以便限制每个主机名规定时间内的请求次数。我们也可以使用客户端地址作为关键字,这样我们就可以避免单个客户端规定时间内多次的连接我们的服务。当前接受的 key 有:"remote_addr"(客户端 IP 地址), "server_addr"(服务端 IP 地址), 请求头中的"X-Forwarded-For" 或 "X-Real-IP", "consumer_name"(consumer 的 username), "service_id" 。', + 'component.pluginForm.limit-count.rejected_code.tooltip': '当请求超过阈值被拒绝时,返回的 HTTP 状态码。', + 'component.pluginForm.limit-count.policy.tooltip': '用于检索和增加限制的速率限制策略。可选的值有:local(计数器被以内存方式保存在节点本地,默认选项) 和 redis(计数器保存在 Redis 服务节点上,从而可以跨节点共享结果,通常用它来完成全局限速);以及redis-cluster,跟 redis 功能一样,只是使用 redis 集群方式。', + 'component.pluginForm.limit-count.redis_host.tooltip': '当使用 redis 限速策略时,该属性是 Redis 服务节点的地址。', + 'component.pluginForm.limit-count.redis_port.tooltip': '当使用 redis 限速策略时,该属性是 Redis 服务节点的端口。', + 'component.pluginForm.limit-count.redis_password.tooltip': '当使用 redis 限速策略时,该属性是 Redis 服务节点的密码。', + 'component.pluginForm.limit-count.redis_database.tooltip': '当使用 redis 限速策略时,该属性是 Redis 服务节点中使用的 database,并且只针对非 Redis 集群模式(单实例模式或者提供单入口的 Redis 公有云服务)生效。', + 'component.pluginForm.limit-count.redis_timeout.tooltip': '当使用 redis 限速策略时,该属性是 Redis 服务节点以毫秒为单位的超时时间。', + 'component.pluginForm.limit-count.redis_cluster_nodes.tooltip': '当使用 redis-cluster 限速策略时,该属性是 Redis 集群服务节点的地址列表(至少需要两个地址)。', + 'component.pluginForm.limit-count.redis_cluster_name.tooltip': '当使用 redis-cluster 限速策略时,该属性是 Redis 集群服务节点的名称。', + 'component.pluginForm.limit-count.atLeast2Characters.rule': '请至少输入 2 个字符', };