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 2020/12/31 01:00:01 UTC

[apisix-dashboard] branch master updated: feat(FE): version manager (#1157)

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 2dea025  feat(FE): version manager (#1157)
2dea025 is described below

commit 2dea0251d2f3cd215aa0a9fa29cc7656ec0d120e
Author: 琚致远 <ju...@apache.org>
AuthorDate: Wed Dec 30 18:59:53 2020 -0600

    feat(FE): version manager (#1157)
    
    * style: codes-format
    
    * feat: use labels form item
    
    * style: codes-format
    
    * feat: adjust buttons
    
    * style: 调整按钮位置
    
    * feat: 增加警告标示
    
    * feat: added version
    
    * feat: added version
    
    * fix: redirect
    
    * style: format codes
    
    * fix: style
---
 web/src/components/Plugin/PluginDetail.tsx         |   4 +-
 web/src/components/Plugin/PluginPage.tsx           |   2 +-
 web/src/constants.ts                               |   2 +-
 web/src/hooks/useForceIntl.ts                      |   2 +-
 web/src/locales/en-US/component.ts                 |   2 +
 web/src/locales/zh-CN/component.ts                 |   2 +
 web/src/pages/Consumer/Create.tsx                  |  26 +++--
 web/src/pages/Plugin/PluginMarket.tsx              |   4 +-
 web/src/pages/Plugin/components/Step1.tsx          |  16 ---
 web/src/pages/Plugin/typing.d.ts                   |   2 +-
 web/src/pages/Route/Create.tsx                     |   4 +-
 web/src/pages/Route/List.tsx                       | 130 ++++++++++++++-------
 .../pages/Route/components/Step1/LabelsDrawer.tsx  |  25 ++--
 web/src/pages/Route/components/Step1/MetaView.tsx  | 110 +++++++++++------
 web/src/pages/Route/service.ts                     |   4 +-
 web/src/pages/Route/transform.ts                   |  34 ++++--
 web/src/pages/Route/typing.d.ts                    |  10 +-
 17 files changed, 233 insertions(+), 146 deletions(-)

diff --git a/web/src/components/Plugin/PluginDetail.tsx b/web/src/components/Plugin/PluginDetail.tsx
index 932dee6..2ff336c 100644
--- a/web/src/components/Plugin/PluginDetail.tsx
+++ b/web/src/components/Plugin/PluginDetail.tsx
@@ -67,8 +67,8 @@ const PluginDetail: React.FC<Props> = ({
   visible,
   readonly = false,
   initialData = {},
-  onClose = () => { },
-  onChange = () => { },
+  onClose = () => {},
+  onChange = () => {},
 }) => {
   const { formatMessage } = useIntl();
   const [form] = Form.useForm();
diff --git a/web/src/components/Plugin/PluginPage.tsx b/web/src/components/Plugin/PluginPage.tsx
index 272b00c..cde2897 100644
--- a/web/src/components/Plugin/PluginPage.tsx
+++ b/web/src/components/Plugin/PluginPage.tsx
@@ -48,7 +48,7 @@ const PluginPage: React.FC<Props> = ({
   initialData = {},
   schemaType = 'route',
   type = 'scoped',
-  onChange = () => { },
+  onChange = () => {},
 }) => {
   const [pluginList, setPluginList] = useState<PluginComponent.Meta[]>([]);
   const [name, setName] = useState<string>(NEVER_EXIST_PLUGIN_FLAG);
diff --git a/web/src/constants.ts b/web/src/constants.ts
index 217cf40..3df1c0c 100644
--- a/web/src/constants.ts
+++ b/web/src/constants.ts
@@ -32,4 +32,4 @@ export const codeMessage = {
   504: '网关超时。',
 };
 
-export const DEFAULT_GLOBAL_RULE_ID = "1"
+export const DEFAULT_GLOBAL_RULE_ID = '1';
diff --git a/web/src/hooks/useForceIntl.ts b/web/src/hooks/useForceIntl.ts
index 8f67bef..890a16a 100644
--- a/web/src/hooks/useForceIntl.ts
+++ b/web/src/hooks/useForceIntl.ts
@@ -29,7 +29,7 @@ const useForceIntl = () => {
     }
 
     const { locale } = getIntl();
-    if (locale === 'zh-cn') {
+    if (locale === 'zh-CN') {
       return;
     }
 
diff --git a/web/src/locales/en-US/component.ts b/web/src/locales/en-US/component.ts
index 946a735..fa58634 100644
--- a/web/src/locales/en-US/component.ts
+++ b/web/src/locales/en-US/component.ts
@@ -28,6 +28,7 @@ export default {
   'component.global.add': 'Add',
   'component.global.save': 'Save',
   'component.global.edit': 'Edit',
+  'component.global.manage': 'Manage',
   'component.global.update': 'Update',
   'component.global.get': 'Get',
   'component.global.edit.plugin': 'Edit plugin',
@@ -35,6 +36,7 @@ export default {
   'component.global.list': 'List',
   'component.global.description': 'Description',
   'component.global.labels': 'Labels',
+  'component.global.version': 'Version',
   'component.global.operation': 'Operation',
   'component.status.success': 'Successfully',
   'component.status.fail': 'Failed',
diff --git a/web/src/locales/zh-CN/component.ts b/web/src/locales/zh-CN/component.ts
index d0829c6..210dd86 100644
--- a/web/src/locales/zh-CN/component.ts
+++ b/web/src/locales/zh-CN/component.ts
@@ -28,6 +28,7 @@ export default {
   'component.global.add': '新建',
   'component.global.save': '保存',
   'component.global.edit': '编辑',
+  'component.global.manage': '管理',
   'component.global.update': '更新',
   'component.global.get': '获取',
   'component.global.edit.plugin': '编辑插件',
@@ -35,6 +36,7 @@ export default {
   'component.global.list': '列表',
   'component.global.description': '描述',
   'component.global.labels': '标签',
+  'component.global.version': '版本',
   'component.global.operation': '操作',
   'component.status.success': '成功',
   'component.status.fail': '失败',
diff --git a/web/src/pages/Consumer/Create.tsx b/web/src/pages/Consumer/Create.tsx
index 2bfdfe4..217c335 100644
--- a/web/src/pages/Consumer/Create.tsx
+++ b/web/src/pages/Consumer/Create.tsx
@@ -29,7 +29,7 @@ import { fetchItem, create, update, fetchPlugList } from './service';
 const Page: React.FC = (props) => {
   const [step, setStep] = useState(1);
   const [plugins, setPlugins] = useState<PluginComponent.Data>({});
-  const [pluginList, setPluginList] = useState<PluginComponent.Meta[]>([])
+  const [pluginList, setPluginList] = useState<PluginComponent.Meta[]>([]);
   const [form1] = Form.useForm();
   const { formatMessage } = useIntl();
 
@@ -52,12 +52,13 @@ const Page: React.FC = (props) => {
     (username ? update(username, data) : create(data))
       .then(() => {
         notification.success({
-          message: `${username
-            ? formatMessage({ id: 'component.global.edit' })
-            : formatMessage({ id: 'component.global.create' })
-            } ${formatMessage({ id: 'menu.consumer' })} ${formatMessage({
-              id: 'component.status.success',
-            })}`,
+          message: `${
+            username
+              ? formatMessage({ id: 'component.global.edit' })
+              : formatMessage({ id: 'component.global.create' })
+          } ${formatMessage({ id: 'menu.consumer' })} ${formatMessage({
+            id: 'component.status.success',
+          })}`,
         });
         history.push('/consumer/list');
       })
@@ -76,7 +77,7 @@ const Page: React.FC = (props) => {
       if (
         !Object.keys(plugins).filter(
           (name) =>
-            (pluginList.find(item => item.name === name)!.type === 'auth') &&
+            pluginList.find((item) => item.name === name)!.type === 'auth' &&
             !plugins[name].disable,
         ).length
       ) {
@@ -98,10 +99,11 @@ const Page: React.FC = (props) => {
   return (
     <>
       <PageContainer
-        title={`${(props as any).match.params.id
-          ? formatMessage({ id: 'component.global.edit' })
-          : formatMessage({ id: 'component.global.create' })
-          } ${formatMessage({ id: 'menu.consumer' })}`}
+        title={`${
+          (props as any).match.params.id
+            ? formatMessage({ id: 'component.global.edit' })
+            : formatMessage({ id: 'component.global.create' })
+        } ${formatMessage({ id: 'menu.consumer' })}`}
       >
         <Card bordered={false}>
           <Steps current={step - 1} style={{ marginBottom: 30 }}>
diff --git a/web/src/pages/Plugin/PluginMarket.tsx b/web/src/pages/Plugin/PluginMarket.tsx
index 3613e00..44032ec 100644
--- a/web/src/pages/Plugin/PluginMarket.tsx
+++ b/web/src/pages/Plugin/PluginMarket.tsx
@@ -48,9 +48,9 @@ const PluginMarket: React.FC = () => {
                 ...pluginsData,
               },
             }).then(() => {
-              // TODO: 
+              // TODO:
               window.location.reload();
-            })
+            });
           }}
         />
       </Card>
diff --git a/web/src/pages/Plugin/components/Step1.tsx b/web/src/pages/Plugin/components/Step1.tsx
deleted file mode 100644
index 2944f98..0000000
--- a/web/src/pages/Plugin/components/Step1.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * 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.
- */
diff --git a/web/src/pages/Plugin/typing.d.ts b/web/src/pages/Plugin/typing.d.ts
index f0f3ac9..6f9c31b 100644
--- a/web/src/pages/Plugin/typing.d.ts
+++ b/web/src/pages/Plugin/typing.d.ts
@@ -25,5 +25,5 @@ declare namespace PluginModule {
   type GlobalRule = {
     id: string;
     plugins: Record<string, object>;
-  }
+  };
 }
diff --git a/web/src/pages/Route/Create.tsx b/web/src/pages/Route/Create.tsx
index a3d5f53..ab1257f 100644
--- a/web/src/pages/Route/Create.tsx
+++ b/web/src/pages/Route/Create.tsx
@@ -112,8 +112,8 @@ const Page: React.FC<Props> = (props) => {
             if (action === 'advancedMatchingRulesChange') {
               setAdvancedMatchingRules(data);
             }
-            if (action === 'labelsChange') {
-              form1.setFieldsValue({ ...form1.getFieldsValue(), labels: data });
+            if (action === 'custom_normal_labels') {
+              form1.setFieldsValue({ custom_normal_labels: data });
             }
           }}
           isEdit={props.route.path.indexOf('edit') > 0}
diff --git a/web/src/pages/Route/List.tsx b/web/src/pages/Route/List.tsx
index 304ad3b..4a6c8a6 100644
--- a/web/src/pages/Route/List.tsx
+++ b/web/src/pages/Route/List.tsx
@@ -103,11 +103,13 @@ const Page: React.FC = () => {
       title: formatMessage({ id: 'component.global.labels' }),
       dataIndex: 'labels',
       render: (_, record) => {
-        return Object.keys(record.labels || {}).map((item) => (
-          <Tag key={Math.random().toString(36).slice(2)}>
-            {item}:{record.labels[item]}
-          </Tag>
-        ));
+        return Object.keys(record.labels || {})
+          .filter((item) => item !== 'API_VERSION')
+          .map((item) => (
+            <Tag key={Math.random().toString(36).slice(2)}>
+              {item}:{record.labels[item]}
+            </Tag>
+          ));
       },
       renderFormItem: (_, { type }) => {
         if (type === 'form') {
@@ -127,18 +129,53 @@ const Page: React.FC = () => {
               );
             }}
           >
-            {Object.keys(labelList).map((key) => {
-              return (
-                <OptGroup label={key} key={Math.random().toString(36).slice(2)}>
-                  {(labelList[key] || []).map((value: string) => (
-                    <Option key={Math.random().toString(36).slice(2)} value={`${key}:${value}`}>
-                      {' '}
-                      {value}{' '}
-                    </Option>
-                  ))}
-                </OptGroup>
-              );
-            })}
+            {Object.keys(labelList)
+              .filter((item) => item !== 'API_VERSION')
+              .map((key) => {
+                return (
+                  <OptGroup label={key} key={Math.random().toString(36).slice(2)}>
+                    {(labelList[key] || []).map((value: string) => (
+                      <Option key={Math.random().toString(36).slice(2)} value={`${key}:${value}`}>
+                        {' '}
+                        {value}{' '}
+                      </Option>
+                    ))}
+                  </OptGroup>
+                );
+              })}
+          </Select>
+        );
+      },
+    },
+    {
+      title: formatMessage({ id: 'component.global.version' }),
+      dataIndex: 'API_VERSION',
+      render: (_, record) => {
+        return Object.keys(record.labels || {})
+          .filter((item) => item === 'API_VERSION')
+          .map((item) => record.labels[item]);
+      },
+      renderFormItem: (_, { type }) => {
+        if (type === 'form') {
+          return null;
+        }
+
+        return (
+          <Select style={{ width: '100%' }}>
+            {Object.keys(labelList)
+              .filter((item) => item === 'API_VERSION')
+              .map((key) => {
+                return (
+                  <OptGroup label={key} key={Math.random().toString(36).slice(2)}>
+                    {(labelList[key] || []).map((value: string) => (
+                      <Option key={Math.random().toString(36).slice(2)} value={`${key}:${value}`}>
+                        {' '}
+                        {value}{' '}
+                      </Option>
+                    ))}
+                  </OptGroup>
+                );
+              })}
           </Select>
         );
       },
@@ -169,36 +206,36 @@ const Page: React.FC = () => {
       render: (_, record) => (
         <>
           <Space align="baseline">
-            <Button
-              type="primary"
-              onClick={() => history.push(`/routes/${record.id}/edit`)}
-              style={{ marginRight: 10 }}
-            >
+            {!record.status ? (
+              <Button
+                type="primary"
+                onClick={() => {
+                  handlePublishOffline(record.id, RouteStatus.Publish);
+                }}
+              >
+                {formatMessage({ id: 'page.route.publish' })}
+              </Button>
+            ) : null}
+            {record.status ? (
+              <Popconfirm
+                title={formatMessage({ id: 'page.route.popconfirm.title.offline' })}
+                onConfirm={() => {
+                  handlePublishOffline(record.id, RouteStatus.Offline);
+                }}
+                okButtonProps={{
+                  danger: true,
+                }}
+                okText={formatMessage({ id: 'component.global.confirm' })}
+                cancelText={formatMessage({ id: 'component.global.cancel' })}
+              >
+                <Button type="primary" danger disabled={Boolean(!record.status)}>
+                  {formatMessage({ id: 'page.route.offline' })}
+                </Button>
+              </Popconfirm>
+            ) : null}
+            <Button type="primary" onClick={() => history.push(`/routes/${record.id}/edit`)}>
               {formatMessage({ id: 'component.global.edit' })}
             </Button>
-            <Button
-              type="primary"
-              onClick={() => {
-                handlePublishOffline(record.id, RouteStatus.Publish);
-              }}
-              style={{ marginRight: 10 }}
-              disabled={Boolean(record.status)}
-            >
-              {formatMessage({ id: 'page.route.publish' })}
-            </Button>
-            <Popconfirm
-              title={formatMessage({ id: 'page.route.popconfirm.title.offline' })}
-              onConfirm={() => {
-                handlePublishOffline(record.id, RouteStatus.Offline);
-              }}
-              okText={formatMessage({ id: 'component.global.confirm' })}
-              cancelText={formatMessage({ id: 'component.global.cancel' })}
-              disabled={Boolean(!record.status)}
-            >
-              <Button type="primary" danger disabled={Boolean(!record.status)}>
-                {formatMessage({ id: 'page.route.offline' })}
-              </Button>
-            </Popconfirm>
             <Popconfirm
               title={formatMessage({ id: 'component.global.popconfirm.title.delete' })}
               onConfirm={() => {
@@ -210,6 +247,9 @@ const Page: React.FC = () => {
                   );
                 });
               }}
+              okButtonProps={{
+                danger: true,
+              }}
               okText={formatMessage({ id: 'component.global.confirm' })}
               cancelText={formatMessage({ id: 'component.global.cancel' })}
             >
diff --git a/web/src/pages/Route/components/Step1/LabelsDrawer.tsx b/web/src/pages/Route/components/Step1/LabelsDrawer.tsx
index ba5b221..720a437 100644
--- a/web/src/pages/Route/components/Step1/LabelsDrawer.tsx
+++ b/web/src/pages/Route/components/Step1/LabelsDrawer.tsx
@@ -22,16 +22,20 @@ import { useIntl } from 'umi';
 import { transformLableValueToKeyValue } from '../../transform';
 import { fetchLabelList } from '../../service';
 
-interface Props extends Pick<RouteModule.Step1PassProps, 'onChange'> {
-  labelsDataSource: string[];
+type Props = {
+  title?: string;
+  actionName: string;
+  dataSource: string[];
   disabled: boolean;
   onClose(): void;
-}
+} & Pick<RouteModule.Step1PassProps, 'onChange'>;
 
 const LabelList = (disabled: boolean, labelList: RouteModule.LabelList) => {
   const { formatMessage } = useIntl();
 
-  const keyOptions = Object.keys(labelList || {}).map((item) => ({ value: item }));
+  const keyOptions = Object.keys(labelList || {})
+    .filter((item) => item !== 'API_VERSION')
+    .map((item) => ({ value: item }));
   return (
     <Form.List name="labels">
       {(fields, { add, remove }) => {
@@ -108,12 +112,14 @@ const LabelList = (disabled: boolean, labelList: RouteModule.LabelList) => {
 };
 
 const LabelsDrawer: React.FC<Props> = ({
-  disabled,
-  labelsDataSource,
+  title = 'Label Manager',
+  actionName = '',
+  disabled = false,
+  dataSource = [],
   onClose,
   onChange = () => {},
 }) => {
-  const transformLabel = transformLableValueToKeyValue(labelsDataSource);
+  const transformLabel = transformLableValueToKeyValue(dataSource);
 
   const { formatMessage } = useIntl();
   const [form] = Form.useForm();
@@ -126,12 +132,13 @@ const LabelsDrawer: React.FC<Props> = ({
 
   return (
     <Drawer
-      title="Edit labels"
+      title={title}
       placement="right"
       width={512}
       visible
       closable
       onClose={onClose}
+      maskClosable={false}
       footer={
         <div style={{ display: 'flex', justifyContent: 'space-between' }}>
           <Button onClick={onClose}>{formatMessage({ id: 'component.global.cancel' })}</Button>
@@ -152,7 +159,7 @@ const LabelsDrawer: React.FC<Props> = ({
                 }
 
                 onChange({
-                  action: 'labelsChange',
+                  action: actionName,
                   data,
                 });
                 onClose();
diff --git a/web/src/pages/Route/components/Step1/MetaView.tsx b/web/src/pages/Route/components/Step1/MetaView.tsx
index 25e1f31..09f64e5 100644
--- a/web/src/pages/Route/components/Step1/MetaView.tsx
+++ b/web/src/pages/Route/components/Step1/MetaView.tsx
@@ -14,38 +14,94 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import Form from 'antd/es/form';
-import { Input, Switch, Select, Button, Tag } from 'antd';
+import { Input, Switch, Select, Button, Tag, AutoComplete } from 'antd';
 import { useIntl } from 'umi';
 import { PanelSection } from '@api7-dashboard/ui';
 
 import { FORM_ITEM_WITHOUT_LABEL } from '@/pages/Route/constants';
 import LabelsDrawer from './LabelsDrawer';
+import { fetchLabelList } from '../../service';
 
 const MetaView: React.FC<RouteModule.Step1PassProps> = ({ disabled, form, isEdit, onChange }) => {
   const { formatMessage } = useIntl();
   const [visible, setVisible] = useState(false);
+  const [labelList, setLabelList] = useState<RouteModule.LabelList>({});
 
-  return (
-    <PanelSection title={formatMessage({ id: 'page.route.panelSection.title.nameDescription' })}>
-      {visible && (
-        <Form.Item shouldUpdate noStyle>
-          {() => {
-            if (form.getFieldValue('labels')) {
+  useEffect(() => {
+    // TODO: use a better state name
+    fetchLabelList().then(setLabelList);
+  }, []);
+
+  const NormalLabelComponent = () => {
+    const field = 'custom_normal_labels';
+    const title = 'Label Manager';
+
+    return (
+      <React.Fragment>
+        <Form.Item label={formatMessage({ id: 'component.global.labels' })} name={field}>
+          <Select
+            mode="tags"
+            style={{ width: '100%' }}
+            placeholder="--"
+            disabled={disabled}
+            open={false}
+            bordered={false}
+            tagRender={(props) => {
+              const { value, closable, onClose } = props;
+              return (
+                <Tag closable={closable && !disabled} onClose={onClose} style={{ marginRight: 3 }}>
+                  {value}
+                </Tag>
+              );
+            }}
+          />
+        </Form.Item>
+        <Form.Item {...FORM_ITEM_WITHOUT_LABEL}>
+          <Button type="dashed" disabled={disabled} onClick={() => setVisible(true)}>
+            {formatMessage({ id: 'component.global.manage' })}
+          </Button>
+        </Form.Item>
+        {visible && (
+          <Form.Item shouldUpdate noStyle>
+            {() => {
+              const labels = form.getFieldValue(field) || [];
               return (
                 <LabelsDrawer
-                  labelsDataSource={form.getFieldValue('labels')}
+                  title={title}
+                  actionName={field}
+                  dataSource={labels}
                   disabled={disabled || false}
                   onChange={onChange}
                   onClose={() => setVisible(false)}
                 />
               );
-            }
-            return null;
-          }}
+            }}
+          </Form.Item>
+        )}
+      </React.Fragment>
+    );
+  };
+
+  const VersionLabelComponent = () => {
+    return (
+      <React.Fragment>
+        <Form.Item
+          label={formatMessage({ id: 'component.global.version' })}
+          name="custom_version_label"
+        >
+          <AutoComplete
+            options={(labelList.API_VERSION || []).map((item) => ({ value: item }))}
+            disabled={disabled}
+          />
         </Form.Item>
-      )}
+      </React.Fragment>
+    );
+  };
+
+  return (
+    <PanelSection title={formatMessage({ id: 'page.route.panelSection.title.nameDescription' })}>
       <Form.Item
         label={formatMessage({ id: 'component.global.name' })}
         name="name"
@@ -70,35 +126,17 @@ const MetaView: React.FC<RouteModule.Step1PassProps> = ({ disabled, form, isEdit
           disabled={disabled}
         />
       </Form.Item>
-      <Form.Item label={formatMessage({ id: 'component.global.labels' })} name="labels">
-        <Select
-          mode="tags"
-          style={{ width: '100%' }}
-          placeholder="--"
-          disabled={disabled}
-          open={false}
-          bordered={false}
-          tagRender={(props) => {
-            const { value, closable, onClose } = props;
-            return (
-              <Tag closable={closable && !disabled} onClose={onClose} style={{ marginRight: 3 }}>
-                {value}
-              </Tag>
-            );
-          }}
-        />
-      </Form.Item>
-      <Form.Item {...FORM_ITEM_WITHOUT_LABEL}>
-        <Button disabled={disabled} onClick={() => setVisible(true)}>
-          {formatMessage({ id: 'component.global.edit' })}
-        </Button>
-      </Form.Item>
+
+      <NormalLabelComponent />
+      <VersionLabelComponent />
+
       <Form.Item label={formatMessage({ id: 'component.global.description' })} name="desc">
         <Input.TextArea
           placeholder={formatMessage({ id: 'component.global.input.placeholder.description' })}
           disabled={disabled}
         />
       </Form.Item>
+
       <Form.Item
         label={formatMessage({ id: 'page.route.publish' })}
         name="status"
diff --git a/web/src/pages/Route/service.ts b/web/src/pages/Route/service.ts
index bc1a469..f67283f 100644
--- a/web/src/pages/Route/service.ts
+++ b/web/src/pages/Route/service.ts
@@ -40,12 +40,12 @@ export const fetchItem = (rid: number) =>
   request(`/routes/${rid}`).then((data) => transformRouteData(data.data));
 
 export const fetchList = ({ current = 1, pageSize = 10, ...res }) => {
-  const { labels } = res;
+  const { labels, API_VERSION } = res;
   return request<Res<ResListData<RouteModule.ResponseBody>>>('/routes', {
     params: {
       name: res.name,
       uri: res.uri,
-      label: (labels || []).join(','),
+      label: (labels || []).concat(API_VERSION).join(','),
       page: current,
       page_size: pageSize,
     },
diff --git a/web/src/pages/Route/transform.ts b/web/src/pages/Route/transform.ts
index 401b15d..5b10a8c 100644
--- a/web/src/pages/Route/transform.ts
+++ b/web/src/pages/Route/transform.ts
@@ -25,12 +25,15 @@ export const transformLableValueToKeyValue = (data: string[]) => {
   });
 };
 
+// Transform Route data then sent to API
 export const transformStepData = ({
   form1Data,
   form2Data,
   advancedMatchingRules,
   step3Data,
 }: RouteModule.RequestData) => {
+  const { custom_normal_labels, custom_version_label, service_id = '' } = form1Data;
+
   let redirect: RouteModule.Redirect = {};
   const step3DataCloned = cloneDeep(step3Data);
   if (form1Data.redirectOption === 'disabled') {
@@ -44,14 +47,16 @@ export const transformStepData = ({
     };
   }
 
-  const labels = {};
-  transformLableValueToKeyValue(form1Data.labels).forEach((item) => {
-    labels[item.labelKey] = item.labelValue;
+  const labels: Record<string, string> = {};
+  transformLableValueToKeyValue(custom_normal_labels).forEach(({ labelKey, labelValue }) => {
+    labels[labelKey] = labelValue;
   });
-  const { service_id = '' } = form1Data;
+  if (custom_version_label) {
+    labels.API_VERSION = custom_version_label;
+  }
 
   const data: Partial<RouteModule.Body> = {
-    ...omit(form1Data, 'labels'),
+    ...form1Data,
     labels,
     ...step3DataCloned,
     vars: advancedMatchingRules.map((rule) => {
@@ -81,7 +86,7 @@ export const transformStepData = ({
     }
 
     if (redirect.http_to_https) {
-      if (Object.keys(data.plugins!).length === 0) {
+      if (Object.keys(data.plugins || {}).length === 0) {
         data.plugins = {};
       }
       data.plugins!.redirect = redirect;
@@ -92,6 +97,8 @@ export const transformStepData = ({
 
     // Remove some of the frontend custom variables
     return omit(data, [
+      'custom_version_label',
+      'custom_normal_labels',
       'advancedMatchingRules',
       'upstreamHostList',
       'upstreamPath',
@@ -100,8 +107,8 @@ export const transformStepData = ({
       'ret_code',
       'redirectOption',
       service_id.length === 0 ? 'service_id' : '',
-      !Object.keys(step3DataCloned.plugins || {}).length ? 'plugins' : '',
-      !Object.keys(step3DataCloned.script || {}).length ? 'script' : '',
+      !Object.keys(data.plugins || {}).length ? 'plugins' : '',
+      !Object.keys(data.script || {}).length ? 'script' : '',
       form1Data.hosts.filter(Boolean).length === 0 ? 'hosts' : '',
       form1Data.redirectOption === 'disabled' ? 'redirect' : '',
       data.remote_addrs?.filter(Boolean).length === 0 ? 'remote_addrs' : '',
@@ -123,6 +130,7 @@ export const transformStepData = ({
     service_id.length !== 0 ? 'service_id' : '',
     form1Data.hosts.filter(Boolean).length !== 0 ? 'hosts' : '',
     data.remote_addrs?.filter(Boolean).length !== 0 ? 'remote_addrs' : '',
+    form1Data.custom_version_label.length !== 0 ? 'labels' : '',
   ]);
 };
 
@@ -154,11 +162,12 @@ export const transformUpstreamNodes = (
   return data;
 };
 
+// Transform response's data
 export const transformRouteData = (data: RouteModule.Body) => {
   const {
     name,
     desc,
-    labels,
+    labels = {},
     methods = [],
     uris,
     uri,
@@ -173,6 +182,7 @@ export const transformRouteData = (data: RouteModule.Body) => {
     priority = 0,
     enable_websocket,
   } = data;
+
   const form1Data: Partial<RouteModule.Form1Data> = {
     name,
     desc,
@@ -180,7 +190,11 @@ export const transformRouteData = (data: RouteModule.Body) => {
     hosts: hosts || (host && [host]) || [''],
     uris: uris || (uri && [uri]) || [],
     remote_addrs: remote_addrs || [''],
-    labels: Object.keys(labels || []).map((item) => `${item}:${labels[item]}`),
+    // NOTE: API_VERSION is a system label
+    custom_version_label: labels.API_VERSION || '',
+    custom_normal_labels: Object.keys(labels)
+      .filter((item) => item !== 'API_VERSION')
+      .map((key) => `${key}:${labels[key]}`),
     // @ts-ignore
     methods: methods.length ? methods : ['ALL'],
     priority,
diff --git a/web/src/pages/Route/typing.d.ts b/web/src/pages/Route/typing.d.ts
index 6b87a0b..60afe5f 100644
--- a/web/src/pages/Route/typing.d.ts
+++ b/web/src/pages/Route/typing.d.ts
@@ -83,7 +83,7 @@ declare namespace RouteModule {
     remote_addrs: string[];
     vars: [string, Operator, string][];
     upstream: {
-      type: 'roundrobin' | 'chash';
+      type: 'roundrobin' | 'chash' | 'ewma';
       hash_on?: string;
       key?: string;
       nodes: {
@@ -134,16 +134,14 @@ declare namespace RouteModule {
     advancedMatchingRules: MatchingRule[];
     disabled?: boolean;
     isEdit?: boolean;
-    onChange?(data: {
-      action: 'redirectOptionChange' | 'advancedMatchingRulesChange' | 'labelsChange';
-      data: T;
-    }): void;
+    onChange?(data: { action: string; data: T }): void;
   };
 
   type Form1Data = {
     name: string;
     desc: string;
-    labels: string[];
+    custom_version_label: string;
+    custom_normal_labels: string[];
     priority: number;
     websocket: boolean;
     hosts: string[];