diff --git a/web/config/routes.ts b/web/config/routes.ts index cfb01bc313..dac171691f 100644 --- a/web/config/routes.ts +++ b/web/config/routes.ts @@ -75,6 +75,18 @@ const routes = [ path: '/consumer/:username/edit', component: './Consumer/Create', }, + { + path: '/service/list', + component: './Service/List', + }, + { + path: '/service/create', + component: './Service/Create', + }, + { + path: '/service/:serviceId/edit', + component: './Service/Create', + }, { path: '/settings', component: './Setting', diff --git a/web/cypress/integration/route/create-and-delete-route.spec.js b/web/cypress/integration/route/create-and-delete-route.spec.js index ddd60beed0..7b57428222 100644 --- a/web/cypress/integration/route/create-and-delete-route.spec.js +++ b/web/cypress/integration/route/create-and-delete-route.spec.js @@ -58,7 +58,7 @@ context('Create and Delete Route', () => { cy.contains('Next').click(); cy.wait(400); cy.get('#nodes_0_host').type('12.12.12.12', { - timeout: 4000 + timeout: 5000 }); // go to step3 diff --git a/web/src/components/Plugin/typing.d.ts b/web/src/components/Plugin/typing.d.ts index 3a899aa02b..1d8b7c809e 100644 --- a/web/src/components/Plugin/typing.d.ts +++ b/web/src/components/Plugin/typing.d.ts @@ -17,7 +17,7 @@ declare namespace PluginComponent { type Data = object; - type Schema = '' | 'route' | 'consumer'; + type Schema = '' | 'route' | 'consumer' | 'service'; type Category = | 'Security' diff --git a/web/src/components/Upstream/UpstreamForm.tsx b/web/src/components/Upstream/UpstreamForm.tsx index a6838e4a08..7afc30e1a5 100644 --- a/web/src/components/Upstream/UpstreamForm.tsx +++ b/web/src/components/Upstream/UpstreamForm.tsx @@ -97,7 +97,10 @@ const UpstreamForm: React.FC = forwardRef( useEffect(() => { const id = form.getFieldValue('upstream_id'); if (id) { - form.setFieldsValue(list.find((item) => item.id === id)); + setReadonly(true); + requestAnimationFrame(() => { + form.setFieldsValue(list.find((item) => item.id === id)); + }) } }, [list]); @@ -562,18 +565,21 @@ const UpstreamForm: React.FC = forwardRef( }} > {showSelector && ( - + { + if (prev.upstream_id !== next.upstream_id) { + const id = next.upstream_id; + setReadonly(Boolean(id)); + if (id) { + form.setFieldsValue(list.find((item) => item.id === id)); + form.setFieldsValue({ + upstream_id: id, + }); + } + } + return prev.upstream_id !== next.upstream_id; + }}> + {/* TODO: value === '' means no service_id select, need to find a better way */} + None + {serviceList.map(item => { + return + {item.name} + + })} + + ); }; diff --git a/web/src/pages/Route/locales/en-US.ts b/web/src/pages/Route/locales/en-US.ts index ebef2c253e..5dbd812003 100644 --- a/web/src/pages/Route/locales/en-US.ts +++ b/web/src/pages/Route/locales/en-US.ts @@ -31,6 +31,7 @@ export default { 'page.route.regexMatch': 'Regex Match', 'page.route.rule': 'Rule', 'page.route.httpHeaderName': 'HTTP Request Header Name', + 'page.route.service': 'Service', 'page.route.input.placeholder.parameterNameHttpHeader': 'Request header name, for example: HOST', 'page.route.input.placeholder.parameterNameRequestParameter': 'Parameter name, for example: id', diff --git a/web/src/pages/Route/locales/zh-CN.ts b/web/src/pages/Route/locales/zh-CN.ts index b2d7bc385d..040eaa2190 100644 --- a/web/src/pages/Route/locales/zh-CN.ts +++ b/web/src/pages/Route/locales/zh-CN.ts @@ -40,6 +40,7 @@ export default { 'page.route.published': '已发布', 'page.route.unpublished': '未发布', 'page.route.onlineDebug': '在线调试', + 'page.route.service': '服务', // button 'page.route.button.returnList': '返回路由列表', diff --git a/web/src/pages/Route/service.ts b/web/src/pages/Route/service.ts index 68eb6d3790..12e2df1c7e 100644 --- a/web/src/pages/Route/service.ts +++ b/web/src/pages/Route/service.ts @@ -90,7 +90,7 @@ export const checkHostWithSSL = (hosts: string[]) => export const updateRouteStatus = (rid: string, status: RouteModule.RouteStatus) => request(`/routes/${rid}`, { method: 'PATCH', - data: {status} + data: { status } }); export const debugRoute = (data: RouteModule.debugRequest) => { @@ -99,3 +99,9 @@ export const debugRoute = (data: RouteModule.debugRequest) => { data, }); }; + +export const fetchServiceList = () => + request('/services').then(({ data }) => ({ + data: data.rows, + total: data.total_size, + })); diff --git a/web/src/pages/Route/transform.ts b/web/src/pages/Route/transform.ts index 1a325fffa1..3c3de56572 100644 --- a/web/src/pages/Route/transform.ts +++ b/web/src/pages/Route/transform.ts @@ -35,6 +35,8 @@ export const transformStepData = ({ }; } + const { service_id = '' } = form1Data; + const data: Partial = { ...form1Data, ...step3DataCloned, @@ -83,6 +85,7 @@ export const transformStepData = ({ 'redirectURI', 'ret_code', 'redirectOption', + service_id.length === 0 ? 'service_id' : '', !Object.keys(step3DataCloned.plugins || {}).length ? 'plugins' : '', !Object.keys(step3DataCloned.script || {}).length ? 'script' : '', form1Data.hosts.filter(Boolean).length === 0 ? 'hosts' : '', @@ -103,6 +106,7 @@ export const transformStepData = ({ 'redirect', 'vars', 'plugins', + service_id.length !== 0 ? 'service_id' : '', form1Data.hosts.filter(Boolean).length !== 0 ? 'hosts' : '', data.remote_addrs?.filter(Boolean).length !== 0 ? 'remote_addrs' : '', ]); @@ -150,6 +154,7 @@ export const transformRouteData = (data: RouteModule.Body) => { status, upstream, upstream_id, + service_id = '', priority = 0, enable_websocket } = data; @@ -163,7 +168,8 @@ export const transformRouteData = (data: RouteModule.Body) => { // @ts-ignore methods: methods.length ? methods : ["ALL"], priority, - enable_websocket + enable_websocket, + service_id }; const redirect = data.plugins?.redirect || {}; diff --git a/web/src/pages/Route/typing.d.ts b/web/src/pages/Route/typing.d.ts index 7710337474..0363b32e0a 100644 --- a/web/src/pages/Route/typing.d.ts +++ b/web/src/pages/Route/typing.d.ts @@ -106,6 +106,7 @@ declare namespace RouteModule { script: Record; url?: string; enable_websocket?: boolean; + service_id?: string; }; // step1 @@ -142,6 +143,7 @@ declare namespace RouteModule { ret_code?: number; status: number; enable_websocket?: boolean; + service_id: string; }; type AdvancedMatchingRules = { diff --git a/web/src/pages/Service/Create.tsx b/web/src/pages/Service/Create.tsx new file mode 100644 index 0000000000..3c8bf3a6fb --- /dev/null +++ b/web/src/pages/Service/Create.tsx @@ -0,0 +1,143 @@ +/* + * 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, useRef, useEffect } from 'react' +import { useIntl, history } from 'umi'; +import { Card, Steps, Form, notification } from 'antd'; +import { PageHeaderWrapper } from '@ant-design/pro-layout'; +import { omit } from 'lodash'; + +import ActionBar from '@/components/ActionBar'; +import PluginPage from '@/components/Plugin'; +import { DEFAULT_UPSTREAM } from '@/components/Upstream'; +import Preview from './components/Preview'; +import Step1 from "./components/Step1"; +import { create, update, fetchItem } from './service'; + +const { Step } = Steps; + +const Page: React.FC = (props) => { + const { formatMessage } = useIntl(); + const [form] = Form.useForm(); + const [upstreamForm] = Form.useForm(); + const upstreamRef = useRef(); + const [plugins, setPlugins] = useState({}); + + const STEP_HEADER = [ + formatMessage({ id: 'page.service.steps.stepTitle.basicInformation' }), + formatMessage({ id: 'page.service.steps.stepTitle.pluginConfig' }), + formatMessage({ id: 'component.global.steps.stepTitle.preview' }), + ] + + const [stepHeader] = useState(STEP_HEADER); + const [step, setStep] = useState(1); + + useEffect(() => { + + // init upstream default value + upstreamForm.setFieldsValue(DEFAULT_UPSTREAM); + + const { serviceId } = (props as any).match.params; + if (serviceId) { + fetchItem(serviceId).then(({ data }) => { + if (data.upstream_id && data.upstream_id !== '') { + upstreamForm.setFieldsValue({ upstream_id: data.upstream_id }); + } + if (data.upstream) { + upstreamForm.setFieldsValue(data.upstream); + } + form.setFieldsValue(omit(data, ['upstream_id', 'upstream', 'plugins'])); + setPlugins(data.plugins || {}); + }); + } + }, []); + + const onSubmit = () => { + const data = { + ...form.getFieldsValue(), + plugins, + }; + + const upstreamFormData = upstreamForm.getFieldsValue(); + if (upstreamFormData.upstream_id === '') { + data.upstream = omit(upstreamFormData, ['upstream_id']); + } else { + data.upstream_id = upstreamFormData.upstream_id; + } + + const { serviceId } = (props as any).match.params; + (serviceId ? update(serviceId, data) : create(data)) + .then(() => { + notification.success({ + message: `${serviceId + ? formatMessage({ id: 'component.global.edit' }) + : formatMessage({ id: 'component.global.create' }) + } ${formatMessage({ id: 'menu.service' })} ${formatMessage({ + id: 'component.status.success', + })}`, + }); + history.push('/service/list'); + }) + .catch(() => { + setStep(3); + }); + }; + + const onStepChange = (nextStep: number) => { + if (step === 1 && nextStep === 2) { + form.validateFields().then(() => { + upstreamForm.validateFields().then(() => { + setStep(nextStep); + }) + }) + return; + } + if (nextStep === 4) { + onSubmit(); + return; + }; + setStep(nextStep); + } + + return (<> + + + + {stepHeader.map((item) => ( + + ))} + + {step === 1 && } + {step === 2 && ( + + )} + {step === 3 && } + + + + ) +} + +export default Page; diff --git a/web/src/pages/Service/List.tsx b/web/src/pages/Service/List.tsx new file mode 100644 index 0000000000..e3e5b99413 --- /dev/null +++ b/web/src/pages/Service/List.tsx @@ -0,0 +1,103 @@ +/* + * 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, { useRef } from 'react'; +import { history, useIntl } from 'umi'; +import { PageHeaderWrapper } from '@ant-design/pro-layout'; +import ProTable, { ActionType, ProColumns } from '@ant-design/pro-table'; +import { PlusOutlined } from '@ant-design/icons'; +import { Button, notification, Popconfirm, Space } from 'antd'; + +import { fetchList, remove } from './service'; + +const Page: React.FC = () => { + const ref = useRef(); + const { formatMessage } = useIntl(); + + const columns: ProColumns[] = [ + { + title: 'ID', + dataIndex: 'id', + hideInSearch: true + }, + { + title: formatMessage({ id: 'component.global.name' }), + dataIndex: 'name', + }, + { + title: formatMessage({ id: 'component.global.description' }), + dataIndex: 'desc', + }, + { + title: formatMessage({ id: 'component.global.operation' }), + valueType: 'option', + hideInSearch: true, + render: (_, record) => ( + <> + + + { + remove(record.id!).then(() => { + notification.success({ + message: `${formatMessage({ id: 'component.global.delete' })} ${formatMessage({ + id: 'menu.service', + })} ${formatMessage({ id: 'component.status.success' })}`, + }); + /* eslint-disable no-unused-expressions */ + ref.current?.reload(); + }); + }} + okText={formatMessage({ id: 'component.global.confirm' })} + cancelText={formatMessage({ id: 'component.global.cancel' })} + > + + + + + ), + }, + ]; + + return ( + + actionRef={ref} + rowKey="id" + columns={columns} + request={fetchList} + toolBarRender={() => [ + , + ]} /> + ) +} + +export default Page; diff --git a/web/src/pages/Service/components/Preview.tsx b/web/src/pages/Service/components/Preview.tsx new file mode 100644 index 0000000000..5645224861 --- /dev/null +++ b/web/src/pages/Service/components/Preview.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormInstance } from 'antd/lib/form'; + +import PluginPage from '@/components/Plugin'; +import Step1 from './Step1'; + +type Props = { + form: FormInstance; + upstreamForm: FormInstance; + plugins: PluginComponent.Data; +}; + +const Page: React.FC = ({ form, plugins, upstreamForm }) => { + return ( + <> + + + + ); +}; + +export default Page; diff --git a/web/src/pages/Service/components/Step1.tsx b/web/src/pages/Service/components/Step1.tsx new file mode 100644 index 0000000000..d1f508d8ff --- /dev/null +++ b/web/src/pages/Service/components/Step1.tsx @@ -0,0 +1,71 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { Form, Input } from 'antd'; +import { useIntl } from 'umi'; + +import UpstreamForm from '@/components/Upstream'; +import { fetchUpstreamList } from '../service'; + +const FORM_LAYOUT = { + labelCol: { + span: 3, + }, + wrapperCol: { + span: 8, + }, +}; + +const Step1: React.FC = ({ + form, + upstreamForm, + upstreamRef, + disabled, +}) => { + const { formatMessage } = useIntl(); + const [list, setList] = useState([]); + useEffect(() => { + fetchUpstreamList().then(({ data }) => setList(data)); + }, []); + + return <> +
+ + + + + + +
+ + +} + +export default Step1; diff --git a/web/src/pages/Service/locales/en-US.ts b/web/src/pages/Service/locales/en-US.ts new file mode 100644 index 0000000000..2923865b73 --- /dev/null +++ b/web/src/pages/Service/locales/en-US.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ +export default { + 'page.service.steps.stepTitle.basicInformation': 'Basic Information', + 'page.service.steps.stepTitle.pluginConfig': 'Plugin Config', + 'page.service.steps.stepTitle.preview': 'Preview', +} diff --git a/web/src/pages/Service/locales/zh-CN.ts b/web/src/pages/Service/locales/zh-CN.ts new file mode 100644 index 0000000000..53fda037a3 --- /dev/null +++ b/web/src/pages/Service/locales/zh-CN.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ +export default { + 'page.service.steps.stepTitle.basicInformation': '基本信息', + 'page.service.steps.stepTitle.pluginConfig': '插件配置', + 'page.service.steps.stepTitle.preview': '预览', +} diff --git a/web/src/pages/Service/service.ts b/web/src/pages/Service/service.ts new file mode 100644 index 0000000000..1b541b29bb --- /dev/null +++ b/web/src/pages/Service/service.ts @@ -0,0 +1,53 @@ +/* + * 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 { request } from 'umi'; + +export const fetchList = ({ current = 1, pageSize = 10, ...res }) => + request('/services', { + params: { + name: res.name, + page: current, + page_size: pageSize, + }, + }).then(({ data }) => ({ + data: data.rows, + total: data.total_size, + })); + +export const fetchUpstreamList = () => { + return request>>('/upstreams').then(({ data }) => ({ + data: data.rows, + total: data.total_size, + })); +}; + +export const create = (data: ServiceModule.Entity) => + request('/services', { + method: 'POST', + data, + }); + +export const update = (serviceId: string, data: ServiceModule.Entity) => + request(`/services/${serviceId}`, { + method: 'PUT', + data, + }); + +export const remove = (serviceId: string) => request(`/services/${serviceId}`, { method: 'DELETE' }); + +export const fetchItem = (serviceId: number) => + request(`/services/${serviceId}`).then((data) => (data)); diff --git a/web/src/pages/Service/typing.d.ts b/web/src/pages/Service/typing.d.ts new file mode 100644 index 0000000000..a9e52d041a --- /dev/null +++ b/web/src/pages/Service/typing.d.ts @@ -0,0 +1,47 @@ +/* + * 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. + */ +declare namespace ServiceModule { + + type Entity = { + name: string; + desc: string; + upstream: any; + upstream_id: string; + labels: string; + enable_websocket: boolean; + plugins: { + [name: string]: any; + }; + }; + + type ResponseBody = { + id: string, + plugins: Record, + upstream_id: string, + upstream: Record, + name: string, + desc: string, + enable_websocket: boolean, + } + + type Step1PassProps = { + form: FormInstance; + upstreamForm: FormInstance; + disabled?: boolean; + upstreamRef: any; + }; +}